数据篇(四):从论文PDF到数据库,Schema定义与数据提取

0 阅读18分钟

本文是**「数据篇」**系列的第四篇。前两篇我们搞定了两类数据:本地计算文件(第二篇,OUTCAR → JSON 记录)和公共数据库(第三篇,MP 按需筛选)。这篇进入最难的环节——文献数据。论文是材料研究最大的数据来源,但也是最难提取的。我们以锂硫电池单原子催化剂为例,手搓一个最小模型,把一篇论文变成一条你能查的记录。

封面图

为什么论文是最难的数据源

想想你手里有多少数据来自论文。本地计算目录,几十个、几百个,到头了。公共数据库,MP 上几万条,AFLOW 上上百万条,量很大但边界清晰。而文献呢?Web of Science 上跟「单原子催化剂」相关的论文就有上万篇,每一篇都藏着实验条件、性能数据、DFT 计算结果。这是材料科学最大的数据源——但也是最不像数据的「数据」。

为什么不像数据?因为论文是写给人看的,不是写给机器查的。

你打开一篇典型的单原子催化剂论文,找「0.5 C 循环 300 圈后的容量」这个数据。它在哪?可能在正文第三段某句话里:S@Nb-SAs@NC 电极在 0.5 C 下循环 300 圈后仍保持 1016.1 mAh g⁻¹。可能在 Figure 5a 的折线图里,你需要从坐标轴上肉眼读数。可能在 Table S3(支持信息表格)里,正文压根没提。也可能——根本没测这个条件。

这就是文献数据和前两类数据本质区别:

| 数据源 | 数据在哪 | 提取难度 | | --- | --- | --- | | 本地计算文件 | OUTCAR / vasprun.xml,格式固定 | 低 — pymatgen 直接解析 | | 公共数据库 | API 返回 JSON,结构统一 | 中 — mp-api 查询 + 筛选 | | 文献 | 正文段落 / 图表坐标 / SI 表格,每篇不同 | 高 — 需要人工阅读 + 判断 |

但论文里的数据又是最有价值的。为什么?因为公共数据库里的条目是计算结果,而论文里藏着实验验证——真实合成的材料、真实测量的电化学曲线、真实报告的循环寿命。计算可以预测,但只有实验能确认。你做机器学习模型、做综述对比、做新材料设计,都绕不开文献里的实验数据。

所以这一篇核心问题是:怎么把论文里散落的、异构的、有时只画在图里的数据,变成一条结构化、机器能读的记录?

我们不搞大而全。不建知识图谱,不训练 NLP 模型,不做自动化文献挖掘。就做一件事:**定义一个 schema,然后从一篇论文里手动提取数据,填进这个 schema。**这是最小案例——但它揭示的问题是通用的。

动手之前:装什么,配什么

这篇文章的代码用到三个 Python 库,全部 pip install 就能装好:

| 库名 | 用途 | 安装命令 | | --- | --- | --- | | pydantic | 定义数据 schema,自动校验字段类型 | pip install pydantic | | pymupdf | 读取 PDF 提取文本(批量提取用) | pip install pymupdf | | matplotlib | 画统计图(最后出图用) | pip install matplotlib |

一行装完:

pip install pydantic pymupdf matplotlib

除了装库,你还需要准备两样东西:

  • 论文 PDF

     — 放在一个文件夹里,每篇一个 PDF。本文示例路径:F:\LiS\single,换成你自己的。

  • 一个空的 Python 文件

     — 用来存 schema 定义。后面的代码分几个文件,但核心逻辑都在一个 schema.py 里。

就这些。不需要数据库,不需要 API key,不需要 GPU。一台装了 Python 的电脑 + 一堆 PDF + 一个文本编辑器,就能开始。

例子:锂硫电池单原子催化剂

锂硫电池的理论能量密度是锂离子的 3-5 倍,但有一个致命问题:多硫化锂穿梭。充放电过程中,中间产物 Li₂Sₙ(n=4-8)溶解在电解液里,在正负极之间来回穿梭,导致容量衰减、库仑效率低。

