Skip to content

第 05 章 · 表格数据的形状:为什么 X 必须是二维的 ​

这一章要解决什么:搞清 X.shape 里那两个数字分别是谁, 以及"哪一维是样本"这件事为什么必须先有一个约定。

最容易踩的坑: ① reshape(-1, 1) 和 reshape(1, -1) 占的内存一模一样、数字一模一样,含义正好相反; ② 长表的行数是宽表的 7 倍,看起来"数据变多了",其实一个数字都没多; ③ 当样本数恰好等于特征数时,转置不会报错,只会静默地算出一个不同的模型。

本章你需要完成的代码:demos/ch05-table-shape.py

5.1 两个维度,两个数字 ​

第 04 章说过 X 是二维、y 是一维。这一章要问:二维的"两维"分别是什么?

答案只有两个词:

text
(n_samples, n_features)
     ↑            ↑
   样本数        特征数
  有几个"东西"  每个"东西"量了几个指标

真实输出:

text
  整表 shape = (21239, 9)   ← 2 个数字 = 二维表
    第 0 维(行)= 21,239  第 1 维(列)= 9
  X.shape = (21239, 7)        ← (n_samples, n_features)
  y.shape = (21239,)           ← 只有 1 个数字 = 一维
  X 的行数 == y 的长度 : True

★ 一个能省掉一半困惑的判据:

数 shape 里有几个数字,就知道它是几维。

python
df.shape          # (21239, 9)      → 2 个数字 → 二维
X.shape           # (21239, 7)      → 2 个数字 → 二维
y.shape           # (21239,)        → 1 个数字 → 一维
model.coef_.shape # (7,)            → 1 个数字 → 一维(每个特征一个系数)
model.intercept_.shape  # ()        → 0 个数字 → 标量(一个数)

注意最后一行:intercept_.shape == () 表示它是一个标量,不是长度为 1 的数组。 这就是为什么第 06 章里 model.intercept_ 能直接当数字用,而 model.coef_[0] 要下标。

★ 两个维度的名字要背下来,因为报错信息里全是它们:

出现的地方说法
X.shape 的第 0 维n_samples / 样本数 / 行数 / 观测数
X.shape 的第 1 维n_features / 特征数 / 列数 / 变量数
sklearn 报错Found input variables with inconsistent numbers of samples: [1, 21239]
sklearn 报错Expected 2D array, got 1D array instead
点开:为什么叫 n_samples 而不是 n_rows?

因为"行"是一个排版概念,"样本"是一个语义概念。

"这张表有 21239 行"        → 描述 CSV 文件长什么样
"这个数据集有 21239 个样本" → 描述数据代表什么

两者经常相等,但不总是:

场景行数样本数说明
本书的数据21,23921,239一行一个样本 —— 但样本之间不独立
一行是一个人的一次就诊5,000800800 个患者,每人多次
一行是一个像素65,5361一张 256×256 的图
转置之后的表777 个"样本",每个有 21,239 个特征

最后一行的荒谬感是刻意的 —— 同一批数字,换个读法就变成另一个问题。

★ 所以每次打 X.shape 时,都要在心里读出来:

python
print(f"{X.shape[0]:,d} 个样本,每个 {X.shape[1]} 个特征")
# 21,239 个样本,每个 7 个特征        ← 读出来,别只看着数字

这句话会暴露很多错误。比如你以为是 60 个样本(设备), 读出来却是 21,239 个 —— 那你立刻会想起第 03 章的伏笔: 这里的"样本"其实是一个"窗口",不是一支设备。

5.2 一行到底是什么 ​

先看这张表的前三行长什么样:

真实输出:

text
  第 0 行:cell_id=C001  cycle=    50  resistance=  45.51  soh=0.9921
  第 1 行:cell_id=C001  cycle=    54  resistance=  45.78  soh=0.9901
  第 2 行:cell_id=C001  cycle=    56  resistance=  43.83  soh=0.9898
  → 一行 = 【某支设备在某一个循环窗口上的全部测量值】
  → 一行是一个样本;一列是一个特征。
  这张表有 21,239 行,但只有 60 支设备:
    每支设备出现 235 ~ 464 次(中位 356 次)

注意第 0、1、2 行的 cell_id 全是 C001。

行是"横着读"的:一行 = 一次观测的全部属性
列是"竖着读"的:一列 = 同一个属性在 21,239 次观测上的取值

★ 用一个心智模型记住它:

