第 22 章 · 聚合与布尔索引:读真实电池文件做统计
这一章要解决什么:第一次处理真实文件(1.4 万行 CSV),用 NumPy 完成一整套统计。 打掉一个高频报错:多条件筛选必须用
&加括号,用and会直接报ValueError。
本章你需要完成的代码:demos/ch22-aggregate.py前置:下载包已在 code/data/battery/ 下(无需生成数据)
22.1 用 np.loadtxt 读一列出来
from pathlib import Path
import numpy as np
CODE_DIR = Path(__file__).resolve().parent.parent
CSV_PATH = CODE_DIR / "data" / "battery" / "cycle_raw.csv"
r = np.loadtxt(CSV_PATH, delimiter=",", skiprows=1, usecols=6)
print(r.shape) # (13967,)
print(r[:5]) # [40.33 39.47 39.85 39.78 40. ]三个参数的意思:
| 参数 | 值 | 说明 |
|---|---|---|
delimiter | "," | 列之间用逗号分隔 |
skiprows | 1 | 跳过第 1 行(表头) |
usecols | 6 | 只取第 6 列(下标从 0 开始) |
usecols=6 是怎么数出来的:
下标: 0 1 2 3 4 5 6 7 8
列名: cell_id batch cycle timestamp capacity_ah soh resistance_mohm temp_rise_c coulombic_eff
^^^^^^^^^^^^^^^^
下标 6 = 内阻skiprows=1 也可以写 skiprows=1 以外的值,比如跳过前 3 行的元数据。 但要注意:跳过表头后,usecols 仍然按原始列下标数。
点开:np.loadtxt 的三个限制(为什么本书马上要转向 Pandas)
限制一:只能读纯数值。
如果 CSV 里有字符串列,np.loadtxt 会尝试把它们转成 float 然后报错:
np.loadtxt(CSV_PATH, delimiter=",", skiprows=1)
# ValueError: could not convert string to float: 'C001'只能靠 usecols 把数值列挑出来。
限制二:读不了缺失值(至少不优雅)。
dirty.csv 里有 NaN,np.loadtxt 遇到空字段会报错:
np.loadtxt("dirty.csv", delimiter=",", skiprows=1, usecols=6)
# ValueError: could not convert string to float: ''变通办法:
r = np.genfromtxt(path, delimiter=",", skip_header=1, usecols=6,
missing_values="", filling_values=np.nan)np.genfromtxt 更宽容,但更慢。
限制三:没有列名、没有类型推断、没有索引。
读进来的就是一个裸数组,你得自己记住"第 6 列是内阻"。
对比一下 Pandas:
import pandas as pd
df = pd.read_csv(CSV_PATH) # 一行
df["resistance_mohm"] # 按列名取,不用数下标
df.isna().sum() # 自动识别缺失所以第 23 章起全面转向 Pandas。 但 NumPy 的 loadtxt 有两个场景仍然好用:
- 纯数值的小文件(比如传感器导出的一个矩阵)—— 一行读完,没有额外依赖;
- 需要极致速度读大文件时,
np.loadtxt比pd.read_csv快(但不支持混合类型)。
还有一个常用选择:pd.read_csv(..., usecols=[...]) 也能只读需要的列, 在列很多的大文件上能显著提速。
22.2 聚合函数
print(f"样本数 r.size = {r.size:,d}") # 13,967
print(f"均值 r.mean() = {r.mean():.4f}") # 54.8383
print(f"标准差 r.std() = {r.std():.4f}") # 11.6128
print(f"最小值 r.min() = {r.min():.2f}") # 38.81
print(f"最大值 r.max() = {r.max():.2f}") # 87.57
print(f"极差 np.ptp(r) = {np.ptp(r):.2f}") # 48.76
print(f"中位数 np.median(r) = {np.median(r):.2f}") # 52.80
print(f"四分位 np.percentile(r, [25, 50, 75])") # [44.52 52.8 63.42]
print(f"求和 r.sum() = {r.sum():,.2f}") # 765,926.80完整清单:
| 函数 | 含义 | 备注 |
|---|---|---|
.sum() | 求和 | |
.mean() | 均值 | |
.std() | 总体标准差(÷n) | 注意 ddof |
.var() | 方差 | |
.min() / .max() | 最值 | |
np.ptp(x) | 极差(max - min) | ptp = peak to peak |
np.median(x) | 中位数 | 抗异常值 |
np.percentile(x, q) | 分位数 | q 可以是列表 |
.argmin() / .argmax() | 最值的下标 | 不是值! |
.cumsum() / .cumprod() | 累积和 / 累积积 |
点开:std() 的 ddof 是什么?为什么 NumPy 和 Pandas 不一样?
标准差有两个版本:
区别只在分母:总体用 n,样本用 n - 1。
| 默认 ddof | 结果 | |
|---|---|---|
NumPy arr.std() | ddof=0(总体) | 略小 |
Pandas series.std() | ddof=1(样本) | 略大 |
实测差异(本书的数据):
r = np.loadtxt(..., usecols=6)
print(r.std()) # 11.6128 (ddof=0)
import pandas as pd
print(pd.Series(r).std()) # 11.6132 (ddof=1)差别很小(数据量大时 n 和 n-1 几乎一样),但在两种库之间切换时要留意。
该用哪个:
- 推断统计(从样本推断总体):用
ddof=1(样本标准差)。这是统计学惯例。 - 描述统计(就是描述这堆数据本身):用
ddof=0。NumPy 的默认。 - 机器学习里的标准化:
sklearn的StandardScaler用的是ddof=0(和 NumPy 一致)。
怎么避免搞错:
r.std(ddof=1) # 明确写出来在本书里:第 32 章算 Z 分数时用的 x.std() 是 Pandas 版本(ddof=1), 书中会明确标注。关键是保持一致 —— 训练和推理用同一个口径就够了。
为什么推荐 .min() 而不是 min()
print(r.min()) # ✅ 推荐:数组的方法
print(min(r)) # ⚠️ 能用:Python 内置函数三个理由:
- 快:
.min()是编译好的 C 循环;min()会走 Python 的迭代协议; - 语义清楚:
.min()一看就知道是"这个数组的最小值"; - 行为更正确:对含
NaN的数组,两者行为可能不同(见 22.4)。
22.3 布尔掩码:把所有 > 75 的内阻挑出来
mask = r > 75
print(mask.shape, mask.dtype) # (13967,) bool
print(mask[:12])
# [False False ... False]
hi = r[mask]
print(hi.size) # 899
print(hi[:8]) # [75.05 75.17 75.15 75.16 76.23 75.15 76.12 76.29]四行搞定"筛选 + 计数 + 求占比 + 对比":
mask = r > 75
print(f"个数: {mask.sum()}") # 899
print(f"占比: {mask.mean():.1%}") # 6.4%
print(f"高内阻样本均值: {r[mask].mean():.2f}") # 79.39
print(f"全体均值: {r.mean():.2f}") # 54.84一行版(不用中间变量):
print(f"{(r > 75).sum()} 个,占 {(r > 75).mean():.1%}")注意:这里 (r > 75) 计算了两次。对 1.4 万行无所谓, 但如果是 1 亿行,就会白白多算一遍 —— 这时中间变量不只是可读性,也是性能。
为什么要看"高内阻样本的均值",而不只看全体均值
print(f"高内阻样本 {r[mask].mean():.2f} mΩ vs 全体 {r.mean():.2f} mΩ")
# 高内阻样本 79.39 mΩ vs 全体 54.84 mΩ 差 +24.55 mΩ这是工业数据分析的一个基本视角:均值告诉你"整批的中枢在哪里", 尾部(高内阻的那 6.4%)才对应"要重点关注的那几支"。
全体均值 54.84 ────────────────────┐
│ 都挺好
高内阻均值 79.39 ────────────┐ │
│ │
r=75 ───┴───────┴──── 阈值
↑
这 899 个样本是"风险样本"这就是后面第 32、33 章(Z 分数、IQR)存在的意义: 不是为了让均值好看,而是为了把尾巴找出来。
22.4 多条件:& | 加括号(最高频错误)
both = r[(r > 70) & (r < 75)]
print(both.size) # 842
inverse = r[~(r > 75)] # 取反
either = r[(r < 40) | (r > 80)] # 或三个必踩的坑:
# ❌ 坑一:用 and
r[(r > 70) and (r < 75)]
# ValueError: The truth value of an array with more than one element is ambiguous.
# Use a.any() or a.all()
# ❌ 坑二:不加括号
r[r > 70 & r < 75]
# & 的优先级高于比较,被解析成 r > (70 & r) < 75 —— 结果错或报错
# ✅ 正确
r[(r > 70) & (r < 75)]点开:运算符优先级表(值得收藏)
从高到低:
1. () 括号
2. ** 幂
3. +x -x ~x 一元运算符(~ 是按位取反,在 NumPy 里当"逻辑非"用)
4. * / // % 乘除类
5. + - 加减类
6. << >> 移位
7. & 按位与(**比比较运算优先级高!**)
8. ^ | 异或、按位或
9. == != < > <= >= 比较
10. not 逻辑非
11. and 逻辑与
12. or 逻辑或关键在第 7 行:& 的优先级高于比较运算(第 9 行)。
所以 r > 70 & r < 75 被解析成:
r > (70 & r) < 75
^^^^^^^^^
先算 70 & r70 & r 是一个"位与"运算,结果是一个数组,然后 r > 数组 比较 —— 语义完全错了。
所以括号不是"保险起见",而是语法要求。
为什么 Python 会这么设计:& 在 Python 里原本是位运算(用于整数), 位运算的优先级自然比比较运算高。NumPy 复用了这个运算符做"逐元素逻辑与", 优先级就继承了下来。
记忆法:
在 NumPy/Pandas 里写多条件,条件两边永远加括号。 不要试图记住优先级,加括号永远是安全的。
22.5 np.where / np.select:条件产生"值"
band = np.where(r > 75, "高", "正常")
print(band[:10])
# ['正常' '正常' '正常' '正常' '正常' '正常' '正常' '正常' '正常' '正常']
print(f"高内阻占比 = {(band == '高').mean():.1%}") # 6.4%np.where(条件, 真值, 假值) 返回的是和原数组同长度的新数组, 里面是选中的值(这里是字符串)。
它和布尔索引的区别:
布尔索引 r[mask] | np.where(cond, a, b) | |
|---|---|---|
| 返回 | 只挑出满足条件的元素(变短) | 同长度的新数组(等长) |
| 用来 | 筛选数据 | 打标签、分档 |
两者都很有用,看你要"少几个样本"还是"每个样本都得有个标签"。
22.6 分箱统计:看分布而不是只看均值
edges = [0, 45, 55, 65, 75, np.inf]
labels = ["很低", "偏低", "中等", "偏高", "很高"]
idx = np.digitize(r, edges[1:-1]) # 返回每个值落在第几个区间
for i, lab in enumerate(labels):
cnt = int((idx == i).sum())
print(f"{lab}: {cnt:,d} 个,{cnt / r.size:.2%}")真实输出:
区间 个数 占比
[ -∞, 45) mΩ 3,723 26.66%
[ 45, 55) mΩ 4,050 29.00%
[ 55, 65) mΩ 3,171 22.70%
[ 65, 75) mΩ 2,124 15.21%
[ 75, +∞) mΩ 899 6.44%np.digitize(x, bins) 返回每个元素落在哪个箱子(0 基的下标)。
为什么分箱看:均值会掩盖分布形状。
┌──────────────────────────────────────────┐
均值 54.84 只说"中心在 54.84 附近"
但它不告诉你:
· 是不是双峰的?(两批电芯表现完全不同)
· 尾巴有多厚?(6.4% 高内阻)
· 有没有异常集中在某一档?分箱 + 计数是"用数字快速看分布"的最低成本方法(画图是另一种,见第 38 章)。
22.7 缺失值:为什么不能直接 mean()
x = np.array([45.0, 52.0, np.nan, 61.0])
print(x.mean()) # nan ← 一遇到 NaN 全变 nan!
print(np.nanmean(x)) # 52.667 ← 忽略 NaN
print(np.isnan(x)) # [False False True False]NaN(Not a Number)有一个"传染性":任何和它做的算术都得到 NaN。
| 函数 | 遇到 NaN 时 |
|---|---|
x.mean() / .sum() / .std() | 返回 NaN |
np.nanmean(x) / np.nansum(x) | 忽略 NaN 计算 |
np.median(x) | 返回 NaN |
np.nanmedian(x) | 忽略 NaN |
规律:带 nan 前缀的版本会跳过 NaN。
为什么 NumPy 不默认跳过:因为"跳过"是一种决策,不是技术细节。 如果一个数据集 80% 都是缺失,跳过之后算出的均值毫无意义。 NumPy 把决策权交给你,要求你明确写 nanmean。
在 Pandas 里不一样:df.mean() 默认就跳过 NaN(因为 Pandas 是面向数据分析的)。 这个差异会在第 23 章之后变得明显。
本书的处理路线:
# NumPy 阶段:知道有 nanmean 这个工具,但尽量别让 NaN 出现
# Pandas 阶段:用 isna() 看清楚,用 fillna/interpolate 处理(第 30 章)关键认知:nan 不是"0",也不是"缺少的某个值"。 它是"我不知道这里是多少"。任何计算碰它都得决定"是跳过还是放弃"。
22.8 用 assert 自检:防止"悄悄算错"
assert r.size == 13967, "行数变了?确认数据是否重新生成过"
assert 50 < r.mean() < 60, "均值异常"
assert (r > 75).mean() < 0.10, "高内阻比例异常"为什么这三行比后面一百行代码都重要:
数据管道里最危险的不是"报错",而是"不报错但算错了"。 比如:
- 数据文件被换成了另一批,行数变了 —— 你的
assert会立刻发现; - 单位从 mΩ 变成了 Ω,均值从 54 变成 0.054 ——
assert 50 < mean < 60会拦下来; - 上游改了数据生成逻辑,异常比例从 6% 涨到 30% —— 最后一个断言会提醒你。
这三条断言的共同特点:它们断言的是业务上合理的范围,而不是代码逻辑。 这类断言最有价值,因为它们捕捉的是"数据本身变了"。
实践建议:每个数据处理脚本的入口和出口各放几条这样的断言。
# 入口:确认输入符合预期
assert len(df) > 0, "空表"
assert df["soh"].between(0, 1).all(), "SOH 超出 [0, 1]"
# 出口:确认输出符合预期
assert out.isna().sum().sum() == 0, "还有残留缺失"
assert len(out) == len(df), "行数变了"这几行代码的成本是 30 秒,收益是"不用在半夜排查数据事故"。
22.9 完整示例与输出
cd code
python demos/ch22-aggregate.py真实输出(节选):
=== ① 用 np.loadtxt 读一列出来 ===
读到的数组: shape=(13967,) dtype=float64
前 5 个值 : [40.33 39.47 39.85 39.78 40. ]
注意:np.loadtxt 只能读「纯数值」。有字符串列(cell_id/batch/timestamp)就必须
usecols 挑列,或者干脆用 Pandas。
=== ② 聚合函数 ===
均值 r.mean() = 54.8383 mΩ
标准差 r.std() = 11.6128
极差 np.ptp(r) = 48.76
25/50/75 分位 np.percentile(r, [25,50,75]) = [44.52 52.8 63.42]
=== ③ 布尔掩码:把所有 > 75 的内阻挑出来 ===
个数 : hi.size = 899
占比 : mask.mean() = 6.44%
均值对比 : 高内阻样本 79.39 mΩ vs 全体 54.84 mΩ
=== ④ 多条件:& | 加括号(最高频错误) ===
❌ r[(r > 70) and (r < 75)] → ValueError: The truth value of an array ... is ambiguous
✅ r[(r > 70) & (r < 75)]
=== ⑦ 用 NaN 演示为什么不能对含缺失的列直接 mean() ===
x.mean() = nan ← 一遇到 NaN 全变 NaN!
np.nanmean(x)= 52.667 ← 忽略 NaN 求均值本章练习
练习 1:读真实数据做聚合
用 np.loadtxt 读 cycle_raw.csv 的 resistance_mohm 列,输出:
- 样本数、均值、标准差;
- 内阻 > 75 mΩ 的样本数与占比;
- 比较高内阻样本和全体的均值差异。
参考答案
from pathlib import Path
import numpy as np
CODE_DIR = Path(__file__).resolve().parent.parent.parent
CSV_PATH = CODE_DIR / "data" / "battery" / "cycle_raw.csv"
# 读第 6 列(列名是 resistance_mohm)
r = np.loadtxt(CSV_PATH, delimiter=",", skiprows=1, usecols=6)
# ① 基本统计
print(f"样本数: {r.size}") # 13967
print(f"内阻均值: {r.mean():.2f} mΩ") # 54.84
print(f"标准差: {r.std():.2f}") # 11.61
# ② 筛选与计数
mask = r > 75
print(f"内阻 > 75 mΩ 的样本: {mask.sum()} 个({mask.mean():.1%})")
# 899 个(6.4%)
# ③ 对比
print(f"高内阻样本均值: {r[mask].mean():.2f} mΩ")
print(f"全体均值: {r.mean():.2f} mΩ")
print(f"差: {r[mask].mean() - r.mean():+.2f} mΩ")
# 差: +24.55 mΩ三个要点:
usecols=6的下标要数对:cell_id是 0,往后数到resistance_mohm是 6。 数错一列会读到完全不同的数据,而且不会报错(都是数值)—— 所以读完先print(r[:5])看一眼量级对不对。mask.sum()是个数、mask.mean()是占比(布尔数组的两个"魔法")。.sum()比len()更合适:len()对 0 维标量会报错, 而且.sum()的语义("有几个 True")更清楚。
完整练习:exercises/ex22-01-aggregate
练习 2:修好这个筛选
import numpy as np
r = np.loadtxt("cycle_raw.csv", delimiter=",", skiprows=1, usecols=6)
# 想筛选:内阻在 60 到 70 之间,或者大于 85
result = r[60 < r < 70 or r > 85]
print(result)要求:说出报错内容、原因,并写出正确的代码。
参考答案
报错:
ValueError: The truth value of an array with more than one element is ambiguous.
Use a.any() or a.all()原因:or 不能用于数组。
Python 的 or 需要判断"左边整体是真还是假"。60 < r < 70 是一个布尔数组 (有 True 有 False),"整体是真是假"没有定义,所以报错。
正确的代码:
result = r[((r > 60) & (r < 70)) | (r > 85)]三个修改点:
| 原写法 | 改成 | 原因 |
|---|---|---|
60 < r < 70 | (r > 60) & (r < 70) | 链式比较对数组无效!60 < r < 70 在 NumPy 里不等于数学上的区间 |
or | | | or 作用于整体,| 作用于逐元素 |
| 无括号 | 每个条件都加括号 | & 和 | 的优先级高于比较运算 |
注意第一个坑最隐蔽:60 < r < 70 在 Python 里是链式比较, 等价于 (60 < r) and (r < 70) —— 而 and 对数组会报错。
但对 NumPy 数组,链式比较的实际行为更微妙:
x = np.array([50, 65, 90])
print(60 < x < 70)
# ValueError: The truth value of an array with more than one element is ambiguous会直接报错,所以你不会得到错误结果 —— 这一点是好的。 但在 Pandas 里更危险:
s = pd.Series([50, 65, 90])
s[60 < s < 70] # 也是报错同样是报错,所以链式比较在数组/Series 上总是失败得很明显。
正确的写法(推荐用中间变量,可读性更好):
in_mid = (r > 60) & (r < 70)
is_high = r > 85
result = r[in_mid | is_high]内存友好的写法(用 | 而不是 or,但结果复用):
mask = (r > 60) & (r < 70)
mask |= r > 85 # 原地"或",不产生新数组
result = r[mask]最后一个提醒:~ 是"取反"(不是 not):
r[~(r > 85)] # ✅ 不大于 85 的
r[not (r > 85)] # ❌ 报错练习 3:为什么这几行输出的结果不同?
import numpy as np
x = np.array([45.0, 52.0, np.nan, 61.0])
print(x.sum())
print(x.mean())
print(np.nansum(x))
print(np.nanmean(x))
print(np.isnan(x).sum())要求:写出每一行的输出,并解释为什么。
参考答案
print(x.sum()) # nan
print(x.mean()) # nan
print(np.nansum(x)) # 158.0 (45 + 52 + 61)
print(np.nanmean(x)) # 52.666666666666664 (158 / 3)
print(np.isnan(x).sum()) # 1为什么 sum() 和 mean() 是 nan:
NaN 有"传染性"—— 任何算术运算只要碰到它,结果就是 NaN:
45 + 52 + NaN + 61
= 97 + NaN
= NaN
= NaN + 61
= NaN为什么 nanmean 是 158/3 而不是 158/4:
np.nanmean 把 NaN 从分母里也去掉了 —— 它统计的是"非 NaN 元素的均值",所以分母是 3 而不是 4。
这一点要特别注意:如果 4 个值里 3 个是 NaN,nanmean 会给出那 1 个值的"均值" —— 技术上没错,但业务上毫无意义。
所以纪律是:
# 先看缺多少,再决定怎么算
missing_rate = np.isnan(x).mean()
print(f"缺失率 {missing_rate:.1%}")
if missing_rate > 0.5:
print("缺失超过一半,这个统计量不可信 —— 先去查数据来源")
else:
print(f"均值 = {np.nanmean(x):.4f}")np.isnan(x).sum() = 1:这是"数缺失个数"的标准写法。 注意不能用 x == np.nan:
print(x == np.nan) # [False False False False] ← 全是 False!
print(np.nan == np.nan) # False ← NaN 不等于自己这是 NaN 最反直觉的性质:NaN != NaN 永远为 True。 所以要判断 NaN 必须用 np.isnan()(或 Pandas 的 pd.isna())。
NaN 的三个"反直觉":
| 表达式 | 结果 | 说明 |
|---|---|---|
np.nan == np.nan | False | NaN 不等于自己 |
np.nan in [np.nan] | True | 但 in 用的是 is(身份比较) |
float("nan") is float("nan") | False | 每次创建都是新对象 |
这就是为什么需要专门的 isnan / isna 函数。
在 Pandas 里的对应(第 23 章起常用):
df.isna().sum() # 每列缺几个
df.isna().sum().sum() # 总共缺几格
df.isna().any(axis=1).sum() # 有几行"至少缺一格"
df["col"].notna() # 非缺失的掩码面向 AI 开发:统计量是"数据体检"的听诊器
这一章的方法看似简单(求均值、筛选、计数),但它们是数据体检的核心工具。 在真实项目里,你会用这套工具做出影响决策的判断。
五个"体检指标"及其含义:
| 指标 | 怎么算 | 能发现什么 |
|---|---|---|
| 缺失率 | isnan(x).mean() | 数据采集是否正常、某列是否该丢弃 |
| 量级范围 | min() / max() / ptp() | 单位错误(V 写成 mV)、传感器故障 |
| 分布形状 | 分位数 + 分箱计数 | 是否偏态、是否双峰(两批混在一起) |
| 尾部比例 | (x > thr).mean() | 风险样本占比 —— 比均值重要得多 |
| 异常比例 | Z 分数 / IQR 的命中率 | 数据质量(第 32、33 章) |
一个真实的判断流程:
# 拿到一列新的传感器读数
r = np.loadtxt(...)
# ① 量级对吗?
print(f"范围: {r.min():.2f} ~ {r.max():.2f}")
# 如果输出 "0.038 ~ 0.087",而你预期是"38 ~ 87" → 单位是 V 不是 mV
# ② 有缺失吗?
print(f"缺失率: {np.isnan(r).mean():.1%}")
# 如果 40% → 这一列可能不该用,或者传感器有问题
# ③ 分布健康吗?
print(f"均值 {np.nanmean(r):.2f},中位数 {np.nanmedian(r):.2f}")
# 如果均值 80、中位数 50 → 严重右偏,说明有大量极值(或者数据混了批次)
# ④ 尾部多厚?
print(f"> 75 的占比: {(r > 75).mean():.1%}")四步下来,你对这列数据的信任程度就有了判断。 这比直接扔进模型、等结果不对再回来查,效率高一个数量级。
一个值得记住的比例:
在成熟的 AI 工程团队里,数据处理与检查的代码量,通常是模型代码的 3~10 倍。
这不是效率低,而是经验:在脏数据上调参,是在优化一个错误的答案。
三个可以直接抄走的检查函数:
def describe_column(x):
"""一列数值数据的体检报告。"""
return {
"n": len(x),
"missing": int(np.isnan(x).sum()),
"missing_rate": float(np.isnan(x).mean()),
"min": float(np.nanmin(x)),
"p25": float(np.nanpercentile(x, 25)),
"median": float(np.nanmedian(x)),
"mean": float(np.nanmean(x)),
"p75": float(np.nanpercentile(x, 75)),
"max": float(np.nanmax(x)),
"std": float(np.nanstd(x)),
}
def check_physical(x, lo, hi, name=""):
"""物理规则检查 —— 不需要统计,最可靠。"""
bad = ((x < lo) | (x > hi)).sum()
if bad:
print(f"[警告] {name}: {bad} 个值超出物理范围 [{lo}, {hi}]")
return bad
def tail_ratio(x, thr):
"""尾部占比 —— 工业上最关心的指标。"""
return float((x > thr).mean())这三个函数在后面的章节里会不断用到, 而第 24 章的 df.describe() 和 df.info() 本质上是 describe_column 的批量版本 —— Pandas 帮你把这件事做得更全、更省事。