单原子催化剂(SAC)是解决穿梭问题的热门路线:把单个金属原子锚定在氮掺杂碳上(M-N₄ 构型),既能吸附多硫化锂,又能催化其转化。不同的金属(Fe、Co、Ni、Nb……)、不同的配位环境(M-N₄、M-N₂S₂、M-N₃C……),催化活性天差地别。

这个领域现在**论文井喷,**你手头有 30 多篇,每篇都报告一种或几种 SAC 的性能。你想把这些数据汇总起来,比较不同金属、不同配位环境催化效果,找出规律。

这就是文献数据提取的典型场景。你面前不是 API,不是 OUTCAR,而是 30 篇 PDF。数据在里面,但不会自己跳出来。

第一步:先定义你要什么

大多数人犯的第一个错误是:打开论文就开始读,读完发现忘了记哪个数,回头再找。正确的顺序是先定义 schema,再读论文。

什么是 schema?就是你打算从每篇论文里提取哪些字段。这个决策来自你的研究目的。我们的目的是比较不同 SAC 的催化性能,所以需要三类字段:

| 类别 | 字段 | 为什么需要 | | --- | --- | --- | | 材料信息 | metal_center(金属种类) | 不同金属催化活性差异巨大 | | coordination(配位环境) | M-N₄ 和 M-N₂S₂ 活性不同 | | support(载体) | N 掺杂碳、MXene 等影响导电性 | | DFT 计算量 | Li₂S₄_binding_energy | 吸附强度,衡量锚定多硫化物能力 | | Li₂S_decomposition_barrier | 分解能垒,衡量充电动力学 | | rate_limiting_delta_G | 决速步自由能变,衡量催化活性 | | 实验性能 | capacity_05C_300cycles | 中等倍率循环稳定性 | | capacity_7C | 超高倍率快充能力 | | retention_4C_1000cycles | 长循环保持率 | | R_ct(电荷转移电阻) | 界面反应速率 |

这张表就是你的「筛子」。没有它,你读论文时什么数据都想记,最后记了一堆笔记却拼不成一张表。有了它,你知道每篇论文只需要填这些格子,填不上就标 null——缺失本身也是一种信息。

用 Pydantic 把 schema 写成代码:

# schema.py — 定义你要从论文里提取哪些字段
# 安装依赖:pip install pydantic
from pydantic import BaseModel
from typing import Optional
# --- 材料信息(摘要、实验方法部分就能找到) ---
class MaterialInfo(BaseModel):
    metal_center: str                    # 金属种类,如 "Nb", "Fe", "Co"
    coordination: str                     # 配位环境,如 "NbN4", "FeN4", "CoN2S2"
    support: str                         # 载体,如 "N-doped carbon", "MXene"
    metal_loading_wt_percent: Optional[float]  # 金属负载量 (wt%),ICP-OES 测
# --- DFT 计算量(数值在正文,设置在 SI) ---
class DFTProperties(BaseModel):
    Li2S4_binding_energy_eV: Optional[float]     # Li₂S₄ 结合能 (eV),正文柱状图
    Li2S_decomp_barrier_eV: Optional[float]      # Li₂S 分解能垒 (eV),正文能垒图
    rate_limiting_delta_G_eV: Optional[float]   # 决速步 ΔG (eV),正文自由能图
    dft_functional: Optional[str]               # 泛函,如 "PBE"——需翻 SI
    dft_software: Optional[str]               # 软件,如 "VASP"——需翻 SI
# --- 实验电化学性能(散落在正文/SI/图表) ---
class ElectrochemicalPerf(BaseModel):
    capacity_05C_300cycles_mAh_g: Optional[float]  # 0.5C 300圈后容量,正文一句话
    capacity_7C_mAh_g: Optional[float]             # 7C 倍率容量,正文一句话
    retention_4C_1000cycles_pct: Optional[float]  # 4C 1000圈保持率 (%),正文一句话
    R_ct_ohm: Optional[float]                     # EIS 电荷转移电阻 (Ω)——SI Table
    sulfur_loading_mg_cm2: Optional[float]       # 硫载量 (mg/cm²),实验方法部分
    Q2_Q1_ratio: Optional[float]                 # Q₂/Q₁ 比值——论文可能没报告
