Skip to content

第 22 章 · 聚合与布尔索引:读真实电池文件做统计 ​

这一章要解决什么:第一次处理真实文件(1.4 万行 CSV),用 NumPy 完成一整套统计。 打掉一个高频报错:多条件筛选必须用 & 加括号,用 and 会直接报 ValueError。

本章你需要完成的代码:demos/ch22-aggregate.py前置:下载包已在 code/data/battery/ 下(无需生成数据)

22.1 用 np.loadtxt 读一列出来 ​

python
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","列之间用逗号分隔
skiprows1跳过第 1 行(表头)
usecols6只取第 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 然后报错:

python
np.loadtxt(CSV_PATH, delimiter=",", skiprows=1)
# ValueError: could not convert string to float: 'C001'

只能靠 usecols 把数值列挑出来。

限制二:读不了缺失值(至少不优雅)。

dirty.csv 里有 NaN,np.loadtxt 遇到空字段会报错:

python
np.loadtxt("dirty.csv", delimiter=",", skiprows=1, usecols=6)
# ValueError: could not convert string to float: ''

变通办法:

python
r = np.genfromtxt(path, delimiter=",", skip_header=1, usecols=6,
                  missing_values="", filling_values=np.nan)

np.genfromtxt 更宽容,但更慢。

限制三:没有列名、没有类型推断、没有索引。

读进来的就是一个裸数组,你得自己记住"第 6 列是内阻"。

对比一下 Pandas:

python
import pandas as pd
df = pd.read_csv(CSV_PATH)          # 一行
df["resistance_mohm"]                # 按列名取,不用数下标
df.isna().sum()                      # 自动识别缺失

所以第 23 章起全面转向 Pandas。 但 NumPy 的 loadtxt 有两个场景仍然好用:

  1. 纯数值的小文件(比如传感器导出的一个矩阵)—— 一行读完,没有额外依赖;
  2. 需要极致速度读大文件时,np.loadtxt 比 pd.read_csv 快(但不支持混合类型)。

还有一个常用选择:pd.read_csv(..., usecols=[...]) 也能只读需要的列, 在列很多的大文件上能显著提速。

22.2 聚合函数 ​

python
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 不一样?

标准差有两个版本:

σ总体=∑(xi−μ)2ns样本=∑(xi−x¯)2n−1

区别只在分母:总体用 n,样本用 n - 1。

默认 ddof结果
NumPy arr.std()ddof=0(总体)略小
Pandas series.std()ddof=1(样本)略大

实测差异(本书的数据):

python
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 一致)。

怎么避免搞错:

python
r.std(ddof=1)          # 明确写出来

在本书里:第 32 章算 Z 分数时用的 x.std() 是 Pandas 版本(ddof=1), 书中会明确标注。关键是保持一致 —— 训练和推理用同一个口径就够了。

为什么推荐 .min() 而不是 min() ​

python
print(r.min())          # ✅ 推荐:数组的方法
print(min(r))           # ⚠️ 能用:Python 内置函数

三个理由:

  1. 快:.min() 是编译好的 C 循环;min() 会走 Python 的迭代协议;
  2. 语义清楚:.min() 一看就知道是"这个数组的最小值";
  3. 行为更正确:对含 NaN 的数组,两者行为可能不同(见 22.4)。

22.3 布尔掩码:把所有 > 75 的内阻挑出来 ​

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

四行搞定"筛选 + 计数 + 求占比 + 对比":

python
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

一行版(不用中间变量):

python
print(f"{(r > 75).sum()} 个,占 {(r > 75).mean():.1%}")

注意:这里 (r > 75) 计算了两次。对 1.4 万行无所谓, 但如果是 1 亿行,就会白白多算一遍 —— 这时中间变量不只是可读性,也是性能。

为什么要看"高内阻样本的均值",而不只看全体均值 ​

python
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 多条件:& | 加括号(最高频错误) ​

python
both = r[(r > 70) & (r < 75)]
print(both.size)                    # 842

inverse = r[~(r > 75)]              # 取反
either = r[(r < 40) | (r > 80)]     # 或

三个必踩的坑:

python
# ❌ 坑一:用 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 & r

