数据篇(五):三源异构数据的合并与统一,特征表构建

0 阅读11分钟

图片

本文是**「数据篇」**系列的第五篇,也是最后一篇。前四篇我们搞定了三类数据:本地计算文件(第二篇)、公共数据库(第三篇)、文献提取(第四篇)。这篇把三类数据合并成一张统一特征表——每行一个材料,每列一个特征,能查、能比、能训练机器学习模型。数据篇到此完结。

三堆 JSON 摆在一起

你现在有三堆数据。它们都是 JSON,但长得完全不一样:

# 本地计算记录(第二篇产出)
{
  "source": "local_vasp",
  "formula": "LiCoO2",
  "energy_per_atom": -7.42,
  "forces": [[0.01, -0.02, 0.00], ...],
  "pseudo_potential": "PBE.54"
}
# 公共数据库记录(第三篇产出)
{
  "source": "Materials Project",
  "material_id": "mp-2483633",
  "formula": "Na2Fe2O4",
  "band_gap": 2.1,
  "energy_above_hull": 0.0
}
# 文献提取记录(第四篇产出)
{
  "source": "literature",
  "doi": "10.1002/adfm.202302001",
  "material": {"metal_center": "Nb", "coordination": "NbN4"},
  "electrochem": {"capacity_05C": 1016.1}
}

三堆 JSON 摆在一起不能直接用。字段名不一样(**energy_per_atom** vs **band_gap** vs **capacity_05C**),单位不一样(eV vs eV/atom vs mAh/g),完整度不一样(本地有力但没有带隙,MP 有带隙但没有实验容量,文献有容量但没有力)。

**统一特征表要解决的核心问题:**怎么把三堆异构数据拼成一张表,每行一个材料,每列一个特征,同时标记每条数据的来源和可信度,让你(或你的模型)知道哪些能放在一起比、哪些不能。

这一篇没有新数据源——数据都是前四篇产出的。这篇讲的是怎么合并。合并本身是一门学问:字段怎么对齐,同一材料怎么关联,哪些列能一起比,缺失值怎么办。

第一步:定义三层 schema

统一特征表不只是"把所有列拼在一起"。你需要区分三层信息——标识层、元数据层、数据层。一个直观的类比:统一特征表就像一张"材料身份证"——身份证号码对应标识层,籍贯/出生日期对应元数据层,具体信息字段对应数据层。

层级字段示例作用
标识层**material_id**
, **formula**, **doi**
跨源匹配的锚点
元数据层**source**
, **data_type**, **functional**, **method**
判断"能否一起比"的依据
数据层**energy_per_atom**
, **band_gap**, **capacity**
实际用于 ML 训练的特征值

为什么要有元数据层?因为没有元数据层的特征表,本质上就是一堆无法追溯来源的数字。你不知道这一行能量是 PBE 算的还是 HSE 算的,不知道那个容量是 0.5C 还是 1C 测的——这些信息直接影响模型的可解释性和可靠性。元数据层就是"这行数据从哪来、怎么来的"的档案。

用 Pydantic 把三层 schema 写成代码:

# unified_schema.py — 统一特征表的数据模型
# 安装依赖:pip install pydantic
from pydantic import BaseModel
from typing import Optional
# --- 标识层:跨源匹配的锚点 ---
class Identity(BaseModel):
    formula: str                  # 化学式,Hill 系统标准化
    material_id: Optional[str]       # MP 编号(有就用,没有填 None)
    doi: Optional[str]              # 论文 DOI(文献数据才有)
# --- 元数据层:判断"能否一起比"的依据 ---
class MetaData(BaseModel):
    source: str                 # "local_vasp" / "mp_database" / "literature"
    data_type: str              # "calc" (计算) / "exp" (实验)
    functional: Optional[str]     # 泛函,如 "PBE", "PBE+U"
    method: Optional[str]        # 实验方法,如 "galvanostatic"
# --- 数据层:实际特征值 ---
class Features(BaseModel):
    energy_per_atom: Optional[float]     # eV/atom
    band_gap: Optional[float]             # eV
    capacity_mAh_g: Optional[float]    # mAh/g(实验容量)
    forces: Optional[list]           # 力数组(本地计算才有)
