当前位置:首页>python>Python Pandas 实战

Python Pandas 实战

  • 2026-10-11 06:21:41
Python Pandas 实战

从 3 个 PKL 文件里抓出 6 个隐藏错误,拼出 18 万行训练表

做 AI 药物研发,很多人一上来就想训练 Random Forest、XGBoost,甚至直接谈大模型。

但真实的数据分析往往没那么浪漫。

模型还没见到,数据表里已经埋好了六颗雷:

NaN
False
-inf
MUT_ERROR
category dtype
以及一个看起来正常、实际会把行数合错的 merge key

这次练习的任务很明确:

输入:3 个 PKL 文件
输出:merged2

最终尺寸:
182,853 rows × 23,542 columns

其中 Random Forest 输入特征:
23,538 columns

原始练习要求先理解三张表的结构,再完成两次 merge,Random Forest 训练暂时不做。


一、先别急着 merge,先看清三张表

我们有三个数据集。

1. Response data

194,750 行 × 3 列

三列分别是:

cellLine
drug
IC50(LN)

一行代表:

某个药物作用于某个细胞系时,对应的 IC50。

2. Drug data

217 行 × 2,326 列

其中:

1 列 Drug name
2,325 列药物分子描述符

这些 descriptor 可以理解为机器学习眼中的“分子体检报告”,包括不同维度的物理化学和拓扑特征。

3. Mutation data

21,213 行 × 1,002 列

当前结构是:

行:mutation
列:cell line
值:0 或 1

问题也在这里。

Response 表是一行一个 cellLine,但 Mutation 表却是一列一个 cellLine。

所以它不能直接 merge,必须先转置。三张原始表的实际尺寸和最终目标都在配套 Notebook 中进行了明确检查。


二、第一原则:不要相信“成功读取”的数据

import pandas as pd
import numpy as np

response = pd.read_pickle("20260729_Response_data_FX.pkl")
drug = pd.read_pickle("20260729_Drug_data_FX.pkl")
mutation = pd.read_pickle("20260729_Mutation_data_FX.pkl")

程序没有报错,不等于数据没有错误。

先检查尺寸:

print("response:", response.shape)
print("drug:    ", drug.shape)
print("mutation:", mutation.shape)

再看一小部分:

display(response.head())
display(drug.iloc[:5, :8])
display(mutation.iloc[:5, :8])

为什么不用直接输入:

drug

因为它有 2,326 列。

Jupyter 会努力展示,你的浏览器会努力活着。


三、Error 1:IC50 中藏了一个 NaN

先检查真正的缺失值:

missing_ic50 = response["IC50(LN)"].isna()

response.loc[missing_ic50]

发现一行:

cellLine: KMS-12-BM
drug: AS601245
IC50(LN): NaN

最简单的考试处理方法是用中央值填补:

ic50_numeric = pd.to_numeric(
    response["IC50(LN)"],
    errors="coerce"
)

ic50_median = ic50_numeric.median()

response.loc[
    missing_ic50,
"IC50(LN)"
] = ic50_median

这里每行代码都值得理解。

pd.to_numeric(...)

尝试把所有值转换为数字。

errors="coerce"

表示:

遇到无法转换成数字的内容,不要报错,把它改成 NaN。

median()

计算有效 IC50 数值的中位数。


四、Error 2:IC50 里还有一个假装成数据的 "False"

如果只检查:

response["IC50(LN)"].isna()

你只能发现真正的 NaN。

但字符串 "False" 并不是缺失值,它只是不能用于回归。

解决办法是先尝试数字转换:

ic50_numeric = pd.to_numeric(
    response["IC50(LN)"],
    errors="coerce"
)

然后找出:

  1. 转换后变成 NaN
  2. 转换前又不是空值
text_ic50 = (
    ic50_numeric.isna()
    & response["IC50(LN)"].notna()
)

response.loc[text_ic50]

这样就找到了:

cellLine: LB1047-RCC
drug: GSK1070916
IC50(LN): "False"

修复:

response.loc[
    text_ic50,
"IC50(LN)"
] = ic50_median

response["IC50(LN)"] = pd.to_numeric(
    response["IC50(LN)"]
)

最后检查:

print(response["IC50(LN)"].isna().sum())
print(response["IC50(LN)"].dtype)

期待结果:

0
float64

这两个 IC50 错误,一个是真正的缺失值,一个是隐藏在数值列里的字符串。


五、中位数填补,到底是救命还是造数据?

这一步需要单独谈一下。

中位数的优点

第一,不删除行。

如果直接删除 IC50 错误行,样本会减少。删除某个药物 descriptor 行时,甚至会连带失去数百或数千条药物响应记录。