70 & r 是一个"位与"运算,结果是一个数组,然后 r > 数组 比较 —— 语义完全错了。

所以括号不是"保险起见",而是语法要求。

为什么 Python 会这么设计:& 在 Python 里原本是位运算(用于整数), 位运算的优先级自然比比较运算高。NumPy 复用了这个运算符做"逐元素逻辑与", 优先级就继承了下来。

记忆法:

在 NumPy/Pandas 里写多条件,条件两边永远加括号。 不要试图记住优先级,加括号永远是安全的。

22.5 np.where / np.select:条件产生"值" ​

python
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 分箱统计:看分布而不是只看均值 ​

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

真实输出:

text
区间                    个数       占比
[  -∞,   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() ​

python
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 章之后变得明显。

本书的处理路线:

python
# NumPy 阶段:知道有 nanmean 这个工具,但尽量别让 NaN 出现
# Pandas 阶段:用 isna() 看清楚,用 fillna/interpolate 处理(第 30 章)

关键认知:nan 不是"0",也不是"缺少的某个值"。 它是"我不知道这里是多少"。任何计算碰它都得决定"是跳过还是放弃"。

22.8 用 assert 自检:防止"悄悄算错" ​

python
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% —— 最后一个断言会提醒你。

这三条断言的共同特点:它们断言的是业务上合理的范围,而不是代码逻辑。 这类断言最有价值,因为它们捕捉的是"数据本身变了"。

实践建议:每个数据处理脚本的入口和出口各放几条这样的断言。

python
# 入口:确认输入符合预期
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 完整示例与输出 ​

bash
cd code
python demos/ch22-aggregate.py

真实输出(节选):

text
=== ① 用 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 列,输出:

  1. 样本数、均值、标准差;
  2. 内阻 > 75 mΩ 的样本数与占比;
  3. 比较高内阻样本和全体的均值差异。
参考答案
python
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Ω

三个要点:

  1. usecols=6 的下标要数对:cell_id 是 0,往后数到 resistance_mohm 是 6。 数错一列会读到完全不同的数据,而且不会报错(都是数值)—— 所以读完先 print(r[:5]) 看一眼量级对不对。
  2. mask.sum() 是个数、mask.mean() 是占比(布尔数组的两个"魔法")。
  3. .sum() 比 len() 更合适:len() 对 0 维标量会报错, 而且 .sum() 的语义("有几个 True")更清楚。

完整练习:exercises/ex22-01-aggregate

练习 2:修好这个筛选
python
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),"整体是真是假"没有定义,所以报错。

正确的代码:

python
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 数组,链式比较的实际行为更微妙:

python
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 里更危险:

python
s = pd.Series([50, 65, 90])
s[60 < s < 70]          # 也是报错

同样是报错,所以链式比较在数组/Series 上总是失败得很明显。

正确的写法(推荐用中间变量,可读性更好):

python
in_mid = (r > 60) & (r < 70)
is_high = r > 85
result = r[in_mid | is_high]

内存友好的写法(用 | 而不是 or,但结果复用):

python
mask = (r > 60) & (r < 70)
mask |= r > 85              # 原地"或",不产生新数组
result = r[mask]

最后一个提醒:~ 是"取反"(不是 not):

python
r[~(r > 85)]                # ✅ 不大于 85 的
r[not (r > 85)]             # ❌ 报错
练习 3:为什么这几行输出的结果不同?
python
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())

要求:写出每一行的输出,并解释为什么。

参考答案
python
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 个值的"均值" —— 技术上没错,但业务上毫无意义。

所以纪律是:

python
# 先看缺多少,再决定怎么算
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:

python
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.nanFalseNaN 不等于自己
np.nan in [np.nan]True但 in 用的是 is(身份比较)
float("nan") is float("nan")False每次创建都是新对象

这就是为什么需要专门的 isnan / isna 函数。

在 Pandas 里的对应(第 23 章起常用):

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

一个真实的判断流程:

python
# 拿到一列新的传感器读数
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 倍。

这不是效率低,而是经验:在脏数据上调参,是在优化一个错误的答案。

三个可以直接抄走的检查函数:

python
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 帮你把这件事做得更全、更省事。


下一章:第 23 章 · 第一个 DataFrame:读电池 CSV