# --- 一条完整记录(把上面三块组装起来) ---
class SACRecord(BaseModel):
    doi: str                                # 论文 DOI,唯一标识,从论文首页复制
    source: str = "literature"                # 数据来源标记,区分本地/公共数据库/文献
    material: MaterialInfo                    # 填材料信息
    dft: DFTProperties                        # 填 DFT 数据
    electrochem: ElectrochemicalPerf          # 填电化学性能

注意每个字段都是 Optional——因为不是每篇论文都会报告所有参数。这跟第二篇的本地数据不一样:OUTCAR 里的能量、力是必然存在的,但论文里的 R_ct 可能压根没测。

第二步:从一篇论文里提取数据

schema 定好了,现在拿一篇真实论文来练手。我们用一篇 Nb 单原子催化剂的论文——首次将铌单原子锚定在氮掺杂碳上用于锂硫电池,通过 d-p 轨道杂化实现「捕获-耦合-转化」机制。

打开这篇论文,带着 schema 去找数据。这个过程像考古——你知道要找什么,但不知道埋在哪一层。

材料信息——正文摘要就够

材料信息是最容易提取的。论文标题和摘要通常就会说清楚:金属是什么、配位是什么、载体是什么。这篇论文摘要直接写:Nb single atoms on N-doped carbon,EXAFS 拟合给出 Nb-N 配位数 3.7±0.5,ICP-OES 测得 Nb 含量 1.42 wt%。

# extract_material.py — 从论文摘要/实验方法提取材料信息
# 先运行 schema.py 定义类,再运行这段
from schema import MaterialInfo  # 导入刚才定义的类
# 以下数值来自论文摘要和 EXAFS 拟合结果
# 你读论文时把这些值抄下来,填到对应字段里
material = MaterialInfo(
    metal_center="Nb",                         # 摘要直接写了 Nb single atoms
    coordination="NbN4",            # EXIFS 拟合:CN=3.7±0.5,约等于 4
    support="N-doped carbon",     # 摘要:ZIF-8 衍生 N-doped carbon
    metal_loading_wt_percent=1.42,  # ICP-OES 测得,在实验方法部分
)

30 秒搞定。这一类信息一般在摘要、实验方法或表征部分,不需要翻 SI。

DFT 数据——正文有值,细节在 SI

DFT 数据稍微麻烦。这篇论文在正文里直接给了三个关键数值:

  • Li₂S₄ 结合能 E_b = 4.38 eV(Figure 1b 柱状图,正文有引用)

  • Li₂S 分解能垒 = 1.14 eV(Figure 4f,正文有引用)

  • 决速步 ΔG(Li₂S₂ → Li₂S)= 1.02 eV(Figure 3h,正文有引用)

数值有了。但 DFT 计算设置呢?泛函是什么?用的什么软件?截断能多少?这些信息论文正文一个字没写,全在 SI 的 "Theoretical Calculation Details" 里。你得翻到 SI 才能找到。

这就是文献数据和本地数据的第一个大区别:本地计算的设置在 INCAR 里,一目了然;论文的计算设置可能散落在 SI 的某一段,甚至根本不报告。你打开 30 篇论文的 SI,会发现有的写得详细(VASP, PBE, 520 eV, 3×3×1 k-mesh),有的只写了一句 "DFT calculations were performed"。

# extract_dft.py — 从正文图表和 SI 提取 DFT 数据
from schema import DFTProperties
# 前三个数值来自正文 Figure 1b/4f/3h 的柱状图和能垒图
# 后两个设置信息需要翻到 SI 的 "Theoretical Calculation Details"
dft = DFTProperties(
    Li2S4_binding_energy_eV=4.38,      # 正文 Figure 1b 柱状图
    Li2S_decomp_barrier_eV=1.14,       # 正文 Figure 4f 能垒图
    rate_limiting_delta_G_eV=1.02,    # 正文 Figure 3h 自由能图
    dft_functional="PBE",          # 翻 SI 才找到——正文没写
    dft_software="VASP",          # 翻 SI 才找到——正文没写
)

电化学性能——散落在正文、图表和 SI