text
              cycle  resistance  temp  ...   soh
C001 第 1 窗    50      45.51      ?    ...  0.9921   ← 一个样本(一行)
C001 第 2 窗    54      45.78      ?    ...  0.9901   ← 一个样本(一行)
C001 第 3 窗    56      43.83      ?    ...  0.9898   ← 一个样本(一行)
  ↑
一列 = 一个特征在全部样本上的取值

★ 这里有一个"想当然"很容易出错的地方:

看到 cell_id 有 60 个不同值
   ↓
容易以为"我有 60 个样本"
   ↓
实际是 21,239 个样本,来自 60 个【实体】

"实体数"和"样本数"是两个不同的量,第 03 章已经在提醒这件事。 这一章只加一句新的:

n_samples 数的是行,不是实体。 想知道有多少实体,得自己 groupby 去数。

点开:同一个东西的六个名字(看文献时不再卡住)

"一行"这个概念在不同学科、不同工具里有不同的叫法:

叫法常见于中文
sample / example机器学习样本
instance机器学习实例
observation统计观测
record / row数据库记录 / 行
case流行病学病例
subject / unit实验设计受试者 / 单元

"一列"也一样:

叫法常见于
feature / attribute机器学习
variable统计
field / column数据库
predictor / covariate回归分析
dimension一些工程文献

★ 为什么值得看一眼这张表

因为报错信息和文档里会混着用:

text
sklearn : n_samples, n_features
pandas  : rows, columns
numpy   : axis 0, axis 1
统计书  : observations, variables

四个词表说的是同一件事。看到 n_samples 就想到"行",看到 n_features 就想到"列", 这一层翻译做完,剩下的就是技术问题了。

⚠️ 一个例外:dimension(维度)这个词很危险。 它有时指"特征数"(这张表有 7 维),有时指"数组有几个轴"(这是二维数组)。 本书里"维度"一律指后者(轴的个数),"特征数"就叫特征数。

5.3 约定不是真理:同一堆数字的两种读法 ​

现在到了这一章的核心。我们把 cycle 这一列拉直成一串数字,然后用两种方式"折叠"回去:

真实输出:

text
  flat.shape = (21239,)   (21,239 个整数)
  flat.reshape(-1, 1).shape = (21239, 1)    → 21,239 个样本,每个 1 个特征
  flat.reshape( 1, -1).shape = (1, 21239)      → 1 个样本,它有 21,239 个特征
  两者占的内存完全一样:169,912 bytes == 169,912 bytes -> True
  → 数字一模一样,含义正好相反。
  把 (1, 21239) 喂给模型:ValueError: Found input variables with inconsistent numbers of samples: [1, 21239]…

看懂这一段:

python
flat = df["cycle"].to_numpy()      # 21239 个数字,排成一条线

flat.reshape(-1, 1)   # 21239 行 × 1 列  →  "每个数字是一个样本"
flat.reshape(1, -1)   # 1 行 × 21239 列  →  "这一串数字是一个样本的 21239 个特征"

-1 的意思是"这一维你自己算":

reshape(-1, 1)  → 固定 1 列,行数 = 21239 / 1 = 21239
reshape(1, -1)  → 固定 1 行,列数 = 21239 / 1 = 21239

★ 这是这一章最重要的认知:

数组的形状里没有任何"物理意义"。 "行是样本"是 sklearn 的约定,不是数据自带的性质。

同一堆数字,在另一个库里可能是另一个意思。

点开:哪些工具用哪个约定(这会救你一次)

"样本在前还是特征在前"这件事,各个工具的选择并不统一:

工具 / 场景约定形状
sklearn样本在前(n_samples, n_features)
np.polyfit(x, y, 1)x 是一维(n,) 直接喂
卷积神经网络通道在前(某些框架)(C, H, W)
R 的不少统计函数变量在前(n_vars, n_obs)
协方差矩阵对称方阵(n_features, n_features)

★ 危险的不是"哪个对",而是"你换工具时不检查":

python
# numpy 里:x 是一维的
k, b = np.polyfit(df["cycle"], df["soh"], 1)      # ✅ 一维正合适

# sklearn 里:X 必须是二维的
LinearRegression().fit(df["cycle"], df["soh"])    # ❌ ValueError
LinearRegression().fit(df[["cycle"]], df["soh"])  # ✅ 多一层方括号

同一个文件里,两行代码,两种约定。

