第 05 章 · 表格数据的形状:为什么 X 必须是二维的
这一章要解决什么:搞清
X.shape里那两个数字分别是谁, 以及"哪一维是样本"这件事为什么必须先有一个约定。最容易踩的坑: ①
reshape(-1, 1)和reshape(1, -1)占的内存一模一样、数字一模一样,含义正好相反; ② 长表的行数是宽表的 7 倍,看起来"数据变多了",其实一个数字都没多; ③ 当样本数恰好等于特征数时,转置不会报错,只会静默地算出一个不同的模型。
本章你需要完成的代码:demos/ch05-table-shape.py
5.1 两个维度,两个数字
第 04 章说过 X 是二维、y 是一维。这一章要问:二维的"两维"分别是什么?
答案只有两个词:
(n_samples, n_features)
↑ ↑
样本数 特征数
有几个"东西" 每个"东西"量了几个指标真实输出:
整表 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里有几个数字,就知道它是几维。
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,239 | 21,239 | 一行一个样本 —— 但样本之间不独立 |
| 一行是一个人的一次就诊 | 5,000 | 800 | 800 个患者,每人多次 |
| 一行是一个像素 | 65,536 | 1 | 一张 256×256 的图 |
| 转置之后的表 | 7 | 7 | 7 个"样本",每个有 21,239 个特征 |
最后一行的荒谬感是刻意的 —— 同一批数字,换个读法就变成另一个问题。
★ 所以每次打 X.shape 时,都要在心里读出来:
print(f"{X.shape[0]:,d} 个样本,每个 {X.shape[1]} 个特征")
# 21,239 个样本,每个 7 个特征 ← 读出来,别只看着数字这句话会暴露很多错误。比如你以为是 60 个样本(设备), 读出来却是 21,239 个 —— 那你立刻会想起第 03 章的伏笔: 这里的"样本"其实是一个"窗口",不是一支设备。
5.2 一行到底是什么
先看这张表的前三行长什么样:
真实输出:
第 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 次观测上的取值★ 用一个心智模型记住它:
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 | 一些工程文献 |
★ 为什么值得看一眼这张表
因为报错信息和文档里会混着用:
sklearn : n_samples, n_features
pandas : rows, columns
numpy : axis 0, axis 1
统计书 : observations, variables四个词表说的是同一件事。看到 n_samples 就想到"行",看到 n_features 就想到"列", 这一层翻译做完,剩下的就是技术问题了。
⚠️ 一个例外:dimension(维度)这个词很危险。 它有时指"特征数"(这张表有 7 维),有时指"数组有几个轴"(这是二维数组)。 本书里"维度"一律指后者(轴的个数),"特征数"就叫特征数。
5.3 约定不是真理:同一堆数字的两种读法
现在到了这一章的核心。我们把 cycle 这一列拉直成一串数字,然后用两种方式"折叠"回去:
真实输出:
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]…看懂这一段:
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) |
★ 危险的不是"哪个对",而是"你换工具时不检查":
# 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 强制二维
报错信息长这样:
ValueError: Expected a 2-dimensional container but got <class 'pandas.Series'> instead为什么它不"聪明"地猜一下? 因为猜不出来:
一维数组 (21239,)
├─ 读法 A:(21239, 1) 21,239 个样本,每个 1 个特征 ← 你想要的
└─ 读法 B:(1, 21239) 1 个样本,有 21,239 个特征 ← 也不是不可能两种读法都合法、都无法从数据本身判断。
如果 sklearn 猜 A:
你其实想做 B 的时候,它静默地给你一个错的结果
如果 sklearn 直接报错:
你改一行代码,它给你正确的结果★ 这是一个贯穿全书的主题:
宁可报错,不要静默地猜。最危险的错误是"不报错但结果错了"。
顺便说清 shape 和 ndim 的关系:
X.ndim # 2 ← 有几个轴
X.shape # (21239, 7) ← 每个轴多长
len(X.shape) == X.ndim # True检查"是不是二维",可以只看 ndim:
assert X.ndim == 2, f"X 必须是二维,现在是 {X.ndim} 维:{X.shape}"这一行断言比"跑一遍看报不报错"便宜得多。
5.5 一个转置不报错的陷阱
前面说"形状错了 sklearn 会报错"。这一节说一个例外:
真实输出:
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时,转置是静默的。
真实项目里方阵很少见,但下面这种情况不少:
X = df[FEATS].to_numpy()
X = X.T # 手滑/复制粘贴
# 如果 len(df) 恰好等于 len(FEATS),你什么提示都看不到怎么防:不要在建模代码里写 .T。
转置通常出现在哪:
① 数据从别的语言/R 导进来,方向是反的
② 你想"把设备当成样本、把时间点当成特征"(那是另一个建模问题)
③ 矩阵运算的中间步骤(协方差、投影等)
①②要显式地改,并且【改完立刻打印 shape】
③属于数学运算,不进 sklearn 的 fit()★ 一个 3 秒钟的习惯,能挡住上面全部问题:
print(f"X.shape = {X.shape} y.shape = {y.shape}")
model.fit(X, y)这不是"调试代码",这是安全带。第 09 章之后你会看到, 划分数据的时候形状还会再变一次(那时更要打)。
5.6 三个"看起来是二维"的假象
二维不等于"能喂"。下面三种情况的形状都是二维,但都过不了。
假象一:.to_numpy() 之后的 object 数组
真实输出:
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() 看起来像是在"把表变成模型要的数组",很正当。
检查办法:
print(X.dtypes) # DataFrame:逐列看
print(X.to_numpy().dtype) # numpy:只能看一个第一行信息量更大 —— 它能告诉你是哪一列是字符串。
假象二:(1, n) 与 (n, 1)
这个在前面 5.3 已经量化过:
(21239, 1) → 21,239 个样本 ← 你想要的
(1, 21239) → 1 个样本 ← 内存一样、数字一样区别只在"哪一维是 1",而这一维的位置决定了全部含义。
★ 一个快速的记忆法:
第二维是 1 → 每个样本只有 1 个特征 (单特征模型)
第一维是 1 → 只有 1 个样本 (几乎总是错的)假象三:长表的行数
这个值得单独一节讲 —— 见 5.7。
5.7 宽表 vs 长表
同一份数据有两种排布方式:
真实输出:
宽表(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,673 | 148,673 |
| 能直接喂 sklearn | ✅ | ❌ |
| 适合画分组图 | ❌ | ✅ |
★ 这里有一个非常容易犯的错:
看到长表有 148,673 行
↓
"哇,数据变多了 7 倍!"
↓
错。 148,673 = 21,239 × 7,【一个数字都没多】行数变多只是因为"把列名拆进了每一行"。
用一句话概括两种表的取舍:
宽表是"给模型看的";长表是"给人看的"(也给人画图、做缺失值分析用)。
点开:什么时候必须用长表(以及什么时候必须转回去)
必须用长表的场合:
# ① 画分组箱线图 / 小提琴图: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())
# ④ 特征会随时间"增加"的场景(比如新加了一路传感器)
# 宽表要改列,长表只需要多几行必须转回宽表的场合:
# 只要 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 的时候有两个细节要注意:
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 常见坑
① X 用一维 Series → ValueError: Expected 2D array(第 04 章的坑,本质是"没有约定")
② reshape(1, -1) → 内存和数字都一样,但样本数变成 1 → 样本数不匹配
③ 方阵转置 → 形状不变,不报错,只有 coef_ 变了
④ .to_numpy() 混字符串列 → 形状是二维,dtype 是 object → 报错原因和形状无关
⑤ 长表直接喂 → 行数放大 7 倍,看着"数据更多",其实一样
⑥ 长表 drop 掉 feature 列 → 能跑,但把同一批数据重复算了 7 倍(最危险的一个)★ 这六个坑里,①②④⑤都会报错,只有 ③⑥ 是静默的。
报错的坑:改一行代码就好
静默的坑:可能一路活到上线所以本书的套路始终是:
先让它报错 → 再讲它为什么报错 → 最后讲"不报错的那一类怎么防"5.9 完整示例与输出
cd code
python demos/ch05-table-shape.py真实输出(节选):
=== ① 一张表的两个维度 ===
整表 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)?先写答案,再跑代码验证。
| # | 写法 |
|---|---|
| 1 | df["cycle"] |
| 2 | df[["cycle"]] |
| 3 | df[["cycle"]].to_numpy() |
| 4 | df["cycle"].to_numpy() |
| 5 | df["cycle"].to_numpy().reshape(1, -1) |
| 6 | df[["cycle", "soh"]] |
要求:每一条都给出 shape 和"能/不能"的判断,第 6 条还要说明"能跑"背后的问题。
参考答案
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}")期望输出:
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)所以完整的检查是两条:
assert X.ndim == 2
assert X.shape[0] == len(y)★ 第 6 条是"形状全对但问题全错":
df[["cycle", "soh"]] # 形状 (21239, 2),二维,样本数对,dtype 是 float
# 从"形状检查"的角度看,完美
# 但它把要预测的答案放进了输入形状检查只能挡住"技术性错误",挡不住"逻辑性错误"。 后面这一类要靠第 04 章的三个问题(身份?标签来源?预测时刻拿得到吗?)。
练习 2:宽表 ↔ 长表,验证"信息量守恒"
用 cycle、resistance_mohm、coulombic_eff 三列:
- 转成长表,打印
wide.shape、long.shape; - 验证两者的数值个数相同(信息量守恒);
- 说明长表的行数是宽表的几倍,这个倍数等于什么;
pivot回宽表,验证和原表逐元素相同。
参考答案
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())}")期望输出:
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 行)★ 还有一个容易漏掉的细节:长表的第一列变成了字符串
long["feature"].dtype # str这就是长表不能直接喂模型的原因(练习 1 里的第 6 条是"逻辑错", 这里是"类型错"):
LinearRegression().fit(long[["feature"]], long["value"])
# ValueError: could not convert string to float: 'cycle'⚠️ 而最危险的改法是:
# 有人会这么"修":把字符串那一列丢掉,只喂数值列
LinearRegression().fit(long[["value"]], ...)
# 不报错了 —— 但你把 21,239 个样本 × 7 个特征,
# 变成了 148,673 个"样本" × 1 个特征
# 同一批数据被重复算了 7 遍★ 判据(再重复一次,因为它值钱):
X.shape[1]必须等于特征个数。不等于,就是形状错了。
练习 3:写一个 check_xy,把形状问题拦在 fit 之前
写一个函数 check_xy(X, y),依次检查:
X有shape(是数组或表);X是二维的;X的样本数和y一致;X不是空的;X里没有非数值列。
然后用下面五个输入测试它,打印每个输入被哪一条挡住:
| 输入 | 期望 |
|---|---|
df["cycle"] | 第 2 条 |
df[["cycle"]].head(10) | 第 3 条 |
df[["cell_id", "cycle"]] | 第 5 条 |
df[["cycle"]].head(0) | 第 4 条 |
df[["cycle"]] | 全过 |
参考答案
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}")期望输出:
一维 Series → ❌ X 必须是二维:现在是 1 维,shape=(21239,)
长度不匹配 → ❌ 样本数不匹配:X 有 10 行,y 有 21239 个
混了字符串列 → ❌ X 里有非数值列,dtype=object
空表 → ❌ X 一行数据都没有(shape=(0, 1))
正确的 X → ✅ 通过((21239, 1))★ 这个函数的每一条断言都对应这一章的一个坑:
| 断言 | 挡住的坑 | 没有它会发生什么 |
|---|---|---|
len(shape) == 2 | 一维 Series | sklearn 报错,但报错信息不如你的清楚 |
arr.shape[0] == len(y) | reshape(1, -1)、切错行 | 报错(这次是 sklearn 帮你) |
arr.shape[0] > 0 | 切空了 | 不报错,R² 会是 nan 或抛一个难懂的异常 |
arr.dtype != object | 混了字符串列 | 报错,但你要自己找出是哪一列 |
★ 为什么值得写这个函数
不写:
形状错误在 fit() 那一刻才暴露
报错信息是 sklearn 的口吻(不知道你的业务)
↓
你要回头翻"我刚才切的是哪几列"
写了:
错误在【你自己的代码里】、离错误发生的地方最近的那一行报出来
↓
信息是你自己写的,能直接指到问题★ 一个"空表"的真实教训
# 按条件筛选之后忘了检查
sub = df[df["cycle"] > 1400]
print(sub.shape) # (0, 7) ← 一行都没有!
model.fit(sub[FEATS], sub["soh"])
# 不报错。R² 是 nan,或者抛一个你从没见过的异常这类"筛选筛空了"的错误在真实项目里非常常见(比如时间范围写错、批次名拼错), 而它恰好是形状检查唯一能提前发现的那一类。
★ 一条纪律(第 04 章练习 2 的同一条,这里加强):
在数据管道里,把"我以为的形状"写成断言。 断言的报错信息要写清"我期望什么、实际是什么"。
面向工程实践:形状是"数据契约"的第一行
这一章讲的都是形状,看起来是最没有"算法含量"的一章。 但它决定的事比任何模型都靠前:
形状错了
↓
后面所有结论都不可信
↓
而且它错得很安静(第 5.5、5.6 节)① 生产代码里的第一件事:在边界上校验形状
# 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 遍★ 挡住这个事故只需要一行:
assert len(df) == EXPECTED_ROWS, f"行数从 {EXPECTED_ROWS} 变成了 {len(df)}"③ 把形状写进文档和接口
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
↓
调用的人不用读你的实现,就知道该传什么
↓
"形状不对"这类问题从"联调时发现"提前到"读文档时发现"④ 一个可以立刻用的三条检查
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() —— 而你已经知道, 在那之前该先打印哪两个形状。