# --- 一行完整记录 ---
class UnifiedRecord(BaseModel):
    identity: Identity               # 标识层
    metadata: MetaData              # 元数据层
    features: Features              # 数据层

每个字段都是 **Optional**——因为不是每个源都有所有字段。本地计算没有 **band_gap**?填 **None**。文献没有 **forces**?填 **None**。这很正常——缺失本身也是一种信息。

第二步:写映射函数

schema 定好了,现在把三堆 JSON 各自"翻译"成统一格式。每个源写一个映射函数,做两件事:字段名对齐和单位转换。

比如本地的 **energy** 是总能量(eV),统一表要 **energy_per_atom**(eV/atom),所以要除以原子数。MP 的 **band_gap** 直接能用,但 **material_id** 要填到标识层。文献的 **capacity_05C_300cycles_mAh_g** 要简化成 **capacity_mAh_g**,同时把 DOI 填到标识层。

# map_sources.py — 三个源的映射函数
from unified_schema import UnifiedRecord, Identity, MetaData, Features
# ---- 映射函数 1:本地计算记录 → 统一格式 ----
# 输入是第二篇产出的 JSON 记录
def map_local(raw: dict) -> UnifiedRecord:
    # ★ raw 是你第二篇产出的 JSON 记录
    # energy 是总能量,要除以原子数转成 per atom
    n_atoms = raw.get("n_atoms", 1)  # 原子数
    return UnifiedRecord(
        identity=Identity(
            formula=raw["formula"],
            material_id=None,         # 本地计算没有 MP ID
            doi=None,
        ),
        metadata=MetaData(
            source="local_vasp",
            data_type="calc",
            functional=raw.get("pseudo_potential"),  # 从记录里读赝势信息
            method=None,
        ),
        features=Features(
            energy_per_atom=raw["energy"] / n_atoms,  # 单位转换
            band_gap=None,                  # 本地没算带隙
            capacity_mAh_g=None,            # 计算数据没有实验容量
            forces=raw.get("forces"),
        ),
    )
# ---- 映射函数 2:MP 记录 → 统一格式 ----
# 输入是第三篇产出的 JSON 记录
def map_mp(raw: dict) -> UnifiedRecord:
    return UnifiedRecord(
        identity=Identity(
            formula=raw["formula"],
            material_id=raw["material_id"],
            doi=None,
        ),
        metadata=MetaData(
            source="mp_database",
            data_type="calc",
            functional="PBE",  # MP 默认 PBE
            method=None,
        ),
        features=Features(
            energy_per_atom=None,  # MP 记录里没拉总能量
            band_gap=raw["properties"]["band_gap"],
            capacity_mAh_g=None,
            forces=None,
        ),
    )
# ---- 映射函数 3:文献记录 → 统一格式 ----
# 输入是第四篇产出的 JSON 记录
def map_literature(raw: dict) -> UnifiedRecord:
    # 文献的 metal_center + coordination 拼成 formula
    # 因为文献里没有标准化学式
    mat = raw["material"]
    formula = f"{mat['metal_center']}-{mat['coordination']}"
    return UnifiedRecord(
        identity=Identity(
            formula=formula,
            material_id=None,
            doi=raw["doi"],
        ),
        metadata=MetaData(
            source="literature",
            data_type="exp",  # 文献里有实验数据
            functional=raw["dft"].get("dft_functional"),
            method="galvanostatic",
        ),
        features=Features(
            energy_per_atom=None,
            band_gap=None,
            capacity_mAh_g=raw["electrochem"].get(
                "capacity_05C_300cycles_mAh_g"),
            forces=None,
        ),
    )

三个函数,三套翻译规则。注意每个源填的字段不一样——本地填能量和力,MP 填带隙,文献填容量。这正是多源数据的价值:互补。

第三步:合并关联,不是去重

三堆数据映射完了,现在拼到一起。这里有一个关键判断:"去重"的本质不是"删掉谁",而是"建立关联"。

