在上一篇文章中,我们通过肉眼检查了光变曲线中的凌星信号。但真正的数据分析需要量化方法——用数学来比较不同模型的优劣,并用计算机算法自动搜索周期性信号。
本文将逐步实现这些方法,最终用真实的 NASA Kepler 太空望远镜数据验证我们的技术。
1. 模型比较:哪个模型更好?
1.1 绘制观测数据
首先创建一条简单的光变曲线——5个亮度的观测值随时间变化:
import numpy as npimport matplotlib.pyplot as plt
time = np.array([1, 2, 3, 4, 5])brightness = np.array([1.02, 0.98, 1.01, 0.99, 1.00])
plt.scatter(time, brightness)plt.plot(time, brightness)
plt.xlabel('Time measurements')plt.ylabel('Brightness')
plt.scatter() 绘制散点(实际观测值),plt.plot() 用折线连接它们以展示变化趋势。亮度在 1.0 附近上下波动。
1.2 最简单的模型:恒定亮度
如果我们假设恒星亮度始终不变,那么可以构建一个”恒定模型”——将所有时间的预测值设为 1.00:
model_brightness = 1.00predicted = np.array([model_brightness] * len(brightness))
print(predicted)plt.scatter(time, brightness)plt.plot(time, predicted)
输出:
[1. 1. 1. 1. 1.][model_brightness] * len(brightness) 将标量值重复 5 次,创建一个长度匹配的数组。
1.3 计算残差(误差)
残差 = 预测值 − 观测值。正残差表示模型高估了亮度,负残差表示模型低估:
error = predicted - brightness
print('Observed brightness', brightness)print('Predicted brightness', predicted)print('Errors', error)输出:
Observed brightness [1.02 0.98 1.01 0.99 1. ]Predicted brightness [1. 1. 1. 1. 1.]Errors [-0.02 0.02 -0.01 0.01 0. ]1.4 残差总和的问题
将残差直接相加看似合理,但正负误差会相互抵消:
total_error = np.sum(error)
print('Errors', error)print('Sum of errors', total_error)输出:
Errors [-0.02 0.02 -0.01 0.01 0. ]Sum of errors 0.0残差总和为 0,但模型显然不完美!因为正的 +0.02 和负的 −0.02 相互抵消了。因此不能直接使用残差总和来衡量模型好坏。
1.5 残差平方和(SSE)
解决方案:将误差平方后再求和。平方后所有值都为正,不会相互抵消:
squared_error = (predicted - brightness) ** 2print('Errors:', error)print('Squared errors:', squared_error)
sum_squared_error = np.sum(squared_error)print('Sum of squared error:', sum_squared_error)输出:
Errors: [-0.02 0.02 -0.01 0.01 0. ]Squared errors: [0.0004 0.0004 0.0001 0.0001 0. ]Sum of squared error: 0.0010000000000000018SSE 越小,模型越贴合数据。平方操作放大了大误差的影响,惩罚了偏离程度较大的点。
1.6 比较两个模型
现在比较两个恒定亮度模型:model_1 = 1.00、model_2 = 0.97:
model_1 = 1.00model_2 = 0.97
predicted_1 = np.array([model_1] * len(brightness))predicted_2 = np.array([model_2] * len(brightness))errors_1 = predicted_1 - brightnesserrors_2 = predicted_2 - brightness
squared_error_1 = errors_1 ** 2squared_error_2 = errors_2 ** 2
total_error_1 = np.sum(squared_error_1)total_error_2 = np.sum(squared_error_2)
print('Errors_1:', total_error_1)print('Errors_2:', total_error_2)
# model 1 is better输出:
Errors_1: 0.0010000000000000018Errors_2: 0.00550000000000001SSE₁ (0.001) < SSE₂ (0.0055),模型 1 更好。将两个模型可视化:
plt.scatter(time, brightness)plt.plot(time, predicted_1)plt.plot(time, predicted_2)
plt.xlabel("Time")plt.ylabel("Brightness")plt.title("Which model is better?")
橙色线(model_2 = 0.97)整体低于数据点,SSE 更大。蓝色线(model_1 = 1.00)更接近数据中心。
1.7 自动搜索最佳模型
手动测试两个模型效率太低。可以用 np.linspace 在可能范围内生成 101 个候选值,遍历计算每个的 SSE,最后用 np.argmin 找到 SSE 最小的模型:
possible_models = np.linspace(0.95, 1.05, 101)
sse_values = []
for model in possible_models: predicted = np.array([model] * len(brightness)) errors = brightness - predicted sse = np.sum(errors ** 2) sse_values.append(sse)
sse_values = np.array(sse_values)
best_index = np.argmin(sse_values)best_model = possible_models[best_index]best_sse = sse_values[best_index]
print("Best model brightness:", best_model)print("Smallest squared error:", best_sse)输出:
Best model brightness: 1.0Smallest squared error: 0.0010000000000000018最佳模型亮度 = 1.00,与我们之前的直观判断完全一致。这种”暴力搜索”方法虽然简单,但非常有效——它的核心思想与后续 BLS 算法搜索最佳周期一脉相承。
2. 比较含凌星与不含凌星的模型
2.1 生成含凌星的合成数据
创建一个 100 个点的光变曲线,第 4.2 至 5.0 天存在凌星(亮度降为 0.97),并叠加测量噪声:
time = np.linspace(0, 10, 100)true_brightness = np.ones_like(time)
in_transit_true = (time > 4.2) & (time < 5.0)true_brightness[in_transit_true] = 0.97
rng = np.random.default_rng(42)noise = rng.normal(0, 0.005, size=len(time))brightness = true_brightness + noise
plt.scatter(time, brightness)plt.show()
np.ones_like(time) 创建与 time 形状相同的全 1 数组。(time > 4.2) & (time < 5.0) 用布尔掩码标记凌星区间。
2.2 比较两个模型
“无凌星模型”假设亮度始终为 1.0;“有凌星模型”在第 4.2−5.0 天将亮度设为 0.97:
model_no_transit = np.ones_like(time)model_transit = np.ones_like(time)
in_transit_true = (time > 4.2) & (time < 5.0)model_transit[in_transit_true] = 0.97
sse_no_transit = np.sum((brightness - model_no_transit) ** 2)sse_transit = np.sum((brightness - model_transit) ** 2)print("SSE no transit model: ", sse_no_transit)print("SSE transit model: ", sse_transit)
plt.scatter(time, brightness)plt.plot(time, model_no_transit)plt.plot(time, model_transit)
含凌星模型的 SSE 更小——它更好地解释了数据。这说明用模型的优劣可以量化判断是否存在凌星信号。
3. 真实 Kepler 数据分析
3.1 安装工具包
从这一天开始,我们将使用真实的 NASA Kepler 太空望远镜数据。安装必要的 Python 包:
!pip -q install lightkurve astroquery astropylightkurve:专门用于分析 Kepler/K2/TESS 光变曲线的 Python 库astroquery:查询 NASA 天体物理数据库astropy:天文学核心工具库
import numpy as npimport pandas as pdimport matplotlib.pyplot as plt
import requestsfrom io import StringIO
import lightkurve as lkfrom astropy.timeseries import BoxLeastSquaresfrom scipy.ndimage import median_filter
print("Tools are ready.")输出:
Tools are ready.3.2 选择团队与搜索范围
每个团队被分配到不同的周期搜索范围,模拟真实科研中的分工协作:
TEAM = 1 # change to 1, 2, or 3
TEAM_INFO = { 1: {"emoji": "🔥", "name": "Brown dwarfs", "period_range": (0.5, 3.0)}, 2: {"emoji": "🌡", "name": "We came here for free wi-fi ", "period_range": (3.0, 10.0)}, 3: {"emoji": "🪐", "name": " Trinity ", "period_range": (10.0, 30.0)},}
info = TEAM_INFO[TEAM]p_lo, p_hi = info["period_range"]
print(f"Team {TEAM} {info['emoji']} — {info['name']}")print(f"Period range: {p_lo} to {p_hi} days")输出:
Team 1 🔥 — Brown dwarfsPeriod range: 0.5 to 3.0 days3.3 查询 NASA 系外行星数据库
通过 NASA Exoplanet Archive 的 TAP(Table Access Protocol)接口,使用 SQL 查询已确认的系外行星:
TAP_URL = "https://exoplanetarchive.ipac.caltech.edu/TAP/sync"
query = f"""select top 20 kepid, kepoi_name, kepler_name, koi_disposition, koi_period, koi_duration, koi_depth, koi_prad, koi_score, koi_kepmagfrom cumulativewhere koi_disposition = 'CONFIRMED' and koi_period is not null and koi_depth is not null and koi_period >= {p_lo} and koi_period < {p_hi} and koi_depth > 1000order by koi_depth desc"""
response = requests.get( TAP_URL, params={"query": query, "format": "csv"}, timeout=60)
response.raise_for_status()
archive_df = pd.read_csv(StringIO(response.text))
print("Downloaded targets:", len(archive_df))
archive_df[[ "kepid", "kepoi_name", "kepler_name", "koi_disposition", "koi_period", "koi_duration", "koi_depth", "koi_prad", "koi_score"]].head(10)输出:
Downloaded targets: 20
kepid kepoi_name kepler_name koi_disposition koi_period \0 5794240 K00254.01 Kepler-45 b CONFIRMED 2.4552411 3749365 K01176.01 Kepler-785 b CONFIRMED 1.9737612 11517719 K01416.01 Kepler-840 b CONFIRMED 2.4957803 10619192 K00203.01 Kepler-17 b CONFIRMED 1.4857114 7532973 K01450.01 Kepler-854 b CONFIRMED 2.1446325 9651668 K00183.01 Kepler-423 b CONFIRMED 2.6843296 11414511 K00767.01 Kepler-670 b CONFIRMED 2.8165057 3935914 K00809.01 Kepler-686 b CONFIRMED 1.5947458 7849854 K00897.01 Kepler-718 b CONFIRMED 2.0523509 11446443 K00001.01 Kepler-1 b CONFIRMED 2.470613
koi_duration koi_depth koi_prad koi_score0 1.72722 36911.9 10.60 0.9991 1.82542 30222.4 8.78 0.9982 4.27210 25070.0 15.75 0.9203 2.28740 20823.9 14.48 1.0004 6.27010 19836.9 59.19 0.0415 2.69382 18136.5 12.73 0.9956 2.50587 16813.7 12.21 1.0007 1.96040 15044.5 11.30 1.0008 2.07780 14280.6 13.12 1.0009 1.74319 14230.9 13.04 0.811requests.get 发送 HTTP GET 请求,pd.read_csv(StringIO(response.text)) 将返回的 CSV 文本解析为 DataFrame。koi_depth 单位为 ppm(百万分之一),如 36911.9 ppm = 3.69%。
3.4 选择目标
选取第 8 颗星(Kepler-686 b)作为分析目标:
TARGET_INDEX = 7
target = archive_df.iloc[TARGET_INDEX]
TARGET_KEPID = int(target["kepid"])TARGET_KOI = target["kepoi_name"]TARGET_NAME = target["kepler_name"]TARGET_PERIOD = float(target["koi_period"])TARGET_DURATION_HOURS = float(target["koi_duration"])TARGET_DEPTH_PPM = float(target["koi_depth"])
print("Selected target")print("----------------")print("KIC ID:", TARGET_KEPID)print("KOI name:", TARGET_KOI)print("Kepler name:", TARGET_NAME)print("NASA period:", TARGET_PERIOD, "days")print("NASA duration:", TARGET_DURATION_HOURS, "hours")print("NASA depth:", TARGET_DEPTH_PPM, "ppm")print("NASA depth:", TARGET_DEPTH_PPM / 10000, "%")输出:
Selected target----------------KIC ID: 3935914KOI name: K00809.01Kepler name: Kepler-686 bNASA period: 1.594745374 daysNASA duration: 1.9604 hoursNASA depth: 15044.5 ppmNASA depth: 1.50445 %Kepler-686 b 的公转周期约 1.59 天(超短周期行星),凌星持续约 1.96 小时,深度约 1.5%。
3.5 下载 Kepler 光变曲线数据
lightkurve 库可以直接从 MAST(Mikulski Archive for Space Telescopes)下载 Kepler 数据:
search_result = lk.search_lightcurve( f"KIC {TARGET_KEPID}", mission="Kepler", cadence="long")
print(search_result)
lc_raw = search_result[0].download( quality_bitmask="default", flux_column="pdcsap_flux")
print(lc_raw)输出(部分):
SearchResult containing 15 data products.
# mission year author exptime target_name distance s arcsec--- ----------------- ---- ------ ------- ------------- -------- 0 Kepler Quarter 02 2009 Kepler 1800 kplr003935914 0.0 1 Kepler Quarter 03 2009 Kepler 1800 kplr003935914 0.0 ... 14 Kepler Quarter 17 2013 Kepler 1800 kplr003935914 0.0 time flux ... pos_corr1 pos_corr2 electron / s ... pix pix------------------ -------------- ... -------------- --------------169.76597543497337 7.6352046e+03 ... -5.9283964e-02 2.2301620e-01169.78640959970653 7.6372656e+03 ... -5.8712441e-02 2.2273061e-01...Length = 4075 rowsKepler 在 15 个季度(Quarter)中观测了这颗星,每季度约 90 天。cadence="long" 表示 30 分钟长曝光模式。pdcsap_flux 是经过系统误差校正的流量数据。
3.6 数据清洗
去除 NaN,并用中位数归一化:
time = lc_raw.time.valueflux = lc_raw.flux.value
ok = np.isfinite(time) & np.isfinite(flux)
time = time[ok]flux = flux[ok]
flux = flux / np.nanmedian(flux)
print("Number of points:", len(time))print("Time range:", time.min(), "to", time.max())print("Median normalized flux:", np.nanmedian(flux))
plt.scatter(time, flux, s=1)plt.xlabel('Time')plt.ylabel('Normalized brightness')
plt.title('Raw Kepler Data')
4. 相位折叠
4.1 计算相位的函数
如果知道行星的精确周期,可以将所有数据折叠到一个周期内——这就是相位折叠。% 取模运算符将时间”折叠”到一个周期内:
def compute_phase(time, period, t0=0): """ Convert time into phase between 0 and 1. Phase tells us where each point is inside one repeated cycle. """ return ((time - t0) % period) / period相位 0 到 1 表示一个完整周期的位置。% period 将任意时间映射回 [0, period) 区间,/ period 归一化到 [0, 1)。
4.2 试错法:用不同周期折叠
比较三个周期:偏短、偏长、以及 NASA 目录中的正确周期:
trial_periods = [ TARGET_PERIOD * 0.83, TARGET_PERIOD * 1.31, TARGET_PERIOD]
labels = [ "wrong period: too short", "wrong period: too long", "NASA catalog period"]
fig, axes = plt.subplots(1, 3, figsize=(15, 4), sharey=True)
for ax, period_try, label in zip(axes, trial_periods, labels): phase = compute_phase(time, period_try, time[0]) ax.scatter(phase, flux, s=2, alpha=0.25) ax.set_title(f"{label}\nP = {period_try:.3f} days") ax.set_xlabel("Phase") ax.grid(True)
axes[0].set_ylabel("Normalized brightness")
plt.suptitle(f"{TARGET_NAME} — manual folding at three trial periods")plt.tight_layout()plt.show()
只有使用正确周期时,凌星信号才会在相位图中清晰聚集在某一位置。 错误的周期会导致凌星数据点散布在整个相位空间中,无法形成明显的凹陷。
5. BLS 自动周期搜索
5.1 深度清洗
在进行自动搜索前,需要进一步清洗数据——去除极端异常值并使用中值滤波消除缓慢趋势:
# Step 1: remove extreme outliersmedian_flux = np.nanmedian(flux)std_flux = np.nanstd(flux)
mask = np.abs(flux - median_flux) < 5 * std_flux
time_clean = time[mask]flux_clean = flux[mask]
# Step 2: flatten slow trends with median filterwindow = 301
trend = median_filter(flux_clean, size=window)trend[trend == 0] = 1.0
flux_clean = flux_clean / trendflux_clean = flux_clean / np.nanmedian(flux_clean)
print("Before cleaning:", len(time), "points")print("After cleaning:", len(time_clean), "points")print("Raw flux std:", np.nanstd(flux))print("Clean flux std:", np.nanstd(flux_clean))输出:
Before cleaning: 4070 pointsAfter cleaning: 4046 pointsRaw flux std: 0.003044938Clean flux std: 0.0025537307median_filter 是 scipy.ndimage 提供的中值滤波器,size=301 表示用 301 个相邻点的中位数替代每个点,从而平滑掉缓慢的亮度趋势。清洗后标准差从 0.0030 降至 0.0026:
fig, axes = plt.subplots(2, 1, figsize=(12, 7), sharex=True)
axes[0].scatter(time, flux, s=3, alpha=0.4)axes[0].set_ylabel("Raw flux")axes[0].set_title("Before cleaning")axes[0].grid(True)
axes[1].scatter(time_clean, flux_clean, s=3, alpha=0.4)axes[1].set_xlabel("Time [days]")axes[1].set_ylabel("Cleaned flux")axes[1].set_title("After simple cleaning / flattening")axes[1].grid(True)
plt.tight_layout()plt.show()
5.2 BLS 搜索
**BLS(Box Least Squares,盒子最小二乘)**是系外行星探测中最经典的自动搜索算法。它的核心思想:用一个”盒子形”模型(亮度在凌星期间下降,其它时间正常),在不同周期和持续时间下进行拟合,找到 SSE 最小的参数组合:
bls_lo = max(0.2, TARGET_PERIOD * 0.7)bls_hi = TARGET_PERIOD * 1.3
periods = np.linspace(bls_lo, bls_hi, 3000)
durations = np.linspace( 0.01, min(0.3, TARGET_PERIOD * 0.2), 10)
print("Searching periods from", bls_lo, "to", bls_hi, "days")print("Number of trial periods:", len(periods))print("Durations:", durations)
bls_model = BoxLeastSquares(time_clean, flux_clean)
bls_result = bls_model.power(periods, durations)
best_index = np.argmax(bls_result.power)
best_period = float(np.asarray(bls_result.period)[best_index])best_duration = float(np.asarray(bls_result.duration)[best_index])best_t0 = float(np.asarray(bls_result.transit_time)[best_index])best_power = float(np.asarray(bls_result.power)[best_index])
print("BLS found")print("---------")print("Best period:", best_period, "days")print("NASA period:", TARGET_PERIOD, "days")print("Best duration:", best_duration * 24, "hours")print("Best transit time t0:", best_t0)print("BLS power:", best_power)
period_error = abs(best_period - TARGET_PERIOD) / TARGET_PERIOD * 100print("Period error:", period_error, "%")输出:
Searching periods from 1.116 to 2.073 daysNumber of trial periods: 3000Durations: [0.01 0.042 0.074 ... 0.3]BLS found---------Best period: 1.5945858462866953 daysNASA period: 1.594745374 daysBest duration: 1.776 hoursBest transit time t0: 170.653BLS power: 0.0111Period error: 0.01%BLS 找到的周期(1.5946 天)与 NASA 目录值(1.5947 天)仅差 0.01%。bls_model.power() 返回 BLS 功率谱——数值越高表示该周期的信号越显著。
5.3 BLS 周期图
plt.figure(figsize=(10, 4))
plt.plot(bls_result.period, bls_result.power)
plt.axvline( TARGET_PERIOD, linestyle="--", alpha=0.8, label=f"NASA period = {TARGET_PERIOD:.4f} d")
plt.axvline( best_period, linestyle="-", alpha=0.8, label=f"BLS period = {best_period:.4f} d")
plt.xlabel("Trial period [days]")plt.ylabel("BLS power")plt.title(f"BLS periodogram: {TARGET_NAME}")plt.legend()plt.grid(True)
plt.show()
BLS 功率谱在正确周期处出现明显峰值,两条垂直线几乎完全重合。
6. 相位折叠光变曲线与行星参数估算
6.1 用最佳周期折叠
phase = compute_phase(time_clean, best_period, best_t0)
# Move phase so transit is near 0phase_centered = ((time_clean - best_t0 + 0.5 * best_period) % best_period) / best_period - 0.5
plt.figure(figsize=(10, 4))
plt.scatter(phase_centered, flux_clean, s=4, alpha=0.35)
plt.axvline(-best_duration / (2 * best_period), color="red", linestyle="--", alpha=0.6)plt.axvline(best_duration / (2 * best_period), color="red", linestyle="--", alpha=0.6)
plt.xlabel("Phase")plt.ylabel("Normalized brightness")plt.title(f"Phase-folded light curve at BLS period: {best_period:.5f} days")plt.grid(True)
plt.show()
两根红色虚线标记了 BLS 估算的凌星持续时间区间。凌星信号在相位中心(0 附近)清晰可见。
6.2 估算行星半径
凌星深度与行星-恒星半径比的关系为:
因此 。若假设恒星半径等于太阳半径(109 地球半径),则行星半径 = 109 × :
in_transit = np.abs(phase_centered) < best_duration / (2 * best_period)
f_in = np.nanmedian(flux_clean[in_transit])f_out = np.nanmedian(flux_clean[~in_transit])
depth = (f_out - f_in) / f_out
radius_ratio = np.sqrt(max(depth, 0))
# Sun radius is about 109 Earth radii.planet_radius_earth = radius_ratio * 109.0
print("Estimated transit physics")print("-------------------------")print("F_out:", f_out)print("F_in:", f_in)print("Depth:", depth)print("Depth [%]:", depth * 100)print("Rp / Rstar:", radius_ratio)print("Planet radius if star is Sun-like:", planet_radius_earth, "Earth radii")输出:
Estimated transit physics-------------------------F_out: 1.0000432F_in: 0.98740995Depth: 0.01263266Depth [%]: 1.263266Rp / Rstar: 0.11239511Planet radius if star is Sun-like: 12.25 Earth radii估算行星半径约 12.25 地球半径,这是一个木星级大小的行星。如果将太阳替换为主序星,该半径与热木星(Hot Jupiter)的特征一致——超短周期(1.59 天)+ 大半径。
7. 本节小结
本节完成了以下内容:
- 引入了**残差平方和(SSE)**作为量化模型优劣的指标,解释了为何残差直接相加不可靠(正负抵消)。
- 通过遍历搜索实现了自动最优模型选取,核心思路与 BLS 算法一脉相承。
- 使用
lightkurve从 MAST 数据库下载了真实 Kepler 数据,通过 NASA Exoplanet Archive 的 SQL 查询获取了已知行星信息。 - 实现了相位折叠——利用取模运算将所有数据折叠到一个周期内。
- 使用 **BLS(盒子最小二乘)**算法自动搜索周期,结果与 NASA 目录值仅差 0.01%。
- 从凌星深度估算行星半径(~12 地球半径,木星级),完成了从光变曲线到行星物理参数的完整分析链路。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)
.webp)