第二,不容易被极端值带跑。

例如:

1, 2, 3, 4, 100

平均值是:

22

中位数是:

3

对于偏态数据,中位数通常比平均值更稳。

第三,简单、快速、容易考试。

一行代码就能解决:

df["x"] = df["x"].fillna(df["x"].median())

中位数的缺点

问题也很直接:

中位数不是实验测出来的,是我们填进去的。

它会带来几个后果:

  • 人为制造一个并不存在的观测值
  • 多个缺失值被填成同一个数字,导致方差变小
  • 可能破坏不同 descriptor 之间的化学关系
  • 掩盖原始计算失败的真正原因

更重要的是,正式训练模型时不能先对全部数据计算中位数,再划分 train 和 test。

正确顺序是:

先划分 train/test
只用 train 计算中位数
用 train 的中位数处理 train 和 test

否则测试集的信息会提前泄漏到训练流程里。

这次考试使用中位数,是为了修复错误并保留原始行数,从而得到规定的最终 shape。真实研究中,优先选择应当是回到原始数据库或计算流程,找回正确数值。配套 Notebook 也明确区分了这一点。


六、Error 3:Drug descriptor 中有一个 NaN

先统计每列缺失值:

drug_missing_count = drug.isna().sum()

drug_missing_count[
    drug_missing_count > 0
]

结果:

SpAbs_Dze    1

找出是哪一个药物:

drug.loc[
    drug["SpAbs_Dze"].isna(),
    ["Drug name", "SpAbs_Dze"]
]

发现:

NSC-87877
SpAbs_Dze = NaN

修复:

spabs_median = drug["SpAbs_Dze"].median()

drug["SpAbs_Dze"] = drug["SpAbs_Dze"].fillna(
    spabs_median
)

注意,这里填的是 SpAbs_Dze 自己这一列的中位数,不是随便从整个 DataFrame 里找一个数字。


七、Error 4:isna() 看不到的 -inf

另一个 descriptor 错误是:

T0901317
GATS3v = -inf

-inf 不是 NaN。

所以:

drug["GATS3v"].isna()

抓不到它。

必须使用:

np.isinf()

检查:

infinite_gats3v = np.isinf(
    drug["GATS3v"]
)

drug.loc[
    infinite_gats3v,
    ["Drug name", "GATS3v"]
]

修复分三步。

drug["GATS3v"] = drug["GATS3v"].replace(
    [np.inf, -np.inf],
    np.nan
)

先把正负无穷改成 NaN。

gats3v_median = drug["GATS3v"].median()

再计算有效数值的中位数。

drug["GATS3v"] = drug["GATS3v"].fillna(
    gats3v_median
)

最后填补。

这两类 Drug 错误分别是 SpAbs_Dze 的缺失值和 GATS3v 的负无穷。

验证:

drug_feature_columns = drug.columns.drop("Drug name")

print(
    drug[drug_feature_columns]
    .isna()
    .sum()
    .sum()
)

print(
    np.isinf(
        drug[drug_feature_columns].to_numpy()
    ).sum()
)

应该都是:

0

八、Error 5:Mutation 的 0/1 世界里混进了 "MUT_ERROR"

Mutation 数据理论上应该是:

0 或 1

先找出不是数值类型的列:

mutation_feature_columns = mutation.columns.drop(
"Mutation"
)

non_numeric_columns = (
    mutation[mutation_feature_columns]
    .select_dtypes(exclude="number")
    .columns
)

print(non_numeric_columns.tolist())

得到:

['Hs683', 'KE-37']

别急着一起处理。

这两个列看起来都不是普通数值列,但病因完全不同。

检查 Hs683

hs683_numeric = pd.to_numeric(
    mutation["Hs683"],
    errors="coerce"
)

bad_hs683 = (
    hs683_numeric.isna()
    & mutation["Hs683"].notna()
)

mutation.loc[
    bad_hs683,
    ["Mutation", "Hs683"]
]

发现:

Mutation: ENST00000264463/c.2182C>T
Hs683: MUT_ERROR

由于这是二元数据,使用中位数的解释不如众数自然。

hs683_mode = (
    hs683_numeric
    .dropna()
    .mode()
    .iloc[0]
)

然后修复:

mutation.loc[
    bad_hs683,
"Hs683"
] = hs683_mode

mutation["Hs683"] = pd.to_numeric(
    mutation["Hs683"]
)

九、Error 6:KE-37 的值没错,dtype 错了

检查 category 类型:

category_columns = (
    mutation
    .select_dtypes(include="category")
    .columns
)

print(category_columns.tolist())

结果:

['KE-37']

这次不需要填补。

因为 KE-37 中的值本来就是有效的 0 和 1,只是被存成了 category。

