第 36 章 · 散点图:两列之间的关系
这一章要解决什么:看出两列之间有没有关系、关系有多强。 本章最重要的不是 API,而是一个思维陷阱:共同趋势造成的假相关 —— 相关不等于因果。
本章你需要完成的代码:demos/ch36-scatter.py
36.1 点太多时的三个必备参数
fig, ax = plt.subplots(figsize=(8.6, 5.2))
ax.scatter(df["soh"], df["resistance_mohm"], s=5, alpha=0.4, linewidths=0)
ax.set_xlabel("SOH")
ax.set_ylabel("内阻 (mΩ)")
ax.set_title("内阻 vs SOH(散点)")结果:

点开:为什么 s / alpha / linewidths 是必备的?
默认参数的后果:
ax.scatter(df["soh"], df["resistance_mohm"]) # 全部用默认值问题:
① 默认点大小 s=36 —— 对 1.4 万个点来说太大了,点会互相覆盖
② 完全不透明 —— 重叠的点看不出密度
③ 每个点有白色描边 —— 密起来像一块黑板结果:一片糊成一团的黑色区域,什么也看不出来。
解决方案(三个参数):
| 参数 | 值 | 作用 |
|---|---|---|
s=5 | 点大小 | 从 36 降到 5,点不再互相覆盖 |
alpha=0.4 | 透明度 | 重叠的点颜色更深 → 能看出密度 |
linewidths=0 | 描边宽度 | 去掉白边,否则密起来像黑板 |
★ alpha 是最关键的一个
为什么:它把"散点图"变成了"密度图"。
alpha=1.0: 100 个点重叠 = 一个点(看不出密度)
alpha=0.05:100 个点重叠 = 深色(立刻知道"这里数据密集")经验值:
| 数据量 | s | alpha |
|---|---|---|
| < 1000 | 20~30 | 0.6~0.8 |
| 1 千 ~ 1 万 | 10~15 | 0.4~0.6 |
| 1 万 ~ 10 万 | 3~6 | 0.2~0.4 |
| > 10 万 | 1~3 | 0.1~0.2 |
| > 100 万 | 换 hexbin | — |
★ 一个通用的封装
def scatter_auto(ax, x, y, **kwargs):
"""按数据量自动选 s 和 alpha。"""
n = len(x)
if n < 1000:
s, alpha = 25, 0.7
elif n < 10000:
s, alpha = 12, 0.5
elif n < 100000:
s, alpha = 5, 0.35
else:
s, alpha = 2, 0.2
ax.scatter(x, y, s=kwargs.pop("s", s), alpha=kwargs.pop("alpha", alpha),
linewidths=0, **kwargs)
return ax这个函数的思路值得学:把"调参经验"固化成代码, 下次不用再想"用 s=5 还是 s=10"。
36.2 用颜色表达第三个变量
sc = ax.scatter(df["soh"], df["resistance_mohm"], c=df["cycle"], cmap="viridis",
s=5, alpha=0.5, linewidths=0)
cb = fig.colorbar(sc, ax=ax)
cb.set_label("循环次数 (次)")真实输出:颜色把"循环次数"这条隐藏维度带进了图里 —— 低 SOH 的点同时是高 cycle 的点。
点开:散点图最多能表达几个维度?
一张散点图最多能承载 4 个维度:
| 维度 | 用什么表达 |
|---|---|
| x 轴 | 第一个变量 |
| y 轴 | 第二个变量 |
颜色(c=) | 第三个变量 |
点大小(s=) | 第四个变量 |
还能加第五个吗:用形状(marker=),但通常会很乱(不同形状难以比较)。
★ 常用的 colormap(cmap)
| colormap | 适用 | 特点 |
|---|---|---|
viridis | 连续量(默认推荐) | 感知均匀、色盲友好 |
plasma / inferno / magma | 连续量 | 同上,风格不同 |
coolwarm | 有正负/中心的值 | 蓝-白-红,中心是白色 |
RdBu / bwr | 同上 | 红-蓝 |
jet | ❌ 不推荐 | 感知不均匀(某些色带看起来变化更快) |
★ vmin / vmax 的坑(和第 37 章热力图是同一个问题)
# ❌ 不指定范围:颜色范围跟着数据自适应
ax.scatter(x, y, c=values, cmap="coolwarm")
# ✅ 指定范围:不同图之间颜色可比
ax.scatter(x, y, c=values, cmap="coolwarm", vmin=-1, vmax=1)为什么要指定:
图 A 的数据范围 [0, 1] → 颜色条从 0 到 1
图 B 的数据范围 [0, 100] → 颜色条从 0 到 100
两张图上的"深红"含义完全不同!
放在一起对比时,读者会误判。对有明确物理范围的值(相关系数 ±1、概率 0~1、SOH 0.8~1.0), 一定要写 vmin/vmax。
★ colorbar 的 label 不能省
cb.set_label("循环次数 (次)") # ✅ 必须写没有 label 的 colorbar,读者不知道颜色代表什么。
36.3 相关系数:一个数字概括线性关系
r1 = df["soh"].corr(df["resistance_mohm"])
r2 = df["soh"].corr(df["capacity_ah"])
r3 = df["soh"].corr(df["temp_rise_c"])真实输出:
soh ↔ resistance_mohm : r = -0.9397 强负相关
soh ↔ capacity_ah : r = +0.9981 ← 几乎是同一个量!
soh ↔ temp_rise_c : r = -0.9957 强负相关点开:皮尔逊相关系数的完整含义
公式:
人话版:把 x 和 y 各自标准化,然后求它们"同向变化"的程度。
取值范围和含义:
| r | 含义 |
|---|---|
+1 | 完全正相关(一个涨,另一个必涨,且成比例) |
+0.7 ~ +1 | 强正相关 |
+0.3 ~ +0.7 | 中等正相关 |
-0.3 ~ +0.3 | 弱相关或无线性关系 |
-0.7 ~ -0.3 | 中等负相关 |
-1 ~ -0.7 | 强负相关 |
-1 | 完全负相关 |
⚠ r ≈ 0 不代表"没关系"
它只代表"没有线性关系":
y
│ ● ●
│ ● ●
│ ● ● ← 完美的 U 形关系
│ ● ●
│ ● ●
└──────────────── x
r ≈ 0(因为前半段是负相关、后半段是正相关,互相抵消)
但 x 和 y 其实是"完全确定"的关系!这是相关系数最大的局限 —— 它只能度量线性关系。
★ r² 的含义:能解释的方差比例
r = 0.94
r² = 0.8836
含义:x 能解释 y 的 88.36% 的波动,剩下 11.64% 是其他因素 + 噪声。为什么用 r²:它比 r 更容易解释("解释了多少百分比的波动")。
但要注意:r² 只在线性回归的语境下才有这个解释。随便两个相关变量说"x 解释了 y 的 88%"是不严谨的。
★ 三个必须知道的局限
| 局限 | 说明 | 怎么应对 |
|---|---|---|
| 只度量线性 | U 形关系的 r 可能接近 0 | 一定要看图 |
| 对异常值敏感 | 一个极端点能把 r 从 0.1 拉到 0.9 | 算完 r 去极值再算一遍,看稳不稳 |
| 相关 ≠ 因果 | 详见 36.4 | 用分段相关、控制变量等方法验证 |
第三种是本章的重点。
36.4 ★★ 重头戏:共同趋势 ≠ 因果
这是本章(也是全书)最重要的一个思维训练。
先看现象
r_ct = df["cycle"].corr(df["temp_rise_c"])
print(f"r(cycle, temp_rise_c) = {r_ct:+.4f}")r(cycle, temp_rise_c) = +0.9312 —— 看起来是「非常强的相关」图:

看左图:点几乎构成一条漂亮的斜线。是不是意味着"循环次数导致温升升高"?
不是。 下面用分段相关拆穿它。
分段相关
df["seg"] = pd.cut(df["cycle"], bins=5)
for seg, g in df.groupby("seg", observed=True):
print(f"{str(seg):<22}{len(g):>8,d}{g['cycle'].corr(g['temp_rise_c']):>+10.3f}")真实输出:
区间 n 段内 r
(-0.422, 285.4] 3420 +0.793
(285.4, 569.8] 3408 +0.584
(569.8, 854.2] 3420 +0.466
(854.2, 1138.6] 2486 +0.467
(1138.6, 1423.0] 1233 +0.602整体 r = +0.931,但段内 r 只有 0.47 ~ 0.79 —— 明显下降。
点开:为什么会下降?这说明了什么?
原因
分到同一段之后,「循环次数」的变化范围变小了。
整体:cycle 从 1 到 1423(跨度 1422)
段内:cycle 只在 285 的范围内变化(跨度约 285)
→ cycle 的变化"贡献"的相关性变小了
→ 剩下的是"同一循环阶段内,温升的真实差异"而"同一阶段内温升的差异"和 cycle 关系弱得多 —— 所以段内 r 下降。
真正的关系是什么
三者都被同一个东西推着走:
老化(共同的隐藏原因)
│
┌───────┼───────┐
↓ ↓ ↓
cycle↑ SOH↓ 温升↑准确的因果描述是:
不是:cycle → 温升
而是:老化 → cycle 增加
老化 → 温升增加
老化 → SOH 下降
cycle 和温升都是「老化」的结果,所以它们"看起来"高度相关。这就叫「共同趋势(common trend)造成的假相关」。
一句关键的话
相关 ≠ 因果。看到两个变量高度相关时,先问一句"它们是不是都被第三个东西推着走?"
怎么验证"是不是假相关"
方法一:分段相关(本章用的)
如果分段后相关性大幅下降 → 主要是共同趋势
如果分段后相关性依然很强 → 可能是真的直接关系方法二:控制变量
# 固定 cycle,只看温度是否还和内阻相关
sample = df[(df["cycle"] > 500) & (df["cycle"] < 520)]
print(f"同循环区间内 r(temp, r_int) = "
f"{sample['temp_rise_c'].corr(sample['resistance_mohm']):+.3f}")如果"固定 cycle 后相关性消失" → 说明两者的关系是 cycle 带来的。
方法三:做实验
把一部分电芯放在不同温度下循环,其他条件完全相同
→ 如果温度高的那组衰减更快,说明"温度 → 衰减"是真实的因果这是唯一能确证因果的方法 —— 因为它排除了所有共同原因。
★ 这个思维在数据工作里极其重要
| 错误的结论 | 正确的说法 |
|---|---|
| "温升导致容量衰减" | "温升与容量衰减高度相关,两者可能都是老化的表现" |
| "内阻高的电芯衰减快" | "内阻与 SOH 负相关(r = -0.94),需实验验证因果关系" |
| "用了这个功能的用户留存更高" | "两者相关,但可能是活跃用户更愿意尝试新功能" |
★ 一个实用的表达模板
"X 与 Y 的相关系数为 r = XXX(n = N)。
需注意:两者都可能受 Z 影响,因此该相关性不能解释为因果关系。
若要确认因果,需要 <分段分析 / 控制变量 / 对照实验>。"这段话写进报告,会立刻显得专业得多。
36.5 三条误读警告
① 只看 r 不看图:相关系数只度量「线性」关系
② 不看异常值:一个极端点就能把 r 从 0.1 拉到 0.9
③ 把相关说成因果:上面那段就是活生生的例子警告 ② 的验证:
base_r = df["cycle"].corr(df["temp_rise_c"])
trimmed = df[df["temp_rise_c"] < df["temp_rise_c"].quantile(0.99)]
print(f"全部数据 r = {base_r:+.4f}")
print(f"去掉温升前 1% 后 r = {trimmed['cycle'].corr(trimmed['temp_rise_c']):+.4f}")真实输出:
全部数据 r = +0.9312
去掉温升前 1% 后 r = +0.9301(变化 0.0011)变化很小 —— 说明这份数据里没有"一个极端点主导相关性"的问题。
点开:但如果变化很大呢?怎么判断"稳健性"
判断规则:
| 去极值前后的变化 | 判断 |
|---|---|
| < 0.05 | 稳健,r 可信 |
| 0.05 ~ 0.15 | 有一定影响,报告里要说明 |
| > 0.15 | 不稳健,必须查原因 |
如果变化很大,可能的原因:
① 数据里真有异常值没清洗 → 先做异常检测(第 32、33 章)。
② 关系本身是"少数极端样本"造成的 → 那这个"相关"没有普遍性。
③ 关系是非线性的 → 极端点反映了曲线的尾部。
★ 一个更系统的稳健性检查
def corr_robust(x, y, quantiles=(0.0, 0.01, 0.05, 0.10)):
"""在不同去极值程度下算相关系数,看稳定性。"""
out = []
for q in quantiles:
if q > 0:
lo_x, hi_x = x.quantile(q), x.quantile(1 - q)
lo_y, hi_y = y.quantile(q), y.quantile(1 - q)
mask = x.between(lo_x, hi_x) & y.between(lo_y, hi_y)
r = x[mask].corr(y[mask])
else:
r = x.corr(y)
out.append({"trim": f"{q:.0%}", "n": int(len(x) if q == 0 else mask.sum()),
"r": round(r, 4)})
return pd.DataFrame(out)用法:
print(corr_robust(df["cycle"], df["temp_rise_c"]))
# trim n r
# 0% 13967 0.9312
# 1% 13686 0.9301
# 5% 12566 0.9283
# 10% 11174 0.9254r 在 0.925~0.931 之间波动 —— 非常稳健。
如果 10% 裁剪后 r 掉到 0.5,那就说明"这个相关主要是极端值造成的", 结论要重写。
★ 用 Spearman 作为交叉验证
pearson = x.corr(y) # 默认:Pearson(线性)
spearman = x.corr(y, method="spearman") # 秩相关(单调,更稳健)
print(f"Pearson {pearson:.4f} Spearman {spearman:.4f}")Spearman 的适用:
- 关系是单调但非线性的(比如对数关系);
- 数据有异常值(Spearman 只看排名,不受极值幅度影响)。
两者差异大 → 说明关系非线性或有异常值。
36.6 点实在太多怎么办:hexbin
hb = ax.hexbin(df["cycle"], df["temp_rise_c"], gridsize=40, cmap="viridis", mincnt=1)
cb = fig.colorbar(hb, ax=ax)
cb.set_label("该格子内的样本数")结果:颜色越深表示该格子里的样本越多 —— 比散点图更能看清"数据集中在哪里"。
点开:散点图 vs hexbin vs 2D 直方图
三种"表达两个连续变量关系"的方式:
| 方式 | 适合数据量 | 优点 | 缺点 |
|---|---|---|---|
scatter | < 10 万 | 每个点都可见 | 多了就糊 |
hexbin | 10 万 ~ 千万 | 密度清楚、形状规整 | 看不出单个点 |
hist2d | 同上 | 实现简单 | 方格看起来不如六边形自然 |
hexbin 的关键参数:
ax.hexbin(x, y,
gridsize=40, # 网格精度:越大格子越小(默认 100)
cmap="viridis", # 颜色映射
mincnt=1, # 只显示样本数 ≥ 1 的格子(否则空格子也是颜色)
bins="log") # ★ 数量跨度大时用对数色标★ bins="log" 很重要
为什么:样本数的分布通常极不均匀。
大部分格子:1~10 个点
少数格子:1000+ 个点
如果用线性色标:1000 的格子颜色饱和,1~10 的格子全是"最浅色"——看不出区别
用对数色标:1, 10, 100, 1000 每级都有明显区分★ 检查数据量决定用哪个
n = len(df)
if n < 50_000:
ax.scatter(x, y, s=5, alpha=0.4, linewidths=0)
elif n < 5_000_000:
ax.hexbin(x, y, gridsize=50, cmap="viridis", mincnt=1)
else:
# 上千万:先抽样画散点,或者用 datashader
idx = np.random.default_rng(42).choice(n, 100_000, replace=False)
ax.scatter(x.iloc[idx], y.iloc[idx], s=1, alpha=0.2, linewidths=0)抽样画散点的思路:数据量太大时,随机抽 10 万个点画出形状就够了 —— 密度信息会失真,但整体趋势能看清。
★ 一个组合用法:散点 + 分位线
# 用 hexbin 看密度,再叠加"每个 x 区间的 y 中位数"曲线
bins = np.linspace(df["cycle"].min(), df["cycle"].max(), 30)
df["_bin"] = pd.cut(df["cycle"], bins=bins)
median_line = df.groupby("_bin", observed=True)["temp_rise_c"].median()
centers = [interval.mid for interval in median_line.index]
ax.plot(centers, median_line.to_numpy(), color="crimson", lw=2, label="各区间中位数")
ax.legend()这样既看得到"密度分布",也看得到"趋势线" —— 汇报时非常有说服力。
36.7 完整示例与输出
cd code
python demos/ch36-scatter.py真实输出(节选):
=== ④ 相关系数:一个数字概括线性关系 ===
soh ↔ resistance_mohm : r = -0.9397 强负相关
soh ↔ capacity_ah : r = +0.9981 ← 几乎是同一个量!
soh ↔ temp_rise_c : r = -0.9957 强负相关
=== ⑤ ★ 重头戏:共同趋势 ≠ 因果 ===
r(cycle, temp_rise_c) = +0.9312 —— 看起来是「非常强的相关」
分段后的段内相关系数:
区间 n 段内 r
(-0.422, 285.4] 3420 +0.793
(285.4, 569.8] 3408 +0.584
(569.8, 854.2] 3420 +0.466
(854.2, 1138.6] 2486 +0.467
(1138.6, 1423.0] 1233 +0.602
读表:整体 r = +0.931,但每一段内部的 r 只有 0.47 ~ 0.79,明显下降。
★ 所以:相关 ≠ 因果。
=== ⑦ 三条误读警告 ===
全部数据 r = +0.9312
去掉温升前 1% 后 r = +0.9301(变化 0.0011)本章练习
练习 1:画散点图并回答相关系数
- 画
cyclevstemp_rise_c散点图,颜色用soh(cmap="coolwarm"+ colorbar); - 标题里写上相关系数 r;
- 把 cycle 分成 5 段,分别算段内 r;
- 解释"为什么整体 r 那么高、段内却低得多"。
参考答案
import os
import sys
from pathlib import Path
import pandas as pd
CODE_DIR = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(CODE_DIR))
os.environ.setdefault("MPLCONFIGDIR", str(CODE_DIR / ".mplconfig"))
import matplotlib.pyplot as plt # noqa: E402
from common.plots import setup_style, save_fig # noqa: E402
OUT = CODE_DIR / "outputs"
setup_style()
df = pd.read_csv(CODE_DIR / "data" / "battery" / "cycle_raw.csv")
# ① 相关系数
r = df["cycle"].corr(df["temp_rise_c"])
print(f"r = {r:+.3f}") # +0.931
# ② 散点图
fig, ax = plt.subplots(figsize=(8.6, 5.2))
sc = ax.scatter(df["cycle"], df["temp_rise_c"], c=df["soh"], cmap="coolwarm",
s=5, alpha=0.4, linewidths=0)
cb = fig.colorbar(sc, ax=ax)
cb.set_label("SOH")
ax.set_xlabel("循环次数 (次)")
ax.set_ylabel("温升 (℃)")
ax.set_title(f"循环次数 vs 温升(r = {r:.3f})")
save_fig(fig, str(OUT / "ch36_exercise.png"))
# ③ 分段相关
df["seg"] = pd.cut(df["cycle"], bins=5)
for seg, g in df.groupby("seg", observed=True):
print(f"{str(seg):<22}{len(g):>7,d}{g['cycle'].corr(g['temp_rise_c']):>+10.3f}")真实输出:
r = +0.931
(-0.422, 285.4] 3420 +0.793
(285.4, 569.8] 3408 +0.584
(569.8, 854.2] 3420 +0.466
(854.2, 1138.6] 2486 +0.467
(1138.6, 1423.0] 1233 +0.602④ 为什么整体高、段内低:
因为分到同一段之后,「循环次数」的变化范围变小了。
整体:cycle 从 1 到 1423(跨度 1422)
段内:cycle 只在约 285 的范围内变化
→ cycle 对相关性的"贡献"消失了
→ 剩下的是"同一循环阶段内温升的真实差异"而"同一阶段内温升的差异"和 cycle 关系弱得多 —— 所以段内 r 下降。
★ 正确的因果描述
不是:cycle → 温升
而是:老化 → cycle 增加
老化 → 温升增加
cycle 和温升都是「老化」的结果,所以看起来高度相关。
这叫「共同趋势造成的假相关」。★ 报告里该怎么写
"循环次数与温升的相关系数为 r = +0.931(n = 13,967)。 但分段分析显示,在 5 个循环区间内,段内相关系数仅为 0.47~0.79。 这提示两者的高相关主要来自共同的老化趋势, 而非直接的因果关系。若要确认因果,需通过控制变量实验验证。"
这段话的三个要素:① 给出数字;② 给出反证;③ 说明下一步该怎么做。
练习 2:怎么判断"相关系数是否被异常值主导"?
r_all = x.corr(y)
r_trim = x[x.between(x.quantile(0.01), x.quantile(0.99))].corr(
y[x.between(x.quantile(0.01), x.quantile(0.99))])
print(r_all, r_trim)要求:说明"两个数字差多少算正常"、"差异大说明什么"、"该怎么办"。
参考答案
判断标准:
| 差异 | 判断 | 处理 |
|---|---|---|
| < 0.05 | 稳健,r 可信 | 正常报告 |
| 0.05 ~ 0.15 | 有一定影响 | 报告里说明"去极值后 r 变为 XXX" |
| > 0.15 | 不稳健 | 必须查原因,不能直接下结论 |
本数据的实测(cycle vs temp_rise_c):
print(corr_robust(df["cycle"], df["temp_rise_c"]))
# trim n r
# 0% 13967 0.9312
# 1% 13686 0.9301
# 5% 12566 0.9283
# 10% 11174 0.9254r 在 0.925~0.931 之间 —— 变化只有 0.006,非常稳健。
★ 如果差异很大,三种可能的原因
① 数据里有未清洗的异常值。
# 先做异常检测(第 32、33 章)
from scipy import stats
z = np.abs(stats.zscore(df["resistance_mohm"]))
print(f"|z|>3 的记录:{(z > 3).sum()}")② 关系本身是"少数极端样本"造成的。
# 去掉极端样本后关系消失 → 那这个"相关"没有普遍性
# 报告里必须说明:"该相关性主要由 X 个极端样本驱动"③ 关系是非线性的。
# 用 Spearman 交叉验证
print(f"Pearson {x.corr(y):.4f}") # 线性
print(f"Spearman {x.corr(y, method='spearman'):.4f}") # 单调
# 如果 Spearman 明显更高 → 关系是"单调但非线性"★ 一个通用的稳健性检查函数
def corr_robust(x, y, quantiles=(0.0, 0.01, 0.05, 0.10)):
"""在不同去极值程度下算相关系数,看稳定性。"""
rows = []
for q in quantiles:
if q == 0:
mask = pd.Series(True, index=x.index)
else:
lo_x, hi_x = x.quantile(q), x.quantile(1 - q)
lo_y, hi_y = y.quantile(q), y.quantile(1 - q)
mask = x.between(lo_x, hi_x) & y.between(lo_y, hi_y)
rows.append({
"trim": f"{q:.0%}",
"n": int(mask.sum()),
"pearson": round(x[mask].corr(y[mask]), 4),
"spearman": round(x[mask].corr(y[mask], method="spearman"), 4),
})
return pd.DataFrame(rows)这个函数一次给出四个口径的 r(含 Spearman) —— 如果四个数字都接近,就可以放心下结论。
练习 3:点太多时该画什么图?
n = len(df)要求:针对 n = 500、n = 5 万、n = 500 万 三种情况, 分别选择画图方式并说明理由。
参考答案
| 数据量 | 方式 | 参数 |
|---|---|---|
| 500 | scatter | s=25, alpha=0.7, linewidths=0 |
| 5 万 | scatter(密)或 hexbin | s=5, alpha=0.35 或 hexbin(gridsize=40, mincnt=1) |
| 500 万 | hexbin(或先抽样) | hexbin(gridsize=60, bins="log", mincnt=1) |
代码:
def plot_relationship(ax, x, y, name_x="x", name_y="y"):
"""按数据量自动选择画法。"""
n = len(x)
if n < 2000:
ax.scatter(x, y, s=25, alpha=0.7, linewidths=0)
ax.set_title(f"{name_x} vs {name_y}(n={n:,},散点)")
elif n < 200_000:
ax.scatter(x, y, s=5, alpha=0.35, linewidths=0)
ax.set_title(f"{name_x} vs {name_y}(n={n:,},散点)")
else:
hb = ax.hexbin(x, y, gridsize=60, cmap="viridis", mincnt=1, bins="log")
ax.figure.colorbar(hb, ax=ax, label="样本数(对数)")
ax.set_title(f"{name_x} vs {name_y}(n={n:,},hexbin)")
ax.set_xlabel(name_x)
ax.set_ylabel(name_y)
return ax★ 三种方式的取舍
| 方式 | 保留什么信息 | 丢掉什么 |
|---|---|---|
scatter | 每个点的位置 | (数据多时)密度信息 |
hexbin | 密度分布 | 单个点的位置 |
抽样 + scatter | 整体形状 | 精确密度 |
★ 500 万点为什么要用 bins="log"
样本数分布极度不均:
大部分格子:1~10 个点
中心区域:10 万+ 个点
线性色标:10 万 的格子饱和成最深色,1~10 的格子全是"最浅色"
→ 看不出中间的层次
对数色标:1, 10, 100, 1000, 10000 每级都有区分
→ 密度层次清楚★ 一个额外的技巧:抽样
# 500 万点 → 随机抽 10 万画散点(形状依然清楚)
idx = np.random.default_rng(42).choice(len(df), 100_000, replace=False)
ax.scatter(x.iloc[idx], y.iloc[idx], s=1, alpha=0.2, linewidths=0)为什么要固定种子:让每次画出来的图一样 —— 否则汇报时两次打开的图不同,会让人困惑。
★ 最后一条:图的选择取决于"你想让读者看到什么"
想让读者看"有没有关系" → hexbin(看整体趋势)
想让读者看"某个具体的点" → scatter + 标注
想让读者看"密度分布" → hexbin + log 色标
想让读者看"分组的差异" → 分组着色 scatter面向 AI 开发:相关性是"特征筛选"的第一道筛子
在 AI 项目里,相关系数有两个核心用途:找有用特征、找冗余特征。
① 找"和标签相关的特征"
# 计算每个特征与标签的相关系数,按绝对值排序
FEATURES = ["cycle", "capacity_ah", "resistance_mohm", "temp_rise_c", "coulombic_eff"]
TARGET = "soh"
corr_with_target = (df[FEATURES].corrwith(df[TARGET])
.sort_values(key=abs, ascending=False))
print(corr_with_target.round(4))
# capacity_ah 0.9981 ← 但这是泄漏!
# temp_rise_c -0.9957
# coulombic_eff 0.9849
# resistance_mohm -0.9397
# cycle -0.9347★ 但排第一的那个恰恰不能用 —— capacity_ah 是泄漏(第 37 章详讲)。
反直觉但重要的认知:
"相关性最高"不等于"最该用"。 有些高相关的特征恰恰是要剔除的。
② 找"互相冗余的特征"
corr_matrix = df[FEATURES].corr()
# 找出 |r| > 0.95 的特征对
import numpy as np
tri = np.triu(np.ones(corr_matrix.shape), k=1).astype(bool)
pairs = corr_matrix.where(tri).stack()
high = pairs[pairs.abs() > 0.95].sort_values(key=abs, ascending=False)
print(high)真实输出(第 37 章会详细讲):
capacity_ah ↔ soh 0.998
soh ↔ temp_rise_c -0.996
capacity_ah ↔ temp_rise_c -0.994
soh ↔ coulombic_eff 0.985
...高度相关的特征只留一个 —— 这叫多重共线性处理。
★ 一个"特征筛选"的完整流程
① 和标签的 |r| > 0.95 → 高度可疑,检查是不是泄漏(第 37 章)
② 特征之间的 |r| > 0.95 → 冗余,只留一个
③ 和标签的 |r| < 0.05 → 可能无用(但注意非线性关系!)
④ 剩下的特征 → 进入建模★ 但相关性的作用有边界
| 情况 | 相关系数有用吗 |
|---|---|
| 线性关系 | ✅ 很有用 |
| 单调非线性关系 | ⚠️ 用 Spearman |
| U 形 / 周期关系 | ❌ 没用(r 可能接近 0,但关系很强) |
| 类别特征 | ❌ 不能用(要用互信息、方差分析) |
| 交互效应(A×B 才有用) | ❌ 单看 A、B 都不相关 |
最后一条特别值得注意:
# 单看 A 和 B 都不相关
print(df["A"].corr(y)) # 0.02
print(df["B"].corr(y)) # 0.01
# 但 A×B 高度相关!
df["AB"] = df["A"] * df["B"]
print(df["AB"].corr(y)) # 0.85 !!这就是为什么"只看相关系数筛特征"不够 —— 它漏掉了交互效应。而树模型能自动捕捉这类关系(这是它们的一大优势)。
★ 一个更全面的特征评估
from sklearn.feature_selection import mutual_info_regression
# 互信息:能捕捉非线性关系
mi = mutual_info_regression(df[FEATURES], df[TARGET], random_state=42)
mi_series = pd.Series(mi, index=FEATURES).sort_values(ascending=False)
# 和相关系数对比
comparison = pd.DataFrame({
"pearson": df[FEATURES].corrwith(df[TARGET]).abs(),
"mutual_info": mi_series,
}).sort_values("mutual_info", ascending=False)
print(comparison.round(4))互信息和 Pearson 的差异:
- 两者都高 → 线性关系,特征有用;
- 互信息高但 Pearson 低 → 非线性关系(Pearson 漏掉了);
- 两者都低 → 特征可能真的无用。
★ 一条纪律
相关系数是"快速筛子",不是"最终标准"。 用它快速排除明显无关的特征, 但不要因为有"某个特征相关性高"就跳过"物理含义检查"(第 37 章会讲为什么)。