电化学数据是提取难度最高的。同一篇论文里,不同性能指标藏在不同地方:

| 字段 | 数值 | 在哪找到的 | | --- | --- | --- | | 0.5C 300圈容量 | 1016.1 mAh/g | 正文段落,一句话 | | 7C 倍率容量 | 740 mAh/g | 正文段落,一句话 | | 4C 1000圈保持率 | >85% | 正文段落,一句话 | | R_ct(电阻) | 9.73 Ω | SI Table S3,正文未给数值 | | Q₂/Q₁ 比值 | null | 正文提及概念,未给具体数值 | | 硫载量 | ~2 mg/cm² | 实验方法部分 |

注意最后两行。R_ct 的具体数值藏在 SI 的 Table S3 里,正文只说了一句 "Nb-SAs@NC 具有最低的 R_ct"——你要是不翻 SI,这个字段就只能标 null。而 Q₂/Q₁ 比值,论文提到了这个概念,甚至画了 Figure S24,但没有在任何地方给出具体数值——这不是你漏了,是论文没报告。

这就是为什么文献数据提取需要人来判断:你知道这个字段应该存在(因为概念被提及了),但作者选择不报告具体数值。机器很难区分「没测」和「测了但没写」——但你能。

第三步:组装成一条记录

三个部分的数据都找齐了,组装成一条完整记录:

# assemble_record.py — 把材料/DFT/电化学三块数据组装成一条完整记录
from schema import SACRecord, MaterialInfo, DFTProperties, ElectrochemicalPerf
import json
# DOI 从论文首页或网址复制,如 https://doi.org/10.1002/adfm.202302001
record = SACRecord(
    doi="10.1002/adfm.202302001",       # ← 换成你论文的 DOI
    source="literature",               # 固定值,标记数据来自文献
    material=MaterialInfo(
        metal_center="Nb",
        coordination="NbN4",
        support="N-doped carbon",
        metal_loading_wt_percent=1.42,
    ),
    dft=DFTProperties(
        Li2S4_binding_energy_eV=4.38,
        Li2S_decomp_barrier_eV=1.14,
        rate_limiting_delta_G_eV=1.02,
        dft_functional="PBE",
        dft_software="VASP",
    ),
    electrochem=ElectrochemicalPerf(
        capacity_05C_300cycles_mAh_g=1016.1,
        capacity_7C_mAh_g=740.0,
        retention_4C_1000cycles_pct=85.0,
        R_ct_ohm=9.73,
        sulfur_loading_mg_cm2=2.0,
        Q2_Q1_ratio=None,  # 论文没报告具体数值,填 None
    ),
)
# 序列化为 JSON,与第二、三篇格式一致
print(json.dumps(record.model_dump(), indent=2, ensure_ascii=False))

输出的 JSON 长这样:

{
  "doi": "10.1002/adfm.202302001",
  "source": "literature",
  "material": {
    "metal_center": "Nb",
    "coordination": "NbN4",
    "support": "N-doped carbon",
    "metal_loading_wt_percent": 1.42
  },
  "dft": {
    "Li2S4_binding_energy_eV": 4.38,
    "Li2S_decomp_barrier_eV": 1.14,
    "rate_limiting_delta_G_eV": 1.02,
    "dft_functional": "PBE",
    "dft_software": "VASP"
  },
  "electrochem": {
    "capacity_05C_300cycles_mAh_g": 1016.1,
    "capacity_7C_mAh_g": 740.0,
    "retention_4C_1000cycles_pct": 85.0,
    "R_ct_ohm": 9.73,
    "sulfur_loading_mg_cm2": 2.0,
    "Q2_Q1_ratio": null
  }
}

注意 "source": "literature" 这个字段。第二篇本地数据的 source 显示的是 "local_vasp",第三篇公共数据库的是 "mp_database"。下一篇把三类数据合并到统一特征表时,这个字段就是区分来源的钥匙。

一篇论文就暴露了三个问题

刚才只是一个最小模型,只提取了一篇论文。但已经能看出文献数据的三个结构性问题:

**问题一:数据藏在不同层级。**有的数据在正文一句话里(容易),有的在 SI 表格里(要翻),有的只在图里(要从坐标轴读数),有的概念提了但数值没给(只能标 null)。一篇论文的数据不在一个地方,你需要系统性搜索正文、图表、SI 三个层级。

**问题二:字段不对齐。**你定义了 schema,但论文不按你的 schema 写。有的论文报 0.5C 循环,有的报 1C 循环;有的报 300 圈,有的报 500 圈;有的硫载量 2 mg/cm²,有的 5.6 mg/cm²。你想把 30 篇论文放进一张表,发现条件五花八门,很多格子填不齐。

**问题三:单位和定义不统一。**同样是「结合能」,有的论文定义 E_b = E(total) - E(surface) - E(molecule),负值表示吸附;有的定义反过来。同样是「容量」,有的归一化到硫质量,有的归一化到电极质量。你不仔细看定义,直接把数字填进去,后面做比较就是错上加错。

这三个问题不是某篇论文的毛病,是整个领域通病。2022 年一篇 Li-S 电池单原子催化剂综述列了两张汇总表——一张针对负极,一张针对正极——但两张表的参数维度根本不一样:负极侧报告过电位和循环小时数,正极侧报告比容量和 C 率。你想跨论文比较,可能连字段名都对不上。

这就是为什么文献数据提取目前还高度依赖人工。不是技术做不到——而是每篇论文都是独一无二的「数据格式」,通用解析器覆盖不了所有变体。你能做的,是先定义好自己的 schema,系统性地搜索三个层级,把不确定的标出来,把缺失的记下来。这比无目的地读论文高效十倍。

从一篇到三十篇

一篇论文提取完了。下一步是把同样的流程应用到 30 篇论文上。代码结构很简单:用一个循环,每篇论文生成一条 SACRecord,最后保存为一个 JSON 列表:

# batch_extract.py — 批量提取文献数据
# batch_extract.py — 批量提取文献数据
# 安装依赖:pip install pymupdf pydantic
import json
from pathlib import Path
from schema import SACRecord  # 导入 schema.py 里的记录类
# ★ 改成你本地存放 PDF 的文件夹路径
paper_dir = Path(r"F:\LiS\single")  # ← 改成你的路径
records = []
for pdf_path in paper_dir.glob("*.pdf"):
    # 1. 读 PDF:用 PyMuPDF 提取文本(pip install pymupdf)
    #    import fitz; doc = fitz.open(pdf_path); text = doc[0].get_text()
    # 2. 人工/半自动识别:材料信息、DFT数据、电化学性能
    # 3. 填入 SACRecord
    #    record = SACRecord(doi=..., material=..., dft=..., electrochem=...)
    #    records.append(record)
    print(f"处理中: {pdf_path.name}")
    break  # 示例只处理第一篇
# ★ 输出路径改为你想保存的位置
output_path = Path("literature_records.json")  # ← 可改路径
with open(output_path, "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)} 条记录到 {output_path}")

代码框架很简单,但真正的瓶颈不在代码——在于阅读论文和判断数据。你可以用 PyMuPDF 提取 PDF 文本、用正则匹配数字,但「这个 4.38 eV 是 Li₂S₄ 结合能还是 Li₂S 吸附能」这种判断,目前还是得靠人。

30 篇论文,每篇花 20-30 分钟提取,大约 10-15 小时的工作量。听起来不少,但想想:你做完之后手里就有了一个别人没有的结构化数据集——17 种金属中心、23 种配位环境,统一字段、统一单位、可排序、可筛选、可做机器学习。这个数据集的价值远大于 30 篇 PDF。

提取完之后,你可以用 pandas + matplotlib 快速画出统计图。下面这张图就是从 30 条记录里生成的——左图是金属分布(Fe 最多,7 篇;Co 其次,5 篇),右图是各金属的平均容量对比:

30篇论文统计图

生成这张图的代码也很简单——把 records 加载到 pandas,画两个子图:

# plot_overview.py — 从 literature_records.json 画统计图
# plot_overview.py — 从 literature_records.json 画统计图
# 安装依赖:pip install matplotlib pandas
import json
import matplotlib.pyplot as plt
from collections import Counter
# ★ 改成你的 JSON 文件路径
with open("literature_records.json") as f:  # ← batch_extract.py 的输出文件
    records = json.load(f)
