做矿物成分数据智能分类,我遇到的第一个坎从来不是“选哪个模型”,而是手里那张成分表根本不敢直接喂给算法。上周整理一批岩石主量元素化验数据,表面看列名很整齐,SiO2、Al2O3、Fe2O3一排排摆着,实际上同一个字段里混了三种写法:68.31、68.31%、0.6831。数字后面没带单位也就算了,有些单位还标错列名,最离谱的是同一个样号居然在三个Excel表里以三种不同格式出现三次。这种体验我估计做材料数据、地质数据或者任何实验数据出身的人都懂,分类模型上线前,数据清洗才是真正的大头。
这篇是“矿物成分数据智能分类实战”系列的第一篇,主题很聚焦:洗数据。它会带着你从一堆真实场景里常见的脏表格出发,最后得到一张字段统一、单位一致、标签干净、能直接进模型的数据集。你不需要是数据工程专家,只需要会一点Python,跟着流程走一遍,后面做特征工程和分类建模时就能少掉很多头发。先说明白,这篇文章里我会用pandas来演示,主要思路也完全适用于Excel或者其他数据处理工具。
1. 矿物成分表为什么天生就这么“脏”
矿物成分数据和普通业务数据有个很大的差别:它几乎不可能是由一个系统统一生成出来的。一家勘探公司三年的项目资料,可能是三个实验室、十几个技术员、跨越Excel不同版本的产物。你以为自己在整理数据,实际上在整理一个领域多年积累的“表达习惯”。
1.1 脏来自于来源多样,没有一个权威主键
最典型的场景就是同一个样品在多个表格里出现。采样记录表里可能叫“ZK01-03-12”,实验室报告里叫“ZK010312”,另一个送样台账里因为Excel自动格式转换,变成了“3.12E+01”,或者只有一堆日期格式。等你按样号合并的时候,同一份样品被算成三条记录,特征还互相矛盾。
更麻烦的是不同表的列结构不同。第一张表把氧化物放在横排,一个样品占一行;第二张表把指标放在竖排,一个样品占12行;第三张表干脆把主量元素和微量元素拆成两个sheet。这种表拼在一起,行数、列数、顺序全部对不上。若没有清洗流程,后面分类模型里特征的对应关系都是错的。
1.2 最难处理的脏不是空值,是“字段含义不一致”
空值至少有NaN这种明显的痕迹,难的是两个实验室在同一列里写的东西代表不同含义。比如有的实验室Fe元素按全铁Fe2O3报告,有的先给出FeO和Fe2O3两个分量,还有的写的是FeO_T。如果你的分类特征名都叫Fe2O3,但一个来自直接测定、另一个是换算数,模型就会把风马牛不相及的量学进同一个类别里。
微量元素更麻烦。同一个铅元素,可能在A表用“Pb(ppm)”,在B表用“Pb(µg/g)”,单位不同数量级却是一致的,看起来没区别;但如果另一个表用的是“Pb(%)”,数值小数点就完全变样。很多人在建模前习惯看一眼缺失率,却忽略了一个致命问题:非缺失的数值字段,本身口径不统一。我处理过一份样品数据,铅值从个位数到几百涨落很大,最后查出来是有两批样品的单位分别写的是%和ppm,差了10000倍。
1.3 清洗前先想清楚:你智能分类要分什么
很多人一上来就闷头清数据,结果清完发现不是自己想要的。矿物成分分类可以有好几种目标:按岩石大类分类、按矿石类型分类、按矿物共生组合分类、甚至按成因分类。不同目标对清洗要求完全不同。
如果只是按“侵入岩、沉积岩、变质岩”这种大类去分,主量元素和部分矿物描述基本够用;如果要把“硅卡岩型矿石”和“斑岩型矿石”分开,可能还缺一套成矿相关元素和蚀变矿物信息。我习惯在动pandas之前,先和领域人员开一个20分钟的短会,把分类体系的底限字段列出来,例如SiO2、Al2O3、K2O、Na2O是否必须齐全,微量元素里哪些元素不能缺失。这样清洗的时候才知道“哪些脏可以容忍,哪些脏必须解决”,而不是把所有列都强行补得干干净净,最后把不相关字段也当成特征,反而给模型灌入大量噪声。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 清洗主干一:把宽表结构统一成“长表中间态”
矿物成分表的原始格式五花八门,但你想让数据能进入机器学习流程,最终一定要转成固定的“宽表”:每行是一个样品,每列是一种成分。但直接从各个Excel往宽表里拼,非常痛苦,因为列名顺序不同、列数不同、单位不同,pandas在合并时会搞出一堆右斜杠。
我的做法是先转成“长表中间态”,统一结构后再转回宽表。所谓的“长表中间态”,就是指每一行只记录一个样品的某一个测试指标:
text复制sample_id source indicator raw_value unit
S001 labA SiO2 68.31 wt%
S001 labB SiO2 68.310 %
S001 labA Ba 356 ppm
长表的好处是,任何来源的表格清洗后都归到同一种结构里,后续再做什么都好办。
2.1 第一步不是写代码,是给源文件建索引
先别忙着写清洗函数,打开你手头所有Excel的最上面三行,把每个文件的“来源路径、sheet名称、样号所在列、成分表头在第几行、单位在第几行、数据从第几行开始”记录下来。听起来繁琐,但15个表格会很快让你明白,没有原始文件索引的话后面每改一步都要回头翻原始表,非常浪费时间。
这个索引可以直接用一张Excel清单维护,也可以写在Python里。我自己的做法是写一个字典,里面记录每个表格的元信息:
python复制file_schema = {
"原表/2021年化验报告.xlsx": {
"sheet": "主量元素",
"header_row": 2,
"sample_col": "样品编号",
"start_row": 4,
"unit_row": 2,
"source_tag": "labA_2021"
},
# ...
}
你也许会觉得维护这个结构比清洗本身还费劲,但从实际经验看,这一步能省下后期反复对数的两三个小时。
2.2 用pandas读表时,dtype=str能躲开一半的坑
很多人栽的第一个跟头是pandas自动类型推断:样品编号“1-02”会被读成日期,微量铅含量“6.50E-01”会被读成浮点数但显示很怪,还有带“<0.01”的检出限列直接变成字符串。与其事后去猜哪些被自动转换了,不如在读入时统一指定为文本,后面再手工按字段转换。
python复制import pandas as pd
df_raw = pd.read_excel("原表/某批次化验.xlsx",
sheet_name="全表",
header=2, # 按实际表头行号调整
dtype=str, # 所有列先按字符串读入
keep_default_na=False) # 暂时不自动处理NaN
这一步会让后面的正则表达式提取少掉很多不必要的坑。同时,如果表里出现“ND”“N.D.”“-9999”这类代表未检出的字符,不会被pandas转成NaN。先保留它们的原始面貌,后面才能把它们按照领域规则统一编码。
2.3 把列名拆成“元素+测定类型+单位”
矿物成分表格最大的隐性信息在列名里。一个列名经常长成这样:“SiO2(wt%)”“Fe2O3T_%”“Ba(ppm)”“Pb(ug/g)”。如果要直接当特征用,你需要先从这些列名里解析出三个信息:元素/氧化物名称、测定类型、单位。
我通常先写一个解析函数,把列名拆开,然后人工抽查几十个列,确认无误后批量应用:
python复制import re
def parse_column_name(col_text):
"""
从列名文本中提取 indicator 和 unit。
例如:
SiO2(wt%) -> SiO2, wt%
Ba(ppm) -> Ba, ppm
Fe2O3T -> Fe2O3T, wt%
"""
s = str(col_text).strip()
# 先找括号里可能的单位
unit = None
bracket_pattern = r"([((].*?[))])"
m = re.search(bracket_pattern, s)
if m:
bracket_content = m.group(1).strip("()() ")
# 常见单位关键词
for u in ["wt%", "%", "ppm", "ppb", "ug/g", "mg/kg"]:
if u.lower() in bracket_content.lower():
unit = u
break
s = s[:m.start()] + s[m.end():]
# 去掉多余空格和分隔符
indicator = s.strip().strip("_").strip()
if unit is None:
# 如果没找到单位,默认按表头整体推断
unit = "unknown"
return indicator, unit
这个解析函数不可能一次写对,所以建议先在单独的字典上做小批量测试,肉眼检查一下解析结果,再全量跑。跑完之后,把原来的Excel数值列用melt或stack转成一行行的“长表记录”。
比如你有一张宽表,使用pd.melt()会很方便:
python复制id_cols = ["sample_id", "source"]
value_cols = [c for c in df_raw.columns if c not in id_cols]
df_long = pd.melt(
df_raw,
id_vars=id_cols,
value_vars=value_cols,
var_name="raw_header",
value_name="raw_value"
)
df_long["indicator"], df_long["unit"] = zip(*df_long["raw_header"].map(parse_column_name))
这一步完成以后,你就有了一个标准的中间态长表。有了这个结构,后面不管是换列名还是查单位,都只需要对“长表”这一个结构操作,不需要再管不同Excel自己的列结构。你甚至可以把不同来源的数据直接纵向拼接起来,只要source字段保留好来源就行。
2.4 数值提取和单位换算的小工具
原始值里面混着“68.31%”“0.6831”“<0.01”“9.7ppm”之类格式已经是家常便饭。清洗函数要处理的并不是把它们简单转成浮点数,而是要能区分“单位写在值里”和“单位只写在列头”的情况。
可以写一个函数,从单元格文本里抽数值,再把残留单位转成标准单位:
python复制def parse_value_with_unit(text: str, column_unit: str):
"""
返回 (标准单位, 数值);无法识别时返回 (None, None)
"""
if pd.isna(text):
return None, None
s = str(text).strip()
low = s.lower()
# 去掉非断行空格和逗号
s = s.replace("\xa0", " ").replace(",", "")
# 提取数字部分
match = re.search(r"([-+]?\d+\.?\d*(?:[eE][-+]?\d+)?)", s)
if not match:
return None, None
num = float(match.group(1))
# 如果单元格里本身带单位,就用单元格里的单位优先
if "ppb" in low:
return "ppb", num
if "ppm" in low or "ug/g" in low or "mg/kg" in low:
return "ppm", num
if "%" in low:
return "wt%", num
# 没有检测到单位时,沿用列头给定的单位
if column_unit and column_unit != "unknown":
return column_unit, num
return None, None
这一段里有一个容易被忽略的细节:如果单元格里同时有数字和单位,说明单位信息被写进了值里,需要优先使用这个单位,不能傻乎乎地用列头的单位。比如列头写着ppm,但某一行写的是“Pb(%)”,这时候如果还按ppm解析,数据就错了。
单位统一时,我一直习惯只保留两种:主量元素统一成wt%,微量元素统一成ppm。如果有微量元素已经用wt%表示了,需要乘以10000转成ppm;如果有主量元素误写成ppm,需要除以10000转回wt%。换算关系必须单列一个文档写清楚,最好在DataFrame里增加一个unit_final列:
python复制# wt% 与 ppm 的换算
# 1 wt% = 10000 ppm
df_long["unit_final"] = df_long["unit_parsed"]
df_long["value_raw"] = df_long["value_parsed"]
mask_wt2ppm = df_long["unit_parsed"] == "wt%" # 如果目标单位是ppm,则转换
df_long.loc[mask_wt2ppm, "value_ppm"] = df_long.loc[mask_wt2ppm, "value_parsed"] * 10000
等到所有来源表都进入长表结构以后,再通过pivot_table把表展开成宽表:
python复制df_wide = df_long.pivot_table(
index=["sample_id", "source"],
columns="indicator",
values="value_ppm", # 都用ppm,或根据主要特征再拆成两套
aggfunc="first"
).reset_index()
这一步执行完,你手里有了一张宽表,但这张宽表还远没到能训练的程度,因为大量的列仍然存在缺失、重复和异常问题。别着急,让脏数据继续暴露问题,下一章节专门处理它们。
3. 清洗主干二:缺失、重复、异常值,按决策处理
长表转宽表后,第一件要做的事是把每一列的缺失情况、非数值情况统计一遍。我用一个简单的汇总函数扫一遍,速度很快:
python复制def report_missing(df):
info = pd.DataFrame({
"缺失数": df.isna().sum(),
"缺失率": df.isna().mean().round(4),
"类型": df.dtypes.astype(str),
"唯一值数": df.nunique()
}).sort_values("缺失率", ascending=False)
return info
但看见缺失率之后,先别急着填充。缺失不是一种情况,至少得分出好几种来。
3.1 缺失标识符比你想的多,先盘点再统一
矿物成分数据里常见的缺失表示有这么几种:真正的空单元格、值为字符串“NA”“N/A”“ND”“B.A.L.”“-”、-9999或9999、还有“低于检出限”甚至“0.00”。第一轮清洗时,必须把所有表示“没测出来”的符号统一转成NaN,不然它们会被当成一个真实的常量特征,比如把-9999当成一个有效的负数含量。
python复制import numpy as np
missing_terms = ["", "na", "n/a", "nd", "b.a.l.", "-", "--", "-9999", "9999", "none", "null"]
def to_missing(v):
if pd.isna(v):
return np.nan
s = str(v).strip().lower()
if s in missing_terms:
return np.nan
# 数字型空值标记
try:
num = float(s)
if num == -9999 or num == 9999:
return np.nan
except ValueError:
pass
return v
一个关键问题是,不要把“0”当成缺失。有些元素真的含量为零或者低于检出限但被报告成0,如果直接当作缺失,会损失信息;如果真没有测,实验室一般会写“ND”或空着,而不是0。所以请先做一遍数值分布的频次计数,看0的占比,再决定。
3.2 低于检出限:编码策略影响小样本类别
“低于检出限”在矿物实验报告里真的太常见了。比如某微量元素检测下限是2ppm,样品里没测到,报告会写“<2”或“less than 2”,这批数据在小样本类别训练里很要命。如果直接把“<2”转成NaN丢弃,那这个元素在所有低品位样品里全部为空,模型等于少了一个特征。如果转成0,又会扭曲分布,因为它不是零,只是低于仪器能检测到的门限。
我使用的处理方法是:能拿到检测下限具体值时,用检测下限的一半去填充,并且额外加一列布尔值,记录该样品该元素是否低于检出限。如果检测下限值也拿不到,那就保留为NaN,不去硬填。
这种处理方式在岩矿数据建模中比较常用。因为低于检出限的真实值可能介于0和下限之间,用下限一半能给出一个相对合理的代表值;同时布尔列保留了“该样品该项低于检出限”这个信息,模型如果发现这类样品有规律,可以自己提取出来,而不至于被填充值误导。
3.3 重复样本不是无脑去重
清洗时很多人会习惯直接drop_duplicates(),但在矿物成分数据里,这样很可能误删有效记录。同样的样号在不同Excel里出现多次,需要判断它们是同一个样品多次测定,还是不同批次的同一编号。我见过一样号在两个表里SiO2相差超过10%的情况,最后发现一个是快检的野外数据,一个是实验室的全分析数据,二者精度完全不同。
更稳妥的查重路径是先按样号分组,看每组各字段是否完全相同。完全相同可去掉;不完全相同要拿出来逐条确认。如果每个样品的“采样日期”“送样日期”“分析批次”不同,或者数据来源的实验室不同,就必须保留两条,不能合到一起。否则后续分类模型会把两个不同测量系统的系统性偏差也学进去。
如果你最后确定某个样号需要合并,合理的做法不是选其中一条,而是保留更可靠的那条,并在清洗日志里写明选择依据,比如“以实验室补测数据为准”“按送样日期最新的一条优先”。
3.4 异常值先“标可疑”,不急着杀样本
异常值筛选我习惯用“双重确认法”:先用统计学阈值找可疑点,然后用领域范围做二次判断。
例如用四分位距IQR来找离群点没什么门槛:
python复制def find_outliers(series):
q1 = series.quantile(0.25)
q3 = series.quantile(0.75)
iqr = q3 - q1
lower = q1 - 3 * iqr
upper = q3 + 3 * iqr
return (series < lower) | (series > upper)
这个方法适合快速抓出输入错误,比如某列本来都是微量的ppm值,突然出现了一个9999;或者某列有一行“0.1831”明显是百分比的小数写法,和其他93.2%不一个口径。但我不直接用这个方法删样本,而是把所有可疑点挑出来,标上outlier_flag=True,再回原始表人工核实。
矿物成分数据本身是有一定比例的极值样本的,比如富铁矿石的TFe2O3可能达到85%,普通岩石只有5%;某些伟晶岩的Li可以高到几千ppm。如果按全数据集IQR去“清理”,会把真正有意义的稀有类别当成异常点删掉。所以,要区分“录入错误”和“天然极端值”。录入错误需要修;天然极端值不仅不能删,在类别不平衡时还更应该保留,因为它是模型的稀缺样本。
提示:拿不准某个异常值时,保留并标记,比删掉更安全。后续建模时可以用样本权重控制,也可以在训练集中单独标记处理,很少需要真删除原始记录。
4. 清洗主干三:做出可被训练的分类标签
结构化特征清洗干净了,但矿物成分智能分类还需要一个东西:标签。很多原始表里没有现成的“分类标签”这一列,标签藏在“岩石名称”“矿物种属”“手标本鉴定描述”甚至“备注”里。标签清洗是清洗流程里最需要专业知识,也最容易出现臆断的一环。
4.1 标签粒度:先分大类,再考虑细分
分类模型最怕不切实际的细分类。原始鉴定报告里会出现“硅化花岗岩”“绿泥石化花岗岩”“碎裂花岗岩”“细粒二长花岗岩”“黑云母花岗岩”等等。如果把这些当最终标签,类别数量会爆炸,很多类别只有两三个样本,模型根本无法学习。
这时候需要领域人员定一个“最适合自动判断”的分类粒度。比如先用大类标签:“花岗质岩类”“闪长质岩类”“基性岩类”“碳酸盐岩类”。等大类样本量足够,再考虑按蚀变类型或结构细分。
我建议建立一个“标签层级表”,在清洗阶段给每条样本记多层标签,而不是只保留一个最终分类:
text复制sample_id raw_name label_l2 label_l1 confidence
S001 黑云母花岗岩 granite intrusive high
S005 硅化花岗岩 granite,sa? intrusive low
这样底层保留原始名称,中间层留一个可细可粗的标签,模型后续遇到样本不平衡时能够灵活切换分类粒度,而不用重新清洗整批数据。
4.2 别名归一:中文、英文、缩写统一映射
同一岩性名称可能有大量别名。中文的“花岗岩”,英文可能写“granite”“Granite”“GR”,网上表格里可能写“花岗质岩石”,甚至一个表里缩写是“Gr”。建立一张别名映射表,把所有原始文字映射到规范标签,是标签清洗的核心。
我用pandas写一个模糊匹配太费时,更多是先结合领域人员写一个别名表的初版,再按样本实际值不断补充。映射表的核心字段非常简单:
text复制raw_alias label
花岗岩 granite
花岗质岩石 granite
granite granite
GR granite
二长花岗岩 monzogranite
monzogranite monzogranite
然后合并时直接用左连接即可。映射表本身也是一份数据资产,如果后续扩展数据源,直接维护这张表就可以了。
重要的是保留“raw_name”列,不要把映射表只当成替换功能。因为后面可能发现“GR”在某些工程里代表“闪长玢岩”,不是“granite”,如果没有原始列,这个清理会反噬整批数据。
4.3 标签不明确或存疑的样本不要硬塞进训练集
矿物岩石定名存在大量过渡类型,比如“石英二长岩”介于“二长岩”与“花岗闪长岩”之间,“含角