所以你每次换工具都要问一句:

"这个函数要的 X,是 (样本, 特征) 还是 (特征, 样本)?"
   ↓
不确定就看 shape:文档里写的 shape 顺序,就是它要的顺序

★ 一个实用的护身符

在项目里定义一个统一的入口函数(第 04 章的 make_xy), 所有模型都从这里拿 X,绝不在建模代码里手写 reshape。

make_xy(df, FEATS, TARGET)  →  唯一的出口
   ↓
形状错只可能错在这一处,排查成本从"半天"变成"一分钟"

5.4 为什么 sklearn 强制二维 ​

报错信息长这样:

text
ValueError: Expected a 2-dimensional container but got <class 'pandas.Series'> instead

为什么它不"聪明"地猜一下? 因为猜不出来:

text
一维数组 (21239,)
   ├─ 读法 A:(21239, 1)  21,239 个样本,每个 1 个特征   ← 你想要的
   └─ 读法 B:(1, 21239)  1 个样本,有 21,239 个特征     ← 也不是不可能

两种读法都合法、都无法从数据本身判断。

如果 sklearn 猜 A:
  你其实想做 B 的时候,它静默地给你一个错的结果

如果 sklearn 直接报错:
  你改一行代码,它给你正确的结果

★ 这是一个贯穿全书的主题:

宁可报错,不要静默地猜。最危险的错误是"不报错但结果错了"。

顺便说清 shape 和 ndim 的关系:

python
X.ndim      # 2      ← 有几个轴
X.shape     # (21239, 7)   ← 每个轴多长
len(X.shape) == X.ndim     # True

检查"是不是二维",可以只看 ndim:

python
assert X.ndim == 2, f"X 必须是二维,现在是 {X.ndim} 维:{X.shape}"

这一行断言比"跑一遍看报不报错"便宜得多。

5.5 一个转置不报错的陷阱 ​

前面说"形状错了 sklearn 会报错"。这一节说一个例外:

真实输出:

text
  Xs.shape = (4, 4)    Xs.T.shape = (4, 4)   -> 形状相同:True
  fit(Xs)   → R² = 1.000000   coef_ = [-1.846  0.481 -0.046  0.679]
  fit(Xs.T) → R² = 1.000000   coef_ = [-1.159 -0.108  2.373  1.486]

看出问题了吗:

4 个样本 × 4 个特征
   ↓
转置之后还是 4 × 4
   ↓
sklearn 的检查全部通过(形状对、样本数对、类型对)
   ↓
但模型学到的是【完全不同的一组系数】

★ 而且你从 R² 上看不出来:两次都是 1.000000(4 个点、4 个特征, 无论哪个方向都能完美拟合)。

数据一样、R² 一样、代码不报错
   ↓
只有 coef_ 变了
   ↓
如果你不打印 coef_,这个错误可以活到上线

这就是"方阵陷阱":

当 n_samples == n_features 时,转置是静默的。

真实项目里方阵很少见,但下面这种情况不少:

python
X = df[FEATS].to_numpy()
X = X.T                      # 手滑/复制粘贴
# 如果 len(df) 恰好等于 len(FEATS),你什么提示都看不到

怎么防:不要在建模代码里写 .T。

转置通常出现在哪:
  ① 数据从别的语言/R 导进来,方向是反的
  ② 你想"把设备当成样本、把时间点当成特征"(那是另一个建模问题)
  ③ 矩阵运算的中间步骤(协方差、投影等)

①②要显式地改,并且【改完立刻打印 shape】
③属于数学运算,不进 sklearn 的 fit()

★ 一个 3 秒钟的习惯,能挡住上面全部问题:

python
print(f"X.shape = {X.shape}   y.shape = {y.shape}")
model.fit(X, y)

这不是"调试代码",这是安全带。第 09 章之后你会看到, 划分数据的时候形状还会再变一次(那时更要打)。

5.6 三个"看起来是二维"的假象 ​

二维不等于"能喂"。下面三种情况的形状都是二维,但都过不了。

假象一:.to_numpy() 之后的 object 数组 ​

真实输出:

text
  df[['cell_id','cycle','soh']].to_numpy().dtype = object
  形状看起来还是二维:(21239, 3)
  但喂给模型:ValueError: could not convert string to float: 'C001'…
  纯数值列就没问题:df[FEATS].to_numpy().dtype = float64

为什么:

DataFrame 允许每一列有自己的 dtype:str / int64 / float64
   ↓  .to_numpy() 要变成"一个数组",而 numpy 数组只能有一种 dtype
   ↓
统一升格成 object(万能的"随便什么")
   ↓
形状是 (21239, 3),dtype 是 object
   ↓
sklearn 拿到 object 数组,只能逐个尝试转 float → 遇到 'C001' 就炸

★ 报错的原因和形状无关 —— 这和第 04 章的"坑二"是同一个根因。 但它更隐蔽,因为.to_numpy() 看起来像是在"把表变成模型要的数组",很正当。

检查办法:

python
print(X.dtypes)             # DataFrame:逐列看
print(X.to_numpy().dtype)   # numpy:只能看一个

第一行信息量更大 —— 它能告诉你是哪一列是字符串。

假象二:(1, n) 与 (n, 1) ​

这个在前面 5.3 已经量化过:

text
(21239, 1)  → 21,239 个样本        ← 你想要的
(1, 21239)  → 1 个样本            ← 内存一样、数字一样

区别只在"哪一维是 1",而这一维的位置决定了全部含义。

★ 一个快速的记忆法:

第二维是 1        →  每个样本只有 1 个特征     (单特征模型)
第一维是 1        →  只有 1 个样本             (几乎总是错的)

假象三:长表的行数 ​

这个值得单独一节讲 —— 见 5.7。

5.7 宽表 vs 长表 ​

同一份数据有两种排布方式:

真实输出:

text
  宽表(wide):一行 = 一个窗口,7 个特征各占 1 列
    wide.shape = (21239, 7)
  长表(long):一行 = 一个 (窗口, 特征) 对,特征名变成一列【取值】
    long.shape = (148673, 3)   (21,239 行 × 7 个特征 = 148,673 行)
  看前 3 行长什么样:
    row_id=0  feature=cycle                        value=50.0000
    row_id=1  feature=cycle                        value=54.0000
    row_id=2  feature=cycle                        value=56.0000
  两种写法的信息量完全相同:wide 有 148,673 个数值,long 也有 148,673 个
  但内存差 9.98 倍(长表要把特征名重复存 7 遍)
  ★ 长表不能直接喂模型:
    ValueError: could not convert string to float: 'cycle'…
  先 pivot 回宽表再喂:back.shape = (21239, 7)  → 与原表逐元素相同:True

并排看两种表:

宽表(wide)长表(long)
一行是什么一个样本一个(样本, 特征)对
形状(21239, 7)(148673, 3)
列名特征名就是列名特征名是一列取值
数值个数148,673148,673
能直接喂 sklearn✅❌
适合画分组图❌✅

★ 这里有一个非常容易犯的错:

看到长表有 148,673 行
   ↓
"哇,数据变多了 7 倍!"
   ↓
错。 148,673 = 21,239 × 7,【一个数字都没多】

行数变多只是因为"把列名拆进了每一行"。

用一句话概括两种表的取舍:

宽表是"给模型看的";长表是"给人看的"(也给人画图、做缺失值分析用)。

点开:什么时候必须用长表(以及什么时候必须转回去)

必须用长表的场合:

python
# ① 画分组箱线图 / 小提琴图:seaborn 要 long form
long = df.melt(id_vars="cell_id", value_vars=FEATS,
               var_name="feature", value_name="value")
# sns.boxplot(data=long, x="feature", y="value")

# ② 统一处理缺失值:把 7 列的缺失统计合成一张表
long.groupby("feature")["value"].apply(lambda s: s.isna().mean())

# ③ 特征量纲不同、需要分别标准化时,逐组操作更直观
long.groupby("feature")["value"].transform(lambda s: (s - s.mean()) / s.std())

# ④ 特征会随时间"增加"的场景(比如新加了一路传感器)
#    宽表要改列,长表只需要多几行

必须转回宽表的场合:

python
# 只要 fit / predict,就必须宽表
wide = long.pivot_table(index="row_id", columns="feature", values="value")
model.fit(wide[FEATS], y)

★ 一个真实项目里的坑

数据从数据库导出时是长表(这是数据库的常态:一行一个"指标取值")
   ↓
有人直接把长表喂给模型 → 报错 "could not convert string to float"
   ↓
他的修法是:把 feature 列 drop 掉,只喂 value 列
   ↓
结果:148,673 行 × 1 列,能跑,R² 还不低
   ↓
但数据被【重复计算了 7 倍】,而且丢掉了"哪个特征"这个信息