# 统计金属分布:每种金属有多少篇论文
metals = [r["material"]["metal_center"] for r in records]
metal_counts = Counter(metals)
# 按金属分组,收集 0.5C 容量(跳过 null 值)
caps = {}
for r in records:
    m = r["material"]["metal_center"]
    c = r["electrochem"].get("capacity_05C_300cycles_mAh_g")
    if c is not None:
        caps.setdefault(m, []).append(c)
# 画图:左金属分布,右容量对比
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5.5))
# 左图:每种金属的论文数量
sorted_m = sorted(metal_counts, key=metal_counts.get, reverse=True)
ax1.bar(sorted_m, [metal_counts[m] for m in sorted_m])
ax1.set_ylabel("Number of Papers")
# 右图:每种金属的平均 0.5C 容量
avg = {m: sum(v)/len(v) for m, v in caps.items()}
sorted_a = sorted(avg, key=avg.get, reverse=True)
ax2.bar(sorted_a, [avg[m] for m in sorted_a])
ax2.set_ylabel("Capacity at 0.5C (mAh/g)")
plt.tight_layout()
# ★ 输出图片路径可改
plt.savefig("overview.png", dpi=200)  # 保存为 PNG 图片

左图告诉你这个领域的研究热点——Fe 和 Co 是最热门的金属中心,占了 40%。右图告诉你性能排序——Nb 虽然只有 1 篇论文,但容量最高(1016 mAh/g)。研究热度和性能不一定对齐,这本身就是数据驱动发现的起点。

这也是为什么有人选择绕过文献数据——直接用 DFT 高通量计算自建数据集(像前面提到的那篇用 CGCNN 筛选 203 种 SAC 的工作)。自建数据集一致性有保证,但覆盖不了实验验证的性能。文献数据乱,但它是唯一包含实验事实的数据源。

这篇学到了什么

两个点:

**第一,先定 schema,再读论文。**没有 schema 的阅读叫「泛读」,有 schema 的阅读叫「数据提取」。前者读完记不住,后者读完直接得到结构化数据。schema 就是你的筛子——筛子先织好,再去沙里淘金。

**第二,文献数据的三层搜索策略。**正文 → 图表 → SI。大部分数值在正文一句话里就能找到;图表里的数据需要从坐标轴读数(精度有限);SI 里的数据最容易被遗漏但往往最关键(DFT 设置、R_ct 数值)。每一层都要查到,null 和「没找到」是不同的信息。

现在你手里有三类数据了:本地计算记录(第二篇)、公共数据库筛选结果(第三篇)、文献提取记录(这一篇)。三种来源,同一个 JSON 格式,但字段不同、精度不同、可靠度不同。下一篇,我们把它们合并成一张统一特征表——那时候你会发现,合并本身也是一门学问。

附表:本文涉及的数据列表(精选)

本文从 27 篇论文中提取数据,下表列出其中 6 篇代表性论文:

| 金属 | 配位环境 | 标题(简) | DOI | | --- | --- | --- | --- | | Nb | NbN₄ | d-p杂化诱导捕获-耦合-转化机制 | 10.1021/jacs.2c10345 | | Fe | FeN₄ | 调控Fe单原子配位环境增强Li-S性能 | 10.1002/adfm.202501627 | | Co | CoN₃ | 剥离蒸发策略调控Co配位数 | 10.1021/acsmaterialslett.1c00414 | | Cu | CuN₃S₁ | 电负性诱导Jahn-Teller畸变促进Li-S转化 | 10.1021/acs.jpca.5c03527 | | Y | YN₄ | 单原子钇工程Janus电极用于Na-S电池 | 10.1021/jacs.2c07655 | | Zn | ZnN₂ | ZnN₂单原子柔性碳膜长寿命Na-S电池 | 10.1002/adfm.202214353 |

完整 27 篇论文列表(含 DOI、标题、金属中心、配位环境),关注公众号材智工场后回复「SAC」获取

欢迎关注材智工场

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

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