Skip to content

第 36 章 · 散点图:两列之间的关系 ​

这一章要解决什么:看出两列之间有没有关系、关系有多强。 本章最重要的不是 API,而是一个思维陷阱:共同趋势造成的假相关 —— 相关不等于因果。

本章你需要完成的代码:demos/ch36-scatter.py

36.1 点太多时的三个必备参数 ​

python
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(散点)")

结果:

内阻 vs SOH 散点图

点开:为什么 s / alpha / linewidths 是必备的?

默认参数的后果:

python
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 个点重叠 = 深色(立刻知道"这里数据密集")

经验值:

数据量salpha
< 100020~300.6~0.8
1 千 ~ 1 万10~150.4~0.6
1 万 ~ 10 万3~60.2~0.4
> 10 万1~30.1~0.2
> 100 万换 hexbin—

★ 一个通用的封装

python
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 用颜色表达第三个变量 ​

python
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 章热力图是同一个问题)

python
# ❌ 不指定范围:颜色范围跟着数据自适应
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 不能省

python
cb.set_label("循环次数 (次)")     # ✅ 必须写

没有 label 的 colorbar,读者不知道颜色代表什么。

36.3 相关系数:一个数字概括线性关系 ​

python
r1 = df["soh"].corr(df["resistance_mohm"])
r2 = df["soh"].corr(df["capacity_ah"])
r3 = df["soh"].corr(df["temp_rise_c"])

真实输出:

text
soh ↔ resistance_mohm : r = -0.9397   强负相关
soh ↔ capacity_ah     : r = +0.9981   ← 几乎是同一个量!
soh ↔ temp_rise_c     : r = -0.9957   强负相关
点开:皮尔逊相关系数的完整含义

公式:

r=∑(xi−x¯)(yi−y¯)∑(xi−x¯)2⋅∑(yi−y¯)2

人话版:把 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 ★★ 重头戏:共同趋势 ≠ 因果 ​

这是本章(也是全书)最重要的一个思维训练。

先看现象 ​

python
r_ct = df["cycle"].corr(df["temp_rise_c"])
print(f"r(cycle, temp_rise_c) = {r_ct:+.4f}")
text
r(cycle, temp_rise_c) = +0.9312 —— 看起来是「非常强的相关」

图:

循环次数 vs 温升

看左图:点几乎构成一条漂亮的斜线。是不是意味着"循环次数导致温升升高"?

不是。 下面用分段相关拆穿它。

分段相关 ​

python
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}")

真实输出:

text
区间                         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)造成的假相关」。

一句关键的话 ​

相关 ≠ 因果。看到两个变量高度相关时,先问一句"它们是不是都被第三个东西推着走?"

怎么验证"是不是假相关" ​

方法一:分段相关(本章用的)

如果分段后相关性大幅下降 → 主要是共同趋势
如果分段后相关性依然很强 → 可能是真的直接关系

方法二:控制变量

python
# 固定 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 三条误读警告 ​

text
① 只看 r 不看图:相关系数只度量「线性」关系
② 不看异常值:一个极端点就能把 r 从 0.1 拉到 0.9
③ 把相关说成因果:上面那段就是活生生的例子

警告 ② 的验证:

python
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}")

真实输出:

text
全部数据 r = +0.9312
去掉温升前 1% 后 r = +0.9301(变化 0.0011)

变化很小 —— 说明这份数据里没有"一个极端点主导相关性"的问题。

点开:但如果变化很大呢?怎么判断"稳健性"

判断规则:

去极值前后的变化判断
< 0.05稳健,r 可信
0.05 ~ 0.15有一定影响,报告里要说明
> 0.15不稳健,必须查原因

如果变化很大,可能的原因:

① 数据里真有异常值没清洗 → 先做异常检测(第 32、33 章)。

② 关系本身是"少数极端样本"造成的 → 那这个"相关"没有普遍性。

③ 关系是非线性的 → 极端点反映了曲线的尾部。

★ 一个更系统的稳健性检查

python
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)

用法:

python
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.9254

r 在 0.925~0.931 之间波动 —— 非常稳健。

