第 24 章 · 数据速览三件套
这一章要解决什么:建立"拿到任何一张表,先跑这四行"的条件反射。 立下全书第一条建模纪律:标识列和标签列不能当特征。
本章你需要完成的代码:demos/ch24-quick-look.py
24.1 四行标准流程
拿到任何一张表,先跑这四行,别急着做别的:
df.shape # ① 多大规模?
df.head() # ② 长什么样?
df.info() # ③ 每列什么类型、有没有缺失?
df.describe() # ④ 数值范围合不合理?点开:这四行分别能发现什么问题?
① df.shape → (13967, 9)
- 行数对不对?(预期 1.4 万,拿到 4 千 → 上游漏了数据)
- 列数对不对?(预期 9 列,拿到 12 列 → 上游加了字段)
② df.head()
这是信息量最大的一步,因为它让你看到真实的列名和值:
cell_id batch cycle timestamp capacity_ah soh resistance_mohm temp_rise_c coulombic_eff
C001 A 1 2023-01-01 2.5092 0.9999 40.33 6.00 0.998
C001 A 2 2023-01-02 2.4800 0.9998 39.47 6.01 0.998
C001 A 3 2023-01-03 2.4862 0.9997 39.85 6.01 0.998注意这里发现的两件事:
timestamp是2023-01-01这样的字符串,不是日期类型;soh在前 3 行几乎等于 1 —— 说明电池刚上工还没衰减(合理)。
③ df.info()
# Column Non-Null Count Dtype
0 cell_id 13967 non-null str
1 batch 13967 non-null str
2 cycle 13967 non-null int64
3 timestamp 13967 non-null str
4 capacity_ah 13967 non-null float64
...
memory usage: 982.2 KB一眼看出:所有列都是 13967 non-null(没有缺失)、 哪些是字符串哪些是数值、占多少内存。
④ df.describe()
cycle capacity_ah soh resistance_mohm temp_rise_c coulombic_eff
count 13967.0000 13967.0000 13967.0000 13967.0000 13967.0000 13967.0000
mean 601.1927 2.2679 0.9071 54.8383 10.6421 0.9960
std 365.6767 0.1486 0.0593 11.6132 2.9772 0.0012
min 1.0000 1.9781 0.8000 38.8100 6.0000 0.9940
25% 291.5000 2.1405 0.8560 44.5200 7.9850 0.9950
50% 582.0000 2.2743 0.9099 52.8000 10.5000 0.9960
75% 876.0000 2.4001 0.9601 63.4200 13.2000 0.9970
max 1423.0000 2.5216 1.0000 87.5700 17.2000 0.9980这是"物理合理性检查"最快的方式:
| 列 | min | max | 合理吗 |
|---|---|---|---|
cycle | 1 | 1423 | ✅ 循环次数从 1 开始 |
capacity_ah | 1.9781 | 2.5216 | ✅ 额定 2.5 Ah,在合理范围 |
soh | 0.8000 | 1.0000 | ✅ 正好落在 [0.80, 1.00](数据生成规则) |
resistance_mohm | 38.81 | 87.57 | ✅ 几十 mΩ 是正常范围 |
temp_rise_c | 6.00 | 17.20 | ✅ 温升十几度合理 |
coulombic_eff | 0.9940 | 0.9980 | ✅ 库仑效率接近 1 |
如果某一列的 max 是 562(就像 dirty.csv 里的内阻),你立刻就会发现异常。
count 还有一个用途:如果 count 与行数不等,说明有缺失。
count 13967.0000 ← 等于行数 → 无缺失
count 13688.0000 ← 少了 279 → 有缺失,去 isna().sum() 看哪里缺24.2 info():一个坑要提前说
df.info() # 直接打印到终端
x = df.info() # ❌ 拿到的是 None!
print(x) # None为什么:df.info() 是打印到 stdout,不是返回值。 它没有 return 语句,所以返回 None。
这是 Pandas 里少数几个"打印型"方法。其余大多数方法都返回新对象:
x = df.head() # ✅ 返回 DataFrame
y = df.describe() # ✅ 返回 DataFrame
z = df.info() # ❌ 返回 None(它是打印型)记住这一个就够了。 其他的如果拿到 None,先想想"这个方法是不是打印型的"。
24.3 摸清每一个字段
describe 只看数值列。要摸清所有字段,还需要三个工具:
df.isna().sum() # 每列缺几个
df.nunique() # 每列有几个不同的值
df["batch"].value_counts() # 某个类别列的取值分布真实输出:
唯一值个数 df.nunique():
cell_id 12
batch 4
cycle 1423
timestamp 1423
capacity_ah 4839
soh 2001
resistance_mohm 3986
temp_rise_c 1075
coulombic_eff 41
batch 的取值分布:
batch
A 3331
B 4157
C 3304
D 3175从 nunique 能读出很多信息:
| 列 | nunique | 推断 |
|---|---|---|
cell_id | 12 | 12 支电芯 → 这是分组键,不是特征 |
batch | 4 | 4 个批次 → 这是类别列,要编码 |
cycle | 1423 | 连续量(循环次数) |
timestamp | 1423 | 和 cycle 的 nunique 一样 → 两者一一对应(1 圈 = 1 天) |
coulombic_eff | 41 | 只有 41 个不同值 → 精度受限(数据生成时保留 4 位小数) |
value_counts() 的用途:看类别列的平衡性。
print(df["batch"].value_counts())
# A 3331
# B 4157
# C 3304
# D 3175四个批次样本数接近但有差异 —— 因为每支电芯的寿命不同(859~1423 圈), 所以批次的总行数也不同。这是合理的,不是数据错误。
如果出现极端不平衡(比如 A 有 12000 行、B 只有 50 行), 就要警惕:可能是数据采集有偏,或者某个批次的定义有问题。
24.4 把列分成三类:本章的重点
num_cols = df.select_dtypes(include="number").columns.tolist()
str_cols = df.select_dtypes(exclude="number").columns.tolist()
print(num_cols)
# ['cycle', 'capacity_ah', 'soh', 'resistance_mohm', 'temp_rise_c', 'coulombic_eff']
print(str_cols)
# ['cell_id', 'batch', 'timestamp']select_dtypes 是"按类型筛列"的工具,在数据清洗里极其常用 (比如第 30 章要"只对数值列插值")。
等价写法(用列表推导,第 11 章学的):
str_cols2 = [c for c in df.columns if not pd.api.types.is_numeric_dtype(df[c])]
assert str_cols == str_cols2两种写法都对。 select_dtypes 更短,列表推导更灵活(能做更复杂的判断)。
24.5 ★ 第一课建模纪律:这三列该不该进模型
这是本章最重要的内容,也是全书反复出现的一条纪律。
拿到一张表,对每一个非数值列都要问一句:
"这一列的唯一作用,是不是只用来『认出』某条记录?"
如果是 → 它不能作为特征。
点开:逐列审问 cell_id / batch / timestamp / soh
cell_id → 不进特征
理由:
- 它只有 12 个取值,本质是"编号",和数据规律无关;
- 它唯一的作用是"认出这是哪支电芯";
- 模型学到"编号 5 的电芯更好"是纯粹的噪声(换一批数据编号就变了)。
但它非常有用 —— 作为分组键:
df.groupby("cell_id")["soh"].mean() # 每支电芯的平均 SOH(第 28 章)
df.groupby("cell_id")["soh"].transform("mean") # 组内均值贴回原表关键认知:"不能当特征"不等于"没用"。 标识列在聚合、连接、分组里是第一等公民。
batch → 可以进,但要先编码
batch 是类别列(A/B/C/D)。模型只认数字,所以要转换成数字:
# 方式一:映射成整数(有序时用,比如"小/中/大")
df["batch_code"] = df["batch"].map({"A": 0, "B": 1, "C": 2, "D": 3})
# 方式二:one-hot 编码(无序时用,也是默认推荐)
pd.get_dummies(df, columns=["batch"], prefix="batch")
# 会生成 batch_A / batch_B / batch_C / batch_D 四个 0/1 列⚠ 一个坑:cell_id 和 batch 有从属关系(每支电芯属于一个批次)。 如果两个都放进模型,会造成信息重复。
本书的做法:处理电池数据时不把 batch 当特征, 而是用 groupby("batch") 做对比分析(第 38 章的箱线图)。
timestamp → 不进特征,但可以派生
直接喂给模型没意义:2023-01-01 对模型来说只是一个编号。 而"2023 比 2022 大"这个信息是伪信息(年份的大小和数据规律无关)。
但可以派生出真正有信息量的特征:
df["days_since_start"] = (df["timestamp"] - df["timestamp"].min()).dt.days
# 距首次使用的天数 —— 单调递增,有趋势
df["month"] = df["timestamp"].dt.month
# 月份 —— 有周期性(如果和季节相关)
df["dayofweek"] = df["timestamp"].dt.dayofweek
# 周几 —— 有周期性(如果和生产排班相关)这就是"时间特征工程"(第 29 章详讲)。
判断标准:派生的特征要有物理/业务含义。 "距首次使用 300 天"意味着"老化程度",这是有意义的; "年份 = 2023"没有意义。
soh → 不进特征!这是标签
这是最危险的一个。
soh 是我们要预测的目标(标签)。把它当特征,等于把答案抄进试卷。
后果:
# 错误做法
X = df[["cycle", "capacity_ah", "resistance_mohm", "temp_rise_c", "soh"]]
y = df["soh"]
model.fit(X, y)
print(model.score(X, y)) # 1.0 !!完美模型会学到"如果 soh 等于 0.92,那么答案就是 0.92" —— 所以准确率 100%。 上线后立刻崩,因为线上没有 soh 这一列(那正是要预测的东西)。
这个错误有个专门的名字:特征泄漏(data leakage)。 第 37 章会用相关系数 0.998 再坐实一次。
判断"是不是标签/标识"的两条实用启发式
启发式一:看 nunique。
n = len(df)
for c in df.columns:
uniq = df[c].nunique()
if uniq == n:
tag = "每行都不同 → 典型的标识列"
elif uniq < 20:
tag = f"取值很少({uniq} 个)→ 可能是类别列"
else:
tag = "连续量或时间"真实输出:
cell_id nunique= 12 取值很少(12 个)→ 可能是类别列
batch nunique= 4 取值很少(4 个)→ 可能是类别列
cycle nunique= 1,423 连续量或时间
timestamp nunique= 1,423 连续量或时间
capacity_ah nunique= 4,839 连续量或时间
soh nunique= 2,001 连续量或时间
resistance_mohm nunique= 3,986 连续量或时间
temp_rise_c nunique= 1,075 连续量或时间
coulombic_eff nunique= 41 连续量或时间⚠ 但这个启发式会误判:cell_id 只有 12 个唯一值,看起来像"类别列", 但它依然是标识列。
为什么:因为 12 是"电芯的数量",不是"这一列的价值" —— 如果这批数据有 12 万支电芯,cell_id 的 nunique 就是 120000, 但它的性质没变。
所以 nunique 只是线索,最终要靠业务判断。
启发式二:问三个问题。
① 这一列在"预测发生的那一刻"能拿到吗?
拿不到 → 它可能是标签,或者是未来信息 → 不能用
② 这一列的取值会随数据集变化而失去意义吗?
会(编号、ID)→ 标识列 → 不能当特征
③ 这一列是不是从标签算出来的?
是 → 特征泄漏 → 必须剔除第 37 章会看到这三条启发式的完整实战。
24.6 最后:一句话速览
速览的目的是"能在 30 秒内向别人介绍这张表":
per = df.groupby("cell_id").agg(n=("cycle", "size"),
soh_end=("soh", "last")).sort_values("n")
print(per)真实输出(节选):
n soh_end
cell_id
C003 859 0.8
C001 862 0.8
C012 879 0.8
...
C007 1415 0.8一句话速览:
12 支电芯、4 个批次,每支从第 1 圈跑到 SOH 降到 0.80 为止, 寿命 859~1423 圈,共 13,967 条记录、9 个字段; 无缺失;数值范围全部落在物理合理区间。
这就是"数据速览"的产出 —— 一段能被别人听懂的话。 它比"我读了一个 CSV"有价值得多。
24.7 完整示例与输出
cd code
python demos/ch24-quick-look.py真实输出(节选):
=== ② info():一眼看全列名/类型/非空数/内存 ===
# Column Non-Null Count Dtype
0 cell_id 13967 non-null str
3 timestamp 13967 non-null str
4 capacity_ah 13967 non-null float64
dtypes: float64(5), int64(1), str(3)
memory usage: 982.2 KB
=== ③ describe():数值列的统计摘要 ===
cycle capacity_ah soh resistance_mohm
count 13967.0000 13967.0000 13967.0000 13967.0000
mean 601.1927 2.2679 0.9071 54.8383
min 1.0000 1.9781 0.8000 38.8100
max 1423.0000 2.5216 1.0000 87.5700
=== ⑤ 把列分成三类:这是本章真正的重点 ===
数值列(6):['cycle', 'capacity_ah', 'soh', 'resistance_mohm', 'temp_rise_c', 'coulombic_eff']
非数值列(3):['cell_id', 'batch', 'timestamp']
=== ⑦ 用 nunique 自动识别「疑似标识列」 ===
cell_id nunique= 12 取值很少(12 个)→ 可能是类别列
⚠ 这只是启发式。cell_id 只有 12 个唯一值但依然是标识列 —— 最终要靠业务判断。本章练习
练习 1:跑一遍速览流程
对 cycle_raw.csv 依次跑:shape、head(3)、info()、describe()、dtypes、isna().sum()、nunique(),然后说出:
- 有多少行多少列?
- 有没有缺失?
- 哪些列是数值、哪些不是?
- 有没有哪一列的取值范围看起来"不对劲"?
参考答案
from pathlib import Path
import pandas as pd
pd.set_option("display.width", 160)
CODE_DIR = Path(__file__).resolve().parent.parent.parent
df = pd.read_csv(CODE_DIR / "data" / "battery" / "cycle_raw.csv")
# ① 规模
print(df.shape) # (13967, 9)
# → 13,967 行 × 9 列
# ② 缺失
print(df.isna().sum().sum()) # 0
# → 完全没缺失(这是干净数据)
# ③ 类型
print(df.dtypes)
num = df.select_dtypes("number").columns.tolist()
obj = df.select_dtypes(exclude="number").columns.tolist()
print(f"数值列 {len(num)}: {num}")
print(f"非数值列 {len(obj)}: {obj}")
# 数值列: cycle, capacity_ah, soh, resistance_mohm, temp_rise_c, coulombic_eff
# 非数值列: cell_id, batch, timestamp
# ④ 范围检查
print(df.describe().loc[["min", "max"]].T)四列的范围检查结论:
| 列 | min | max | 判断 |
|---|---|---|---|
cycle | 1 | 1423 | ✅ 从 1 开始,合理 |
capacity_ah | 1.9781 | 2.5216 | ✅ 额定 2.5,实测在附近 |
soh | 0.8000 | 1.0000 | ✅ 正好 [0.80, 1.00](生成规则) |
resistance_mohm | 38.81 | 87.57 | ✅ 几十 mΩ 正常 |
temp_rise_c | 6.00 | 17.20 | ✅ 温升十几度合理 |
coulombic_eff | 0.9940 | 0.9980 | ✅ 库仑效率接近 1 |
没有"不对劲"的列 —— 因为这是干净数据。
对比一下 dirty.csv:
dirty = pd.read_csv(CODE_DIR / "data" / "battery" / "dirty.csv")
print(dirty.describe().loc[["min", "max"]].T)
# capacity_ah min = -0.5 ❌ 容量为负!
# resistance_mohm max = 562.61 ❌ 内阻超高!这就是 describe() 最大的价值: 两行代码就能发现"物理上不可能"的值。
注意 info() 是打印型:
x = df.info() # 会打印一堆东西
print(x) # None ← 因为它没有 return完整练习:exercises/ex24-01-quick-look
练习 2:逐列审问"该不该进模型"
对下面四列写出判断和理由:
cell_id(电芯编号,12 个取值)batch(批次 A/B/C/D)timestamp(日期字符串)soh(健康度)
如果目标是"预测 SOH",它们分别该怎么用?
参考答案
| 列 | 进模型? | 理由 | 该怎么用 |
|---|---|---|---|
cell_id | ❌ 不进 | 标识列。取值只是编号,和规律无关;换批数据编号就变 | 分组键 —— groupby("cell_id") |
batch | ⚠️ 可以进,但要编码 | 类别列。模型只认数字 | pd.get_dummies();但本书用它做对比分析(第 38 章) |
timestamp | ❌ 不进特征 | 日期字符串对模型无意义("2023 比 2022 大"是伪信息) | 派生成 days_since_start / month / dayofweek(第 29 章) |
soh | ❌ 绝对不能进 | 它是标签!放进去 = 抄答案 | 预测目标 y = df["soh"] |
★ 关于 soh:这是最危险的一个
# 错误做法
FEATURES = ["cycle", "capacity_ah", "resistance_mohm", "temp_rise_c", "soh"] # ← 混进了 soh
X = df[FEATURES]
y = df["soh"]
from sklearn.linear_model import LinearRegression
model = LinearRegression().fit(X, y)
print(model.score(X, y)) # 1.0(或接近 1.0)—— 完美!看着很美好,实际上模型什么都没学到。它只是发现了 "当 soh 列的值是 0.92 时,答案是 0.92"。
上线时会立刻崩:因为线上根本拿不到 soh 这一列 —— 那正是要预测的东西。
这个错误的名字:特征泄漏(data leakage)。
第 37 章会用相关系数 0.998 再坐实一次,并给出识别它的三条启发式。
★ 关于 cell_id:一个容易忽略的点
# 不能当特征
X = df[["cell_id", "cycle"]] # ❌ cell_id 是字符串,而且没有意义
# 但作为分组键非常有用
df.groupby("cell_id")["soh"].mean() # 每支电芯的平均 SOH
df.groupby("cell_id")["resistance_mohm"].rank(pct=True) # 组内排名"不能当特征" ≠ "没用"。 标识列在聚合、连接、分组里是第一等公民。
★ 关于 batch:为什么本书不用它当特征
batch 和 cell_id 有从属关系(每支电芯属于一个批次)。 如果两个都放进去,会造成信息重复(第 37 章的"多重共线性")。
而且对本书的数据来说,四个批次来自同一个生成过程, 批次间没有真实差异(第 38 章的箱线图会证实这一点)。
所以本书的用法是:
# 用 batch 做"对比分析",而不是当特征
df.groupby("batch")["soh"].describe() # 看四批的分布是否一致真实项目里的经验:如果 batch 真的有差异(比如不同产线), 就应该编码后放进模型 —— 那正是模型该学的东西。 判断标准是"业务上它是否携带信息",而不是机械地排除。
练习 3:nunique 能告诉你什么
df.nunique()真实输出:
cell_id 12
batch 4
cycle 1423
timestamp 1423
capacity_ah 4839
soh 2001
resistance_mohm 3986
temp_rise_c 1075
coulombic_eff 41要求:从这些数字里能推断出什么?至少说出三条。
参考答案
推断一:cycle 和 timestamp 的 nunique 完全一样(都是 1423)
说明两者一一对应 —— 每个 cycle 值对应唯一的 timestamp。 结合"1 次循环 = 1 天"的设计,可以确认:
print((df.groupby("cycle")["timestamp"].nunique() == 1).all()) # True这个检查很有用:如果哪天两者不一致,说明数据的时间字段有问题。
推断二:cell_id 有 12 个取值 → 12 支电芯
结合行数 13,967:
print(f"平均每支 {len(df) / df['cell_id'].nunique():.0f} 圈")
# 平均每支 1164 圈和 cycle 的 max(1423)对比:平均 1164 < 最大 1423, 说明每支电芯的寿命不同(859~1423 圈)。
推断三:coulombic_eff 只有 41 个不同值
这说明这一列的精度有限。看公式:
eff = np.clip(0.998 - 0.004 * c, 0.985, 0.999) # 然后 round(4)只有 4 位小数,而且取值范围窄(0.985~0.999),所以不同值少。
这带来一个实际问题:
print(df["coulombic_eff"].value_counts().head())
# 0.9980 4933
# 0.9960 3727
# 0.9970 2447
# ...大量重复值 —— 如果用它当特征,区分度可能有限。 (但它的相关系数是 0.98,信息量其实是够的。)
推断四:capacity_ah 有 4839 个不同值
对比 soh 的 2001 个 —— capacity_ah 的取值更分散。
为什么:soh 是按公式算出来的(只有 2001 个不同值), 而 capacity_ah 是 soh × 2.5 × (1 + 噪声) —— 噪声让它更离散。
这印证了第 27 章要讲的一点:capacity_ah 和 soh 不是完全等价的 (前者带测量噪声),所以 soh 是"真值",capacity_ah 是"实测"。
推断五(方法论):nunique 是发现"数据异常"的快捷方式
# 如果某列 nunique == 1,说明它是常量列 —— 对模型毫无用处
constant_cols = [c for c in df.columns if df[c].nunique() == 1]
print(constant_cols) # []常量列要直接删掉:它不提供任何信息,只会增加维度。
如果某列 nunique == len(df),说明每行都不同 —— 疑似标识列(见 24.5)。
一个综合的字段审查脚本(可以直接抄走):
def audit_columns(df):
"""逐列输出:类型、缺失率、唯一值数、样例值。"""
n = len(df)
for c in df.columns:
s = df[c]
n_uniq = s.nunique()
miss = s.isna().mean()
sample = s.dropna().iloc[0] if s.notna().any() else None
# 猜测用途
if n_uniq == n:
guess = "疑似标识列"
elif n_uniq == 1:
guess = "常量列(建议删除)"
elif n_uniq <= 20:
guess = "类别列(需编码)"
else:
guess = "连续量/时间"
print(f"{c:<18} {str(s.dtype):<10} 缺失{miss:>6.2%} 唯一{n_uniq:>7,d} {guess}")
audit_columns(df)面向 AI 开发:describe() 是模型上线前的最后一道检查
在真实项目里,describe() 有一个超出"数据探索"的用途:监控线上数据漂移。
什么是数据漂移(data drift):模型训练时看到的数据分布, 和上线后遇到的数据分布不一样。
一个真实场景:
# 训练时记录各列的范围
train_stats = X_train.describe().loc[["mean", "std", "min", "max"]]
train_stats.to_csv("train_stats.csv")
# 上线后每天对比
prod_stats = X_today.describe().loc[["mean", "std", "min", "max"]]
diff = (prod_stats - train_stats).abs() / train_stats.abs()
if (diff["mean"] > 0.2).any():
print("⚠️ 有特征均值漂移超过 20%,检查上游数据")为什么这件事重要:模型的性能下降,90% 的原因不是模型退化,而是输入数据变了:
| 漂移类型 | 例子 | 影响 |
|---|---|---|
| 均值漂移 | 换了新的传感器(读数系统性偏移) | 模型预测整体偏 |
| 方差漂移 | 换了更稳定的工艺 | 模型在"从未见过"的区间预测 |
| 分布形状漂移 | 出现新工况 | 模型对尾部预测不可靠 |
| 缺失模式漂移 | 某个传感器开始掉线 | 填充策略失效 |
describe() 是最低成本的漂移检测工具。
更进一步:PSI(Population Stability Index)
describe() 只能看均值和方差。要看整个分布,用分箱对比:
def psi(expected, actual, bins=10):
"""分布稳定性指标。> 0.25 通常认为分布显著变化。"""
edges = np.percentile(expected, np.linspace(0, 100, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf
e = np.histogram(expected, bins=edges)[0] / len(expected)
a = np.histogram(actual, bins=edges)[0] / len(actual)
e, a = np.clip(e, 1e-6, None), np.clip(a, 1e-6, None)
return float(((a - e) * np.log(a / e)).sum())经验阈值:
| PSI | 含义 |
|---|---|
| < 0.1 | 分布稳定 |
| 0.1 ~ 0.25 | 有变化,需要关注 |
| > 0.25 | 显著变化,模型需要重训 |
为什么 AI 从业者要懂这些: 一个模型上线后,它不是"完成了",而是"开始被监控"。 而监控的第一层就是"输入数据还和训练时一样吗"。
本章的四个速览命令就是监控脚本的第一行代码。
最后一个实用建议:把本章的速览包装成一个函数, 放在每个数据处理脚本的开头调用。
def quick_audit(df, name=""):
"""数据速览:打印规模、类型、缺失、范围。"""
print(f"\n{'='*60}\n{name or 'DataFrame'} 速览\n{'='*60}")
print(f"形状: {df.shape}")
print(f"\n类型与缺失:")
info = pd.DataFrame({
"dtype": df.dtypes.astype(str),
"缺失数": df.isna().sum(),
"缺失率": df.isna().mean().round(4),
"唯一值": df.nunique(),
})
print(info.to_string())
num = df.select_dtypes("number")
if not num.empty:
print(f"\n数值列范围:")
print(num.describe().loc[["min", "mean", "max"]].T.round(4).to_string())
return df
# 用法
df = quick_audit(pd.read_csv(path), "原始数据")这个函数的价值:它把"四行标准流程"固化成一个动作, 保证你不会因为赶时间而跳过检查 —— 而跳过检查,是数据项目翻车的头号原因。