★ 判据:

喂模型之前,问自己一句:X.shape[1] 应该等于特征个数吗? 如果答案是"应该",那它就必须是宽表。

pivot 的时候有两个细节要注意:

python
wide = long.pivot_table(index="row_id", columns="feature", values="value")
print(wide.shape)          # (21239, 7)  ← 行数是"样本数",对了
print(list(wide.columns))  # 列的顺序可能不是你 FEATS 的顺序!
wide = wide[FEATS]         # 显式按 FEATS 排一次,避免顺序问题

最后一行很重要:pivot_table 的列顺序由它自己决定, 不重排就可能和训练时的特征顺序不一致 —— 用 DataFrame 时 sklearn 会按名字对齐, 但如果后面转成了 numpy,顺序错就是静默地算错。

5.8 常见坑 ​

text
① X 用一维 Series         → ValueError: Expected 2D array(第 04 章的坑,本质是"没有约定")
② reshape(1, -1)          → 内存和数字都一样,但样本数变成 1 → 样本数不匹配
③ 方阵转置                → 形状不变,不报错,只有 coef_ 变了
④ .to_numpy() 混字符串列  → 形状是二维,dtype 是 object → 报错原因和形状无关
⑤ 长表直接喂              → 行数放大 7 倍,看着"数据更多",其实一样
⑥ 长表 drop 掉 feature 列 → 能跑,但把同一批数据重复算了 7 倍(最危险的一个)

★ 这六个坑里,①②④⑤都会报错,只有 ③⑥ 是静默的。

报错的坑:改一行代码就好
静默的坑:可能一路活到上线

所以本书的套路始终是:

先让它报错   →  再讲它为什么报错   →  最后讲"不报错的那一类怎么防"

5.9 完整示例与输出 ​

bash
cd code
python demos/ch05-table-shape.py

真实输出(节选):

text
=== ① 一张表的两个维度 ===
  整表 shape = (21239, 9)   ← 2 个数字 = 二维表
  X.shape = (21239, 7)        ← (n_samples, n_features)
  y.shape = (21239,)           ← 只有 1 个数字 = 一维
  X 的行数 == y 的长度 : True

=== ② 一行到底是什么?===
  第 0 行:cell_id=C001  cycle=    50  resistance=  45.51  soh=0.9921
  这张表有 21,239 行,但只有 60 支设备:
    每支设备出现 235 ~ 464 次(中位 356 次)

=== ③ 约定不是真理:同一堆数字,两种读法 ===
  flat.reshape(-1, 1).shape = (21239, 1)    → 21,239 个样本,每个 1 个特征
  flat.reshape( 1, -1).shape = (1, 21239)      → 1 个样本,它有 21,239 个特征
  两者占的内存完全一样:169,912 bytes == 169,912 bytes -> True

=== ④ 一个转置不报错的陷阱(方阵)===
  fit(Xs)   → R² = 1.000000   coef_ = [-1.846  0.481 -0.046  0.679]
  fit(Xs.T) → R² = 1.000000   coef_ = [-1.159 -0.108  2.373  1.486]

=== ⑤ .values / .to_numpy() 的隐藏降级 ===
  df[['cell_id','cycle','soh']].to_numpy().dtype = object

=== ⑥ 宽表 vs 长表 ===
  wide.shape = (21239, 7)
  long.shape = (148673, 3)   (21,239 行 × 7 个特征 = 148,673 行)
  两种写法的信息量完全相同:wide 有 148,673 个数值,long 也有 148,673 个

5.10 本章检查清单 ​

读完这一章,请逐条确认(勾选状态会存在你自己的浏览器里):

已勾选 0 / 0 项

本章练习 ​

练习 1:形状速判

下面六种写法,哪些能直接喂给 LinearRegression().fit(X, y)?先写答案,再跑代码验证。

#写法
1df["cycle"]
2df[["cycle"]]
3df[["cycle"]].to_numpy()
4df["cycle"].to_numpy()
5df["cycle"].to_numpy().reshape(1, -1)
6df[["cycle", "soh"]]

要求:每一条都给出 shape 和"能/不能"的判断,第 6 条还要说明"能跑"背后的问题。

参考答案
python
import pandas as pd

df = pd.read_csv("data/battery/soh_features.csv")
y = df["soh"]