直接转换:

mutation["KE-37"] = mutation["KE-37"].astype(
"int8"
)

这里还有一个实用点。

int8 足够保存:

0
1

没有必要使用更占内存的 int64。

Hs683 是字符串污染,KE-37 则只是 category dtype,两者需要不同的处理逻辑。

最后把所有 mutation feature 都转成 int8:

mutation[
    mutation_feature_columns
] = mutation[
    mutation_feature_columns
].astype("int8")

十、把 Mutation 表转过来

当前 Mutation 表是:

行:21,213 个 mutation
列:1,001 个 cell line

我们需要:

行:1,001 个 cell line
列:21,213 个 mutation

代码:

mutation_wide = (
    mutation
    .set_index("Mutation")
    .T
)

mutation_wide.index.name = "cellLine"

mutation_wide = mutation_wide.reset_index()

逐行理解。

.set_index("Mutation")

把 mutation 名称设为行索引。

.T

行列交换。

mutation_wide.index.name = "cellLine"

告诉 pandas,现在每一行的索引代表 cell line。

.reset_index()

把索引重新变成普通列,方便 merge。

检查:

print(mutation_wide.shape)

正确答案:

(1001, 21214)

为什么是 21,214 列?

21,213 mutation features
+ 1 cellLine
= 21,214

配套 Notebook 对转置后的 shape 和 cellLine 唯一性进行了双重验证。


十一、第一次 merge:Response + Drug

Response 中药物列名是:

drug

Drug 数据中药物列名是:

Drug name

名字不同,所以不能写:

on="drug"

正确写法:

merged1 = pd.merge(
    response,
    drug,
    left_on="drug",
    right_on="Drug name",
    how="inner",
    validate="many_to_one"
)

解释两个重要参数。

how="inner"

只保留两边都能匹配的药物。

validate="many_to_one"

检查数据关系是否符合:

很多条 response
对应
一条 drug descriptor

检查:

assert merged1.shape == (194750, 2329)

列数为什么是 2,329?

Response 3列
+ Drug 2,326列
= 2,329列

drug 和 Drug name 名字不同,因此都会保留下来。第一次 merge 的预期结果是 194,750 × 2,329。


十二、第二次 merge:加入 Mutation

merged2 = pd.merge(
    merged1,
    mutation_wide,
    on="cellLine",
    how="inner",
    validate="many_to_one"
)

这次两张表都叫:

cellLine

所以直接使用:

on="cellLine"

检查最终答案:

assert merged2.shape == (182853, 23542)

print(merged2.shape)

输出:

(182853, 23542)

行数计算:

194,750 response rows
- 11,897 个无法与 mutation 精确匹配的 rows
= 182,853 rows

列数计算:

merged1:        2,329
mutation_wide: 21,214
重复 cellLine:     -1
----------------------
最终:           23,542

最终行列数和计算方式都在 Notebook 中通过 assert 固定下来。


十三、为什么 Random Forest 特征是 23,538,而不是 23,542?

最终表有 23,542 列,但下面四列不能作为普通输入特征:

cellLine
drug
IC50(LN)
Drug name

其中:

  • cellLine 是样本身份
  • drug 是药物名称
  • Drug name 是重复药物身份
  • IC50(LN) 是模型需要预测的目标 y

所以:

23,542 - 4 = 23,538

代码:

non_feature_columns = [
"cellLine",
"drug",
"IC50(LN)",
"Drug name"
]

rf_feature_columns = [
    column
for column in merged2.columns
if column notin non_feature_columns
]

assert len(rf_feature_columns) == 23538

这也正是原始任务给出的 Random Forest 输入特征数量。


十四、最容易把答案做错的一步:不要过度清洗名字

很多人看到细胞系名字,第一反应是统一格式:

.str.lower()
.str.replace("-", "")
.str.replace("_", "")
.str.replace(" ", "")

听起来很专业,但可能是在给数据制造亲戚关系。

例如:

KMH-2
KM-H2

它们未必是同一个细胞系。

如果把连字符全部删除,两者都会变成:

kmh2

结果就是错误匹配。

之前出现过一个结果:

185,194 rows

比正确答案多:

185,194 - 182,853 = 2,341 rows

这些不是模型捡到的免费样本,而是过度规范化制造出来的错误匹配。

这次考试的原则是:

使用原始药物名和 cellLine 名称进行精确匹配。


十五、最终核心代码

import pandas as pd
import numpy as np

response = pd.read_pickle(
"20260729_Response_data_FX.pkl"
)

drug = pd.read_pickle(
"20260729_Drug_data_FX.pkl"
)

mutation = pd.read_pickle(
"20260729_Mutation_data_FX.pkl"
)