同一个材料可能出现在多个源里——MP 有计算值,文献有实验值。这不是重复,而是补充。统一表里同一材料可以有多行:一行是计算数据(来自 MP),一行是实验数据(来自文献)。用 **data_type** 字段区分。

跨源匹配用多级策略:

  • 第一级:化学式匹配 — 用 Hill 系统标准化化学式(**pymatgen** 的 **Composition** 类可以自动做)

  • 第二级:结构指纹匹配 — 如果化学式相同但结构不同(同分异构体),用结构指纹区分

  • 第三级:DOI 匹配 — 如果同一篇论文既报告了计算又报告了实验,用 DOI 关联

# merge.py — 合并三个源,建立关联
# 安装依赖:pip install pymatgen
import json
from pymatgen.core.composition import Composition
from map_sources import map_local, map_mp, map_literature
# ★ 改成你自己的 JSON 文件路径
# local_records.json — 第二篇产出
# mp_records.json — 第三篇产出
# literature_records.json — 第四篇产出
with open("local_records.json") as f: local_data = json.load(f)
with open("mp_records.json") as f: mp_data = json.load(f)
with open("literature_records.json") as f: lit_data = json.load(f)
# 映射成统一格式
records = []
for raw in local_data: records.append(map_local(raw))
for raw in mp_data: records.append(map_mp(raw))
for raw in lit_data: records.append(map_literature(raw))
# 用化学式做第一级关联
# 同一个 formula 的多行记录就是"关联",不删
def normalize_formula(formula: str) -> str:
    """用 pymatgen 标准化化学式(Hill 系统)"""
    try:
        return str(Composition(formula).reduced_formula)
    except:
        return formula  # 无法解析的就原样返回
# 给每行打上标准化的 formula 用于关联
for r in records:
    r.identity.formula = normalize_formula(r.identity.formula)
# 导出统一表
# ★ 输出路径可改
with open("unified_table.json", "w", encoding="utf-8") as f:
    json.dump(
        [r.model_dump() for r in records],
        f, indent=2, ensure_ascii=False
    )
print(f"合并完成:{len(records)} 行")
print(f"来自:local {len(local_data)} + mp {len(mp_data)} + literature {len(lit_data)}")

注意这段代码不做任何"去重"——它把三个源的记录全部保留,只是用标准化化学式建立关联。同一个 Fe₂O₃ 如果在 MP 和文献里都出现了,表里就有两行:一行 **data_type=calc**,一行 **data_type=exp**。这正是我们想要的——计算值和实验值放在一起,才能对比验证。

第四步:标记可比性

表合并完了,但你不能直接把所有列拿去训练模型。因为不是所有列都能放一起比。

比如能量:PBE 算的形成能和 HSE 算的形成能差 0.1~0.3 eV/atom,绝对值不可比。但带隙的"趋势"可以对齐——PBE 系统性低估带隙,但 PBE 和 HSE 的带隙呈近似线性关系,可以建立映射。实验容量和计算能量根本不是一类东西,更不能放一列。

特征跨源可比?注意
energy_per_atom不可比
不同泛函/赝势的能量绝对值差异大
band_gap趋势可比
PBE 低估但有线性关系,可映射
capacity_mAh_g仅实验间可比
不同 C 率/硫载量下不可直接比
forces不可比
不同收敛标准下力差异大

如果你把 PBE 的带隙和 HSE 的带隙直接放在同一列训练回归模型,模型学到的可能是泛函的系统偏差,而不是材料本身的物理规律。所以在训练之前,要么按 **functional** 分组,要么只选同一泛函的数据。

这个判断靠元数据层。有了 **source**、**data_type**、**functional** 这三个字段,你就能在训练前做分组或筛选,而不是让模型自己去"猜"哪些数据能一起用。

第五步:导出 + 质量检查

表合并完了,可比性标好了,现在导出并做最后的检查。最实用的检查是数据覆盖热力图——哪些列缺失多,一目了然。