如果 10% 裁剪后 r 掉到 0.5,那就说明"这个相关主要是极端值造成的", 结论要重写。

★ 用 Spearman 作为交叉验证

python
pearson = x.corr(y)                    # 默认:Pearson(线性)
spearman = x.corr(y, method="spearman")  # 秩相关(单调,更稳健)
print(f"Pearson {pearson:.4f}  Spearman {spearman:.4f}")

Spearman 的适用:

  • 关系是单调但非线性的(比如对数关系);
  • 数据有异常值(Spearman 只看排名,不受极值幅度影响)。

两者差异大 → 说明关系非线性或有异常值。

36.6 点实在太多怎么办:hexbin ​

python
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 万每个点都可见多了就糊
hexbin10 万 ~ 千万密度清楚、形状规整看不出单个点
hist2d同上实现简单方格看起来不如六边形自然

hexbin 的关键参数:

python
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 每级都有明显区分

★ 检查数据量决定用哪个

python
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 万个点画出形状就够了 —— 密度信息会失真,但整体趋势能看清。

★ 一个组合用法:散点 + 分位线

python
# 用 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 完整示例与输出 ​

bash
cd code
python demos/ch36-scatter.py

真实输出(节选):

text
=== ④ 相关系数:一个数字概括线性关系 ===
  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:画散点图并回答相关系数
  1. 画 cycle vs temp_rise_c 散点图,颜色用 soh(cmap="coolwarm" + colorbar);
  2. 标题里写上相关系数 r;
  3. 把 cycle 分成 5 段,分别算段内 r;
  4. 解释"为什么整体 r 那么高、段内却低得多"。
参考答案
python
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}")

真实输出:

text
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:怎么判断"相关系数是否被异常值主导"?
python
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):

python
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.9254

r 在 0.925~0.931 之间 —— 变化只有 0.006,非常稳健。

★ 如果差异很大,三种可能的原因

① 数据里有未清洗的异常值。

python
# 先做异常检测(第 32、33 章)
from scipy import stats
z = np.abs(stats.zscore(df["resistance_mohm"]))
print(f"|z|>3 的记录:{(z > 3).sum()}")

② 关系本身是"少数极端样本"造成的。

python
# 去掉极端样本后关系消失 → 那这个"相关"没有普遍性
# 报告里必须说明:"该相关性主要由 X 个极端样本驱动"

③ 关系是非线性的。

python
# 用 Spearman 交叉验证
print(f"Pearson  {x.corr(y):.4f}")                 # 线性
print(f"Spearman {x.corr(y, method='spearman'):.4f}")  # 单调
# 如果 Spearman 明显更高 → 关系是"单调但非线性"

★ 一个通用的稳健性检查函数

python
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:点太多时该画什么图?
python
n = len(df)

要求:针对 n = 500、n = 5 万、n = 500 万 三种情况, 分别选择画图方式并说明理由。

参考答案
数据量方式参数
500scatters=25, alpha=0.7, linewidths=0
5 万scatter(密)或 hexbins=5, alpha=0.35 或 hexbin(gridsize=40, mincnt=1)
500 万hexbin(或先抽样)hexbin(gridsize=60, bins="log", mincnt=1)

代码:

python
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 每级都有区分
          → 密度层次清楚

★ 一个额外的技巧:抽样

python
# 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 项目里,相关系数有两个核心用途:找有用特征、找冗余特征。

① 找"和标签相关的特征"

python
# 计算每个特征与标签的相关系数,按绝对值排序
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 章详讲)。

反直觉但重要的认知:

"相关性最高"不等于"最该用"。 有些高相关的特征恰恰是要剔除的。

② 找"互相冗余的特征"

python
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 章会详细讲):

text
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 都不相关

最后一条特别值得注意:

python
# 单看 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  !!

这就是为什么"只看相关系数筛特征"不够 —— 它漏掉了交互效应。而树模型能自动捕捉这类关系(这是它们的一大优势)。

★ 一个更全面的特征评估

python
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 章会讲为什么)。


下一章:第 37 章 · 热力图:字段之间的相关性