cands = {
    "df['cycle']": df["cycle"],
    "df[['cycle']]": df[["cycle"]],
    "df[['cycle']].to_numpy()": df[["cycle"]].to_numpy(),
    "df['cycle'].to_numpy()": df["cycle"].to_numpy(),
    "df['cycle'].to_numpy().reshape(1, -1)": df["cycle"].to_numpy().reshape(1, -1),
    "df[['cycle', 'soh']]": df[["cycle", "soh"]],
}
for name, obj in cands.items():
    shape = obj.shape
    if len(shape) == 2 and shape[0] == len(y):
        verdict = "✅ 能喂(形状对)"
    elif len(shape) == 2 and shape[0] != len(y):
        verdict = f"❌ 样本数 {shape[0]} ≠ len(y) {len(y)}"
    else:
        verdict = "❌ 一维,sklearn 直接拒绝"
    if name == "df[['cycle', 'soh']]":
        verdict = "⚠️ 形状能喂,但 soh 是标签 → 泄漏(第 04 章)"
    print(f"{name:<38s} shape = {str(shape):<14s} {verdict}")

期望输出:

text
df['cycle']                            shape = (21239,)       ❌ 一维,sklearn 直接拒绝
df[['cycle']]                          shape = (21239, 1)     ✅ 能喂(形状对)
df[['cycle']].to_numpy()               shape = (21239, 1)     ✅ 能喂(形状对)
df['cycle'].to_numpy()                 shape = (21239,)       ❌ 一维,sklearn 直接拒绝
df['cycle'].to_numpy().reshape(1, -1)  shape = (1, 21239)     ❌ 样本数 1 ≠ len(y) 21239
df[['cycle', 'soh']]                   shape = (21239, 2)     ⚠️ 形状能喂,但 soh 是标签 → 泄漏(第 04 章)

★ 逐条看:

#形状判断原因
1(21239,)❌一维,没有"哪一维是样本"的约定
2(21239, 1)✅推荐写法:二维 + 保留列名
3(21239, 1)✅能跑,但丢了列名(第 04 章 4.6 的警告)
4(21239,)❌和 1 是同一个东西
5(1, 21239)❌形状是二维的,但含义反了
6(21239, 2)⚠️形状全对、能跑、R² 会等于 1.000000

★ 第 5 条最值得停一下:

形状 (1, 21239) 是【二维】的 —— 它满足了"X 必须二维"这一条
   ↓
它还是错的
   ↓
因为"二维"只是必要条件,不是充分条件:
     还必须满足 X.shape[0] == len(y)

所以完整的检查是两条:

python
assert X.ndim == 2
assert X.shape[0] == len(y)

★ 第 6 条是"形状全对但问题全错":

python
df[["cycle", "soh"]]     # 形状 (21239, 2),二维,样本数对,dtype 是 float
                         # 从"形状检查"的角度看,完美
                         # 但它把要预测的答案放进了输入

形状检查只能挡住"技术性错误",挡不住"逻辑性错误"。 后面这一类要靠第 04 章的三个问题(身份?标签来源?预测时刻拿得到吗?)。

练习 2:宽表 ↔ 长表,验证"信息量守恒"

用 cycle、resistance_mohm、coulombic_eff 三列:

  1. 转成长表,打印 wide.shape、long.shape;
  2. 验证两者的数值个数相同(信息量守恒);
  3. 说明长表的行数是宽表的几倍,这个倍数等于什么;
  4. pivot 回宽表,验证和原表逐元素相同。
参考答案
python
import pandas as pd

df = pd.read_csv("data/battery/soh_features.csv")
FEATS = ["cycle", "resistance_mohm", "coulombic_eff"]

wide = df[FEATS].copy()
long = wide.copy()
long.insert(0, "row_id", range(len(long)))
long = long.melt(id_vars="row_id", var_name="feature", value_name="value")

print(f"wide.shape = {wide.shape}   数值个数 = {wide.size:,d}")
print(f"long.shape = {long.shape}   数值个数 = {long['value'].size:,d}")
print(f"数值个数守恒:{wide.size == long['value'].size}")
print(f"行数放大倍数 = {len(long) / len(wide):.0f} = 特征个数 {len(FEATS)}")

back = long.pivot_table(index="row_id", columns="feature", values="value")[FEATS]
print(f"pivot 回来 shape = {back.shape},与原表逐元素相同:"
      f"{bool((back.to_numpy() == wide.to_numpy()).all())}")

期望输出:

text
wide.shape = (21239, 3)   数值个数 = 63,717
long.shape = (63717, 3)   数值个数 = 63,717
数值个数守恒:True
行数放大倍数 = 3 = 特征个数 3
pivot 回来 shape = (21239, 3),与原表逐元素相同:True

★ 逐条回答:

③ 放大倍数就是特征个数(这里是 3)。用全部 7 个特征时是 7 倍:

21,239 × 7 = 148,673 行

这个数字要记住,因为它是"看起来数据变多了"这个错觉的来源。

★ 一个更本质的说法:

宽表:(21239, 7)   →  行是"观测",列是"变量"
长表:(148673, 3)  →  行是"(观测, 变量) 对",列是"元数据"

宽表的一行 = 长表的一整块(7 行)

★ 还有一个容易漏掉的细节:长表的第一列变成了字符串

python
long["feature"].dtype     # str

这就是长表不能直接喂模型的原因(练习 1 里的第 6 条是"逻辑错", 这里是"类型错"):

python
LinearRegression().fit(long[["feature"]], long["value"])
# ValueError: could not convert string to float: 'cycle'

⚠️ 而最危险的改法是:

python
# 有人会这么"修":把字符串那一列丢掉,只喂数值列
LinearRegression().fit(long[["value"]], ...)
# 不报错了 —— 但你把 21,239 个样本 × 7 个特征,
# 变成了 148,673 个"样本" × 1 个特征
# 同一批数据被重复算了 7 遍

★ 判据(再重复一次,因为它值钱):

X.shape[1] 必须等于特征个数。不等于,就是形状错了。

练习 3:写一个 check_xy,把形状问题拦在 fit 之前

写一个函数 check_xy(X, y),依次检查:

  1. X 有 shape(是数组或表);
  2. X 是二维的;
  3. X 的样本数和 y 一致;
  4. X 不是空的;
  5. X 里没有非数值列。

然后用下面五个输入测试它,打印每个输入被哪一条挡住:

输入期望
df["cycle"]第 2 条
df[["cycle"]].head(10)第 3 条
df[["cell_id", "cycle"]]第 5 条
df[["cycle"]].head(0)第 4 条
df[["cycle"]]全过
参考答案
python
import pandas as pd

df = pd.read_csv("data/battery/soh_features.csv")
y = df["soh"]


def check_xy(X, y, name="X"):
    """三道检查:二维、样本数对齐、纯数值。返回是否通过。"""
    shape = getattr(X, "shape", None)
    assert shape is not None, f"{name} 没有 shape(不是数组/表)"
    assert len(shape) == 2, f"{name} 必须是二维:现在是 {len(shape)} 维,shape={shape}"
    arr = X.to_numpy() if hasattr(X, "to_numpy") else X
    assert arr.shape[0] == len(y), (
        f"样本数不匹配:{name} 有 {arr.shape[0]} 行,y 有 {len(y)} 个")
    assert arr.shape[0] > 0, f"{name} 一行数据都没有(shape={shape})"
    assert arr.dtype != object, f"{name} 里有非数值列,dtype={arr.dtype}"
    return True


cases = [
    ("一维 Series", df["cycle"], y),
    ("长度不匹配", df[["cycle"]].head(10), y),
    ("混了字符串列", df[["cell_id", "cycle"]], y),
    ("空表", df[["cycle"]].head(0), df["soh"].head(0)),
    ("正确的 X", df[["cycle"]], y),
]
for label, X, yy in cases:
    try:
        check_xy(X, yy)
        print(f"{label:<14s} → ✅ 通过({X.shape})")
    except AssertionError as exc:
        print(f"{label:<14s} → ❌ {exc}")

期望输出:

text
一维 Series      → ❌ X 必须是二维:现在是 1 维,shape=(21239,)
长度不匹配          → ❌ 样本数不匹配:X 有 10 行,y 有 21239 个
混了字符串列         → ❌ X 里有非数值列,dtype=object
空表             → ❌ X 一行数据都没有(shape=(0, 1))
正确的 X          → ✅ 通过((21239, 1))

★ 这个函数的每一条断言都对应这一章的一个坑:

断言挡住的坑没有它会发生什么
len(shape) == 2一维 Seriessklearn 报错,但报错信息不如你的清楚
arr.shape[0] == len(y)reshape(1, -1)、切错行报错(这次是 sklearn 帮你)
arr.shape[0] > 0切空了不报错,R² 会是 nan 或抛一个难懂的异常
arr.dtype != object混了字符串列报错,但你要自己找出是哪一列