# quality_check.py — 数据覆盖热力图
# 安装依赖:pip install matplotlib pandas seaborn
import json
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# ★ 改成你的统一表路径
with open("unified_table.json") as f:
    records = json.load(f)
# 展平成 DataFrame
rows = []
for r in records:
    row = {}
    row["formula"] = r["identity"]["formula"]
    row["source"] = r["metadata"]["source"]
    row["data_type"] = r["metadata"]["data_type"]
    # 数据层字段展平
    for k, v in r["features"].items():
        row[k] = 1 if v is not None else 0  # 1=有值, 0=缺失
    rows.append(row)
df = pd.DataFrame(rows)
# 画热力图:哪些列缺失多
feature_cols = ["energy_per_atom", "band_gap",
                "capacity_mAh_g", "forces"]
heatmap_data = df[feature_cols]
fig, ax = plt.subplots(figsize=(8, 6))
sns.heatmap(heatmap_data, cmap="YlGnBu",
    cbar_kws={"label": "1=有值, 0=缺失"},
    yticklabels=df["formula"])
ax.set_title("Data Coverage Heatmap")
plt.tight_layout()
# ★ 输出路径可改
plt.savefig("coverage_heatmap.png", dpi=200)
plt.show()
# 打印各列覆盖率
print("\n各特征覆盖率:")
for col in feature_cols:
    coverage = df[col].mean() * 100
    print(f"  {col:25s} {coverage:.0f}%")

热力图会告诉你哪些列覆盖率高(比如 MP 数据的 **band_gap** 可能 100% 有值),哪些列缺失严重(比如文献数据的 **forces** 可能全部缺失)。

关于缺失值,有一个原则:**元数据字段不能插补,只能保留缺失;可比组内的数值特征可以有条件插补;跨可比组的数值特征不建议插补。**比如 **functional** 是 None 意味着"不知道用什么泛函"——你不能猜一个填进去,那会污染可比性判断。而同一泛函组内的 **band_gap** 缺失,可以用 KNN 插补——因为同泛函的带隙有物理关联性。但跨泛函的带隙插补会引入虚假信号。

最后导出成 CSV 或 Parquet,就能直接喂给 **scikit-learn** 或任何 ML 框架了。

拿到表之后

五篇走完,你从"博士后走了数据就丢"变成了"三源数据汇成一张表,能查、能比、能训练机器学习模型"。

回顾一下数据篇的完整路径:

  • 第二篇:本地计算文件 → 结构化 JSON 记录(pymatgen 解析 OUTCAR)

  • 第三篇:公共数据库 → 按需筛选的候选清单(mp-api 查询 + 键长分析)

  • 第四篇:文献 PDF → 结构化记录(先定 schema,三层搜索提取)

  • 第五篇(这一篇):三源合并 → 统一特征表(三层 schema + 映射 + 关联 + 可比性标记)

这张统一特征表就是材料理性设计的起点。下一系列我们会讲怎么在这张表上做特征工程和机器学习——但从数据到模型,这一步是最容易被忽视、也是最花时间的。任何一篇用数据驱动做出的高水平工作,数据准备占 60% 以上的时间,不是夸张。

如果你跟着这个系列走完了,你现在手里应该有:一个 schema 定义文件、三个源数据的 JSON、一个合并脚本、一张统一特征表。这些就是你的数据基础设施——不大,但完整,且每一个字段都可追溯。

两个学习点

**第一,统一 schema 分三层:标识层、元数据层、数据层。**没有元数据层的特征表就是一堆无法追溯的数字。标识层让跨源关联成为可能,元数据层让可比性判断有据可依,数据层才是喂给模型的特征值。

**第二,合并不是去重,是关联;可比性不是默认的,是标记出来的。**同一材料的多行记录(计算行 + 实验行)是补充不是重复。哪些列能一起比,靠元数据层的 **source**、**data_type**、**functional** 三个字段判断——在训练前做分组或筛选,而不是让模型自己去猜。

数据篇到此完结。下一系列见。

欢迎关注材智工场

手搓模型·玩转材料大数据

把研发从试错变成逆向设计

END