# 1. 修复 IC50
ic50 = pd.to_numeric(
    response["IC50(LN)"],
    errors="coerce"
)

response["IC50(LN)"] = ic50.fillna(
    ic50.median()
)

# 2. 修复 Drug descriptor NaN
drug["SpAbs_Dze"] = drug["SpAbs_Dze"].fillna(
    drug["SpAbs_Dze"].median()
)

# 3. 修复 Drug descriptor -inf
drug["GATS3v"] = drug["GATS3v"].replace(
    [np.inf, -np.inf],
    np.nan
)

drug["GATS3v"] = drug["GATS3v"].fillna(
    drug["GATS3v"].median()
)

# 4. 修复 Hs683 字符串
hs683 = pd.to_numeric(
    mutation["Hs683"],
    errors="coerce"
)

mutation["Hs683"] = hs683.fillna(
    hs683.dropna().mode().iloc[0]
).astype("int8")

# 5. 修复 KE-37 dtype
mutation["KE-37"] = mutation["KE-37"].astype(
"int8"
)

# 6. 转置 Mutation
mutation_wide = (
    mutation
    .set_index("Mutation")
    .T
)

mutation_wide.index.name = "cellLine"

mutation_wide = mutation_wide.reset_index()

# 7. 第一次 merge
merged1 = pd.merge(
    response,
    drug,
    left_on="drug",
    right_on="Drug name",
    how="inner",
    validate="many_to_one"
)

# 8. 第二次 merge
merged2 = pd.merge(
    merged1,
    mutation_wide,
    on="cellLine",
    how="inner",
    validate="many_to_one"
)

# 9. 最终检查
assert merged1.shape == (194750, 2329)
assert mutation_wide.shape == (1001, 21214)
assert merged2.shape == (182853, 23542)

print("Mission complete:", merged2.shape)

十六、课后测试

先自己回答,不要急着往下翻。

1

为什么要使用:

errors="coerce"

A. 自动删除错误行 B. 把不能转换成数字的内容变成 NaNC. 把所有数字变成字符串 D. 自动计算中位数

2

下面哪个函数能够发现 -inf?

A. isna()B. isnull()C. np.isinf()D. duplicated()

3

为什么 Mutation 数据需要 .T?

A. 为了删除 mutation B. 为了让 cell line 从列变成行 C. 为了计算 IC50 D. 为了降低特征数量

4

为什么 Hs683 更适合用众数,而不是中位数?

A. 它是药物名称 B. 它是连续型 descriptor C. 它是 0/1 二元数据 D. 它是 IC50

5

第一次 merge 为什么使用:

left_on="drug"
right_on="Drug name"

A. 两张表的 merge key 名称不同 B. 两张表的行数相同 C. 为了自动删除重复列 D. 因为 cellLine 不存在

6

validate="many_to_one" 检查的是什么?

A. 左表每行只能对应左表自己 B. 多条 response 可以对应一个 drug descriptor C. 两张表必须有相同列数 D. 所有数据必须是整数

7

最终为什么有 23,542 列?

请完成:

2,329 + 21,214 - ___ = 23,542

8

Random Forest 为什么只有 23,538 个输入特征?

9

对 cellLine 执行下面操作有什么风险?

.str.replace("-", "")

10

最终最重要的验证代码是什么?


答案

1

B。

errors="coerce" 会把不能转换成数字的字符串变成 NaN。

2

C。

-inf 不是普通缺失值,需要用:

np.isinf()

3

B。

原始 Mutation 表中 cell line 在列上,而 merge 需要一个 cell line 对应一行。

4

C。

Mutation 是 0/1 二元数据,众数比连续型数据常用的中位数更容易解释。

5

A。

左表叫 drug,右表叫 Drug name。

6

B。

多条药物响应记录对应一个药物 descriptor 行。

7

答案是:

1

因为 cellLine 是两张表共有的 merge key,只保留一次。

8

因为需要排除:

cellLine
drug
IC50(LN)
Drug name

所以:

23,542 - 4 = 23,538

9

可能把原本不同的细胞系名称变成相同 key,造成错误匹配和行数膨胀。

10

assert merged2.shape == (182853, 23542)

没有这句,代码跑完了,你也不知道自己是完成任务,还是安静地做错了。


最后一句

这次练习真正训练的,不是 pd.merge()。

而是三个更重要的习惯:

先理解数据结构
再识别数据错误
最后验证结果尺寸

Random Forest 不会因为你写了 n_estimators=500 就自动变聪明。

如果输入表里混着 "False"、-inf 和错误匹配,它只是会用五百棵树,非常认真地学习错误。

Jupyter Notebook + rawdata download from my star~

最新文章

随机文章