★ 为什么值得写这个函数

不写:
   形状错误在 fit() 那一刻才暴露
   报错信息是 sklearn 的口吻(不知道你的业务)
   ↓
   你要回头翻"我刚才切的是哪几列"

写了:
   错误在【你自己的代码里】、离错误发生的地方最近的那一行报出来
   ↓
   信息是你自己写的,能直接指到问题

★ 一个"空表"的真实教训

python
# 按条件筛选之后忘了检查
sub = df[df["cycle"] > 1400]
print(sub.shape)          # (0, 7)   ← 一行都没有!
model.fit(sub[FEATS], sub["soh"])
# 不报错。R² 是 nan,或者抛一个你从没见过的异常

这类"筛选筛空了"的错误在真实项目里非常常见(比如时间范围写错、批次名拼错), 而它恰好是形状检查唯一能提前发现的那一类。

★ 一条纪律(第 04 章练习 2 的同一条,这里加强):

在数据管道里,把"我以为的形状"写成断言。 断言的报错信息要写清"我期望什么、实际是什么"。

面向工程实践:形状是"数据契约"的第一行 ​

这一章讲的都是形状,看起来是最没有"算法含量"的一章。 但它决定的事比任何模型都靠前:

形状错了
   ↓
后面所有结论都不可信
   ↓
而且它错得很安静(第 5.5、5.6 节)

① 生产代码里的第一件事:在边界上校验形状

python
# src/schema.py
FEATS = ("cycle", "mean_charge_voltage_v", "mean_temp_rise_c",
         "resistance_mohm", "coulombic_eff",
         "cap_fade_rate_ah_per_100", "temp_std_c")
TARGET = "soh"
N_FEATURES = len(FEATS)          # 7


def validate_X(X, n_samples=None):
    """所有进入模型的 X 都要过这一道。"""
    assert X.ndim == 2, f"X 必须二维,实际 {X.shape}"
    assert X.shape[1] == N_FEATURES, (
        f"特征数应为 {N_FEATURES},实际 {X.shape[1]};列 = {list(X.columns)}")
    assert list(X.columns) == list(FEATS), "特征顺序/名字与训练时不一致"
    if n_samples is not None:
        assert X.shape[0] == n_samples, f"样本数不匹配:{X.shape[0]} vs {n_samples}"

为什么值得写:

离线训练:形状错了你马上会发现(R² 很怪)
在线推理:形状错了没人告诉你(预测值静静地偏了)

② 一个真实事故的形状(范式,不是虚构的具体案例)

数据源从"宽表导出"改成了"长表导出"
   ↓
下游代码没变,还是 df[FEATS]
   ↓
长表里没有 "cycle" 这一列(它在 feature 列里)→ KeyError?不一定
   ↓
如果上游把 feature 列也一起展开了,你可能拿到 148,673 行
   ↓
训练脚本照跑,因为形状"看起来是二维的"
   ↓
样本量突然变成 7 倍,分数还不错
   ↓
上线后才发现:模型在把同一批数据数了 7 遍

★ 挡住这个事故只需要一行:

python
assert len(df) == EXPECTED_ROWS, f"行数从 {EXPECTED_ROWS} 变成了 {len(df)}"

③ 把形状写进文档和接口

python
def make_xy(df, feats=FEATS, target=TARGET):
    """
    Returns
    -------
    X : pd.DataFrame, shape (n_samples, n_features)
    y : pd.Series,    shape (n_samples,)
    """

★ 为什么写 shape 不是"文档洁癖":

写清了 shape
   ↓
调用的人不用读你的实现,就知道该传什么
   ↓
"形状不对"这类问题从"联调时发现"提前到"读文档时发现"

④ 一个可以立刻用的三条检查

python
print(f"X.shape = {X.shape}   y.shape = {y.shape}")
print(f"读出来:{X.shape[0]:,d} 个样本,每个 {X.shape[1]} 个特征")
assert X.shape[0] == len(y)

这三行加起来不到 3 秒钟。

★ 这一章教的东西,一句话:

形状不是"技术细节",它是"这份数据代表什么"的第一层声明。 声明错了,后面全是空中楼阁。

下一章我们终于开始 fit() —— 而你已经知道, 在那之前该先打印哪两个形状。


下一章:第 06 章 · 最小线性回归:给退化画一条直线