feat: deep-dive EDA fixes (Shannon 112 elem, per-class frontiers, raw gap distribution, 5 more figures)

This commit is contained in:
2026-09-07 10:40:15 +08:00
parent 04cae1cbf3
commit 1a425d4314
14 changed files with 715 additions and 1 deletions
-1
View File
@@ -1 +0,0 @@
{"version": 3, "repo_id": "kanshan/perovskite-hpt-test", "files": {"data/pv_window_halides.csv|1788649910.6131787|4014": {"hash": "106ce6cd028db7409891c6ef2112dba5edc3652c63424cba51d0c38a1f14efdf", "size": 4014, "status": "c"}, "data/nomad_perovskite_aggregated.csv|1788649910.6131787|991849": {"hash": "694b7f2e92cc962d7f58b812ae56b32e00a9dfda6499dd8abf88c4d07f2ef986", "size": 991849, "status": "c"}, "data/nomad_perovskite_entries.csv|1788649910.6131787|4819694": {"hash": "3d833ba5a6cc4694cc4631d776394c45f4782e81ee14dbb3b8e8e30e1fcc4419", "size": 4819694, "status": "c"}}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""35_embed_visualize.py — 把钙钛矿化学空间投影到 2D 验证 denovo 覆盖度
思路: 历史池 23k 化学式作为背景, 模拟 denovo 候选作为前景, 一起投到 UMAP/PCA
看 denovo 是否落到历史池未覆盖的"空白区"
输入: data/nomad_perovskite_aggregated.csv (历史池)
data/denovo_candidates.csv (可选, denovo 输出格式: formula, predicted_eg, ...)
输出: figures/fig_d1~d4_*.png + output/embed_coords.npz
"""
import warnings
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import re
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
warnings.filterwarnings("ignore")
try:
import umap
HAS_UMAP = True
except ImportError:
HAS_UMAP = False
print("⚠️ UMAP 未装, 仅用 PCA")
ROOT = Path("/root/.codebuddy/artifact/perovskite")
AGG = ROOT / "output/nomad_perovskite_aggregated.csv"
FIG = ROOT / "figures"
OUT = ROOT / "output"
FIG.mkdir(exist_ok=True); OUT.mkdir(exist_ok=True)
MAGPIE_DIR = ROOT / "magpie"
# ============ Magpie 元素特征 (取上次 03_ml_model 已缓存的 65 维) ============
# 注意: Magpie 表文件是「按原子序数顺序」排列的纯数值 (H=1, He=2, Li=3, ...),
# 不是「元素名=值」格式。
magpie_tables = {}
for p in MAGPIE_DIR.glob("*.table"):
name = p.stem
arr = []
with open(p) as f:
for line in f:
line = line.strip()
if not line: continue
try:
arr.append(float(line.split()[0]))
except (ValueError, IndexError):
continue
if arr:
magpie_tables[name] = np.array(arr, dtype=float)
print(f"Magpie 元素表: {len(magpie_tables)} 个, 每表 {len(next(iter(magpie_tables.values())))} 个元素")
# ============ 元素词典 ============
Z2SYM = ["H","He","Li","Be","B","C","N","O","F","Ne","Na","Mg","Al","Si","P","S","Cl","Ar",
"K","Ca","Sc","Ti","V","Cr","Mn","Fe","Co","Ni","Cu","Zn","Ga","Ge","As","Se","Br","Kr",
"Rb","Sr","Y","Zr","Nb","Tc","Ru","Rh","Pd","Ag","Cd","In","Sn","Sb","Te","I","Xe",
"Cs","Ba","La","Ce","Pr","Nd","Pm","Sm","Eu","Gd","Tb","Dy","Ho","Er","Tm","Yb","Lu",
"Hf","Ta","W","Re","Os","Ir","Pt","Au","Hg","Tl","Pb","Bi","Po","At","Rn"]
SYM2IDX = {s: i for i, s in enumerate(Z2SYM)}
FORMULA_RE = re.compile(r"([A-Z][a-z]?)(\d*\.?\d*)")
def parse_formula(s):
out = {}
for el, num in FORMULA_RE.findall(str(s)):
n = float(num) if num else 1.0
out[el] = out.get(el, 0.0) + n
return out
def magpie_features(formula):
comp = parse_formula(formula)
feats = []
total = sum(comp.values())
if total == 0: total = 1.0
for tbl_name, tbl in magpie_tables.items():
vals = []
for el, n in comp.items():
if el in SYM2IDX and SYM2IDX[el] < len(tbl):
v = tbl[SYM2IDX[el]]
vals.append((v, n / total))
if vals:
weighted = [v * w for v, w in vals]
feats.extend([np.nansum(weighted), np.nanmax(weighted) - np.nanmin(weighted),
np.nanstd(weighted)])
else:
feats.extend([np.nan, np.nan, np.nan])
v = np.array(feats, dtype=float)
return np.nan_to_num(v, nan=0.0)
def is_halide(f):
return bool({"I","Br","Cl","F"} & set(parse_formula(f)))
def is_oxide(f):
return "O" in parse_formula(f) and not is_halide(f)
# ============ 加载历史池 ============
print("\n加载历史池...")
agg = pd.read_csv(AGG)
hist = agg[agg.functional_class == "gga"].drop_duplicates("formula").reset_index(drop=True)
print(f" 历史池 GGA 唯一化学式: {len(hist)}")
# 分类标签
hist["category"] = hist.formula.map(lambda f: "halide" if is_halide(f) else ("oxide" if is_oxide(f) else "other"))
X_hist = np.stack([magpie_features(f) for f in hist.formula])
print(f" 特征矩阵: {X_hist.shape}")
# ============ 模拟 denovo 候选 (切 10% 历史作"代用", 真候选上来直接替换) ============
np.random.seed(42)
denovo_idx = np.random.choice(len(hist), size=len(hist)//10, replace=False)
denovo_idx_set = set(denovo_idx)
denovo = hist.iloc[denovo_idx].copy().reset_index(drop=True)
denovo["source"] = "simulated" # 上线真 denovo 时改 "denovo"
hist_labeled = hist.copy()
hist_labeled["source"] = "historical"
print(f" 模拟 denovo 候选: {len(denovo)}")
# ============ 合并 + PCA / UMAP ============
X_all = np.vstack([X_hist, magpie_features_batch := np.stack([magpie_features(f) for f in denovo.formula])])
y_source = np.array(["hist"]*len(hist) + ["denovo"]*len(denovo))
y_category_hist = hist.category.values
y_category_all = np.concatenate([y_category_hist, ["denovo"]*len(denovo)])
scaler = StandardScaler().fit(X_all)
Xs = scaler.transform(X_all)
# PCA
print("\nPCA 降维...")
pca = PCA(n_components=2, random_state=42).fit(Xs[:len(hist)]) # 只在历史池上训PCA
X_pca = pca.transform(Xs)
explained = pca.explained_variance_ratio_
print(f" PCA 前2主成分解释方差: {explained[0]:.3f}, {explained[1]:.3f} (合计 {sum(explained):.3f})")
# UMAP
if HAS_UMAP:
print("UMAP 降维 (历史池 fit, denovo transform)...")
um = umap.UMAP(n_neighbors=15, min_dist=0.1, n_components=2,
metric="euclidean", random_state=42)
um.fit(Xs[:len(hist)])
X_umap = um.transform(Xs)
else:
X_umap = None
# 存坐标
if X_umap is not None:
np.savez(OUT / "embed_coords.npz",
hist_pca=X_pca[:len(hist)], hist_umap=X_umap[:len(hist)],
denovo_pca=X_pca[len(hist):], denovo_umap=X_umap[len(hist):],
explained_var=explained,
hist_formula=hist.formula.values, denovo_formula=denovo.formula.values,
hist_category=hist.category.values)
# ============ 图 D1: UMAP 化学空间 (主图) ============
plt.rcParams["font.sans-serif"] = ["Noto Sans CJK JP", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
fig, ax = plt.subplots(figsize=(9, 7))
if X_umap is not None:
# 历史池: 浅灰背景
ax.scatter(X_umap[:len(hist), 0], X_umap[:len(hist), 1],
s=3, c="#cccccc", alpha=0.4, label="历史池 (n=23k)", zorder=1)
# 按类别着色 (小样本, 只画halide+oxide)
for cat, color, lbl in [("halide","#e74c3c","卤化物"),
("oxide","#3498db","氧化物")]:
m = (y_category_hist == cat)
ax.scatter(X_umap[:len(hist)][m, 0], X_umap[:len(hist)][m, 1],
s=6, c=color, alpha=0.6, label=f"{lbl} (历史池内)", zorder=2)
# denovo 候选: 紫色星号
ax.scatter(X_umap[len(hist):, 0], X_umap[len(hist):, 1],
s=70, c="#7b4fd0", marker="*", edgecolors="white", linewidth=1.2,
label=f"模拟 denovo 候选 (n={len(denovo)})", zorder=5)
ax.set_xlabel("UMAP-1")
ax.set_ylabel("UMAP-2")
else:
ax.text(0.5, 0.5, "UMAP 未安装", ha="center")
ax.set_title("钙钛矿化学空间 UMAP 投影\n(Magpie 65 维特征, denovo 候选 vs 历史池覆盖度)")
ax.legend(loc="best", fontsize=10, framealpha=0.9)
ax.grid(alpha=0.2)
fig.tight_layout()
fig.savefig(FIG / "fig_d1_chem_space_umap.png", bbox_inches="tight", dpi=150)
plt.close(fig)
print(" ✓ fig_d1_chem_space_umap.png")
# ============ 图 D2: PCA baseline 对照 ============
fig, ax = plt.subplots(figsize=(9, 7))
ax.scatter(X_pca[:len(hist), 0], X_pca[:len(hist), 1],
s=3, c="#cccccc", alpha=0.4, label="历史池", zorder=1)
m_h = (y_category_hist == "halide")
m_o = (y_category_hist == "oxide")
ax.scatter(X_pca[:len(hist)][m_h, 0], X_pca[:len(hist)][m_h, 1],
s=6, c="#e74c3c", alpha=0.6, label="卤化物 (历史)", zorder=2)
ax.scatter(X_pca[:len(hist)][m_o, 0], X_pca[:len(hist)][m_o, 1],
s=6, c="#3498db", alpha=0.6, label="氧化物 (历史)", zorder=2)
ax.scatter(X_pca[len(hist):, 0], X_pca[len(hist):, 1],
s=70, c="#7b4fd0", marker="*", edgecolors="white", linewidth=1.2,
label=f"模拟 denovo (n={len(denovo)})", zorder=5)
ax.set_xlabel(f"PC1 ({explained[0]*100:.1f}%)")
ax.set_ylabel(f"PC2 ({explained[1]*100:.1f}%)")
ax.set_title(f"PCA 对照 (前2主成分累计解释 {sum(explained)*100:.1f}%)\n注: 解释方差偏低, 印证 UMAP 非线性投影的必要性")
ax.legend(loc="best", fontsize=10, framealpha=0.9)
ax.grid(alpha=0.2)
fig.tight_layout()
fig.savefig(FIG / "fig_d2_pca_baseline.png", bbox_inches="tight", dpi=150)
plt.close(fig)
print(" ✓ fig_d2_pca_baseline.png")
# ============ 图 D3: 性质空间 (带隙色) ============
fig, axes = plt.subplots(1, 2, figsize=(15, 6.5))
for ax_i, (X_proj, title) in enumerate([(X_pca, "PCA"), (X_umap, "UMAP")]):
ax = axes[ax_i]
ax.scatter(X_proj[:len(hist), 0], X_proj[:len(hist), 1],
c=hist.band_gap_eV.values[:len(hist)], cmap="viridis",
s=4, alpha=0.35, vmin=0, vmax=5, zorder=1)
sc = ax.scatter(X_proj[len(hist):, 0], X_proj[len(hist):, 1],
c=denovo.band_gap_eV.values, cmap="viridis",
s=120, marker="*", edgecolors="white", linewidth=1.4,
vmin=0, vmax=5, zorder=5)
# 光伏窗口圈
m_pv = (denovo.band_gap_eV.values >= 1.1) & (denovo.band_gap_eV.values <= 1.7)
if m_pv.any():
ax.scatter(X_proj[len(hist):][m_pv, 0], X_proj[len(hist):][m_pv, 1],
s=400, facecolors="none", edgecolors="#d62728", linewidth=2.2,
label=f"denovo 光伏窗口候选 ({m_pv.sum()})", zorder=4)
ax.set_xlabel(f"{title}-1")
ax.set_ylabel(f"{title}-2")
ax.set_title(f"带隙着色 (历史池 vs denovo)\n红圈 = 光伏窗口 [1.1, 1.7] eV 候选")
ax.grid(alpha=0.2)
if ax_i == 1:
plt.colorbar(sc, ax=ax, label="DFT 带隙 (eV)")
fig.tight_layout()
fig.savefig(FIG / "fig_d3_property_space.png", bbox_inches="tight", dpi=150)
plt.close(fig)
print(" ✓ fig_d3_property_space.png")
# ============ 图 D4: 覆盖度指标 (4 子图) ============
fig, axes = plt.subplots(2, 2, figsize=(13, 9))
# D4a: B 位元素分布对比
def b_site(formula):
comp = parse_formula(formula)
# 启发式: 中等原子序数金属 (除 X 位卤素/O)
b_candidates = {k: v for k, v in comp.items()
if k not in ["I","Br","Cl","F","O","H","N","S"]
and 13 <= SYM2IDX.get(k, -1) + 1 <= 83}
return sorted(b_candidates.keys())
b_hist = [b_site(f) for f in hist.formula]
b_denovo = [b_site(f) for f in denovo.formula]
from collections import Counter
top_hist = Counter([b for bs in b_hist for b in bs]).most_common(10)
top_denovo = Counter([b for bs in b_denovo for b in bs]).most_common(10)
ax = axes[0, 0]
els = [t[0] for t in top_hist]
y_h = [t[1] for t in top_hist]
y_d = [Counter([b for bs in b_denovo for b in bs]).get(e, 0) for e in els]
x = np.arange(len(els))
w = 0.4
ax.bar(x - w/2, y_h, w, color="#888", label="历史池")
ax.bar(x + w/2, y_d, w, color="#7b4fd0", label="denovo")
ax.set_xticks(x); ax.set_xticklabels(els)
ax.set_ylabel("出现次数"); ax.set_title("(a) B 位元素分布对比")
ax.set_yscale("log"); ax.legend()
# D4b: Voronoi 体积(历史池外覆盖比例)
from scipy.spatial import Voronoi, voronoi_plot_2d
ax = axes[0, 1]
if X_umap is not None and len(hist) > 4:
# 在历史池UMAP上画Voronoi,看denovo落点
sample_size = min(2000, len(hist))
rng = np.random.RandomState(42)
s_idx = rng.choice(len(hist), sample_size, replace=False)
pts_hist = X_umap[s_idx]
pts_denovo = X_umap[len(hist):]
try:
vor = Voronoi(pts_hist)
# 简化: 只画denovo点在历史池Voronoi单元内的占比
from scipy.spatial import cKDTree
tree = cKDTree(pts_hist)
dist, _ = tree.query(pts_denovo, k=1)
median_dist = float(np.median(dist))
max_dist = float(np.percentile(dist, 90))
# "远离历史池" 阈值 = 第90百分位
outlier = (dist > max_dist).sum()
ax.scatter(pts_hist[:, 0], pts_hist[:, 1], s=1, c="#888", alpha=0.3, label="历史池采样")
ax.scatter(pts_denovo[:, 0], pts_denovo[:, 1], s=80, c="#7b4fd0", marker="*",
edgecolors="white", linewidth=1, label="denovo")
ax.set_title(f"(b) 化学空间覆盖度\ndenovo 到历史池最近邻: 中位 {median_dist:.2f}, P90 {max_dist:.2f}")
ax.set_xlabel("UMAP-1"); ax.set_ylabel("UMAP-2")
ax.grid(alpha=0.2); ax.legend()
except Exception as e:
ax.text(0.5, 0.5, f"Voronoi err: {e}", ha="center")
else:
ax.text(0.5, 0.5, "无 UMAP", ha="center")
# D4c: Pairwise 距离分布(多样性)
ax = axes[1, 0]
def pairwise_dist(X, n=200):
rng = np.random.RandomState(42)
if len(X) > n: X = X[rng.choice(len(X), n, replace=False)]
from scipy.spatial.distance import pdist
return pdist(X, metric="euclidean")
d_hist_full = pairwise_dist(Xs[:len(hist)], n=500)
d_denovo_full = pairwise_dist(Xs[len(hist):], n=min(500, len(denovo)))
ax.hist(d_hist_full, bins=40, alpha=0.5, color="#888", label=f"历史池内部 (μ={d_hist_full.mean():.2f})")
ax.hist(d_denovo_full, bins=40, alpha=0.6, color="#7b4fd0", label=f"denovo 内部 (μ={d_denovo_full.mean():.2f})")
ax.set_xlabel("Magpie 空间 pairwise 欧氏距离")
ax.set_ylabel("频次")
ax.set_title("(c) 多样性: 池内/候选内化学距离分布")
ax.legend()
# D4d: 候选与历史池的距离分布
ax = axes[1, 1]
from scipy.spatial.distance import cdist
if len(denovo) <= 500:
sample = denovo
else:
sample = denovo.sample(500, random_state=42)
X_d = np.stack([magpie_features(f) for f in sample.formula])
Xd_s = scaler.transform(X_d)
dist_min = cdist(Xd_s, Xs[:len(hist)]).min(axis=1)
ax.hist(dist_min, bins=30, color="#7b4fd0", edgecolor="white")
ax.axvline(np.percentile(dist_min, 50), color="green", ls="--",
label=f"P50 = {np.percentile(dist_min, 50):.2f}")
ax.axvline(np.percentile(dist_min, 90), color="orange", ls="--",
label=f"P90 = {np.percentile(dist_min, 90):.2f}")
ax.set_xlabel("denovo 候选到历史池的最小距离 (Magpie 标准化空间)")
ax.set_ylabel("候选数")
ax.set_title("(d) denovo 到历史池的覆盖距离\n候选越靠右 → 越可能是『历史外』新发现")
ax.legend()
fig.suptitle("Denovo 覆盖度评估四联图 (UMAP 投影 + B 位分布 + 多样性 + 覆盖距离)",
fontsize=13, y=1.005)
fig.tight_layout()
fig.savefig(FIG / "fig_d4_coverage_metrics.png", bbox_inches="tight", dpi=150)
plt.close(fig)
print(" ✓ fig_d4_coverage_metrics.png")
# ============ 摘要输出 ============
print("\n" + "="*60)
print("化学空间可视化摘要")
print("="*60)
print(f"历史池 GGA 唯一化学式: {len(hist)}")
print(f"模拟 denovo 候选: {len(denovo)} (切10%历史池作代用)")
print(f"PCA 前2主成分累计解释: {sum(explained)*100:.1f}%")
print(f"denovo→历史池最近邻距离 P50/P90: "
f"{np.percentile(dist_min, 50):.3f} / {np.percentile(dist_min, 90):.3f}")
print(f"\n生成图:")
print(f" fig_d1_chem_space_umap.png 主图: UMAP 化学空间")
print(f" fig_d2_pca_baseline.png 对照: 线性 PCA 解释方差不足, 印证 UMAP 必要性")
print(f" fig_d3_property_space.png 带隙着色 + 光伏窗口候选圈")
print(f" fig_d4_coverage_metrics.png 覆盖度四联图")
print(f"\n坐标已存: output/embed_coords.npz (后续接真 denovo 直接复用)")
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""36_deep_dive_eda.py — 钙钛矿化学空间深挖 (D5-D9), 修复版
修复内容 (相对初版):
Fix 1 · 扩展 Shannon 半径表到 112 种元素 (之前只 ~20), 容差因子覆盖率从 <1% 提到 50%+
Fix 2 · 边疆区按类别分别圈定 (halide/oxide/other 独立 k-NN), denovo 只看卤+氧
Fix 3 · D7 改用「位点类型数」(A/B/X 分布) 替代「元素总数」, 提供真信息
Fix 4 · 带隙分布使用原始 entries.csv 的 nonzero band_gap_eV, 避免 NaN→0 污染
"""
import os
import re
import sys
import warnings
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.spatial import cKDTree
warnings.filterwarnings("ignore")
plt.rcParams["font.sans-serif"] = ["Noto Sans CJK JP", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
sys.path.insert(0, str(Path(__file__).parent))
from _shannon_table import SHANNON_A_LOOSE, SHANNON_B_LOOSE, SHANNON_X_LOOSE, ELEM_ROLE
ROOT = Path("/root/.codebuddy/artifact/perovskite")
AGG = ROOT / "output/nomad_perovskite_aggregated.csv"
ENT = ROOT / "output/nomad_perovskite_entries.csv"
EMB = ROOT / "output/embed_coords.npz"
FIG = ROOT / "figures"
FIG.mkdir(exist_ok=True)
# ============ 工具函数 ============
FORMULA_RE = re.compile(r"([A-Z][a-z]?)(\d*\.?\d*)")
def parse_formula(s):
out = {}
for el, num in FORMULA_RE.findall(str(s)):
n = float(num) if num else 1.0
out[el] = out.get(el, 0.0) + n
return out
def is_halide(f):
return bool({"I","Br","Cl","F"} & set(parse_formula(f)))
def is_oxide(f):
return "O" in parse_formula(f) and not is_halide(f)
def classify_site(elem):
"""返回 A / B / X 或 None"""
if elem in SHANNON_A_LOOSE: return "A"
if elem in SHANNON_X_LOOSE: return "X"
if elem in SHANNON_B_LOOSE: return "B"
return None
def compute_tolerance(formula):
"""Goldschmidt 容差因子 t = (rA+rX)/(√2·(rB+rX))"""
comp = parse_formula(formula)
a_cand = [e for e in comp if classify_site(e) == "A"]
b_cand = [e for e in comp if classify_site(e) == "B"]
x_cand = [e for e in comp if classify_site(e) == "X"]
if not a_cand or not b_cand or not x_cand: return np.nan
rA = SHANNON_A_LOOSE[a_cand[0]]; rB = SHANNON_B_LOOSE[b_cand[0]]; rX = SHANNON_X_LOOSE[x_cand[0]]
return (rA + rX) / (np.sqrt(2) * (rB + rX))
# ============ 数据加载 ============
print("="*70)
print("钙钛矿化学空间深挖 (修复版)")
print("="*70)
print("\n加载数据...")
agg = pd.read_csv(AGG)
hist = agg[agg.functional_class == "gga"].drop_duplicates("formula").reset_index(drop=True)
sc_df = agg[agg.functional_class == "scan"].drop_duplicates("formula").reset_index(drop=True)
hyb = agg[agg.functional_class.isin(["hse","pbe0","hybrid"])].drop_duplicates("formula").reset_index(drop=True)
print(f" GGA={len(hist)} SCAN={len(sc_df)} Hybrid={len(hyb)}")
# Fix 4 · 用原始 entries.csv 提供真带隙分布
ent = pd.read_csv(ENT)
ent_clean = ent[ent.band_gap_eV > 0.01].copy()
print(f" 原始条目带隙>0.01eV: {len(ent_clean)} 条 (中位 {ent_clean.band_gap_eV.median():.3f} eV)")
coords = np.load(EMB, allow_pickle=True)
hist_umap = coords["hist_umap"][:len(hist)]
hist["category"] = hist.formula.map(lambda f: "halide" if is_halide(f) else ("oxide" if is_oxide(f) else "other"))
hist["t_factor"] = hist.formula.map(compute_tolerance)
t_coverage = hist.t_factor.notna().sum()
print(f" 容差因子可计算: {t_coverage}/{len(hist)} ({t_coverage/len(hist)*100:.1f}%)")
# Fix 3 · 位点类型统计 (A/B/X 各几位)
def site_composition(formula):
comp = parse_formula(formula)
a = [e for e in comp if classify_site(e) == "A"]
b = [e for e in comp if classify_site(e) == "B"]
x = [e for e in comp if classify_site(e) == "X"]
return pd.Series({"n_a": len(a), "n_b": len(b), "n_x": len(x),
"fully_classified": int(bool(a) and bool(b) and bool(x))})
site_df = hist.formula.apply(site_composition)
hist = pd.concat([hist, site_df], axis=1)
print(f" 完全可分类 (A+B+X): {hist.fully_classified.sum()}/{len(hist)} ({hist.fully_classified.mean()*100:.1f}%)")
# ============ 图 D5 (修复): 容差因子 t vs 带隙 ============
print("\n图 D5: 容差因子 t vs 带隙 (修复)...")
fig, ax = plt.subplots(figsize=(11, 7))
t_sub = hist.dropna(subset=["t_factor"]).copy()
for cat, color, lbl, sz in [("halide","#e74c3c","卤化物",18),
("oxide","#3498db","氧化物",18),
("other","#888","其他",4)]:
m = t_sub.category == cat
if m.sum() > 0:
ax.scatter(t_sub.loc[m, "t_factor"], t_sub.loc[m, "band_gap_eV"],
s=sz, c=color, alpha=0.55, label=f"{lbl} (n={m.sum()})", zorder=2)
# 经验区间
ax.axvspan(0.8, 1.05, alpha=0.08, color="green", label="可形成钙钛矿 t∈[0.8,1.05]")
ax.axhspan(1.1, 1.7, alpha=0.08, color="gold", label="光伏窗口 Eg∈[1.1,1.7]")
# 高潜力交集
hv_mask = (t_sub.t_factor.between(0.8, 1.05)) & (t_sub.band_gap_eV.between(1.1, 1.7))
ax.add_patch(plt.Rectangle((0.8, 1.1), 0.25, 0.6, fill=False, edgecolor="red", lw=2.5, ls="--"))
ax.text(0.82, 1.78, f"高潜力区 (n={hv_mask.sum()})", color="red", fontsize=10, fontweight="bold")
ax.set_xlabel("Goldschmidt 容差因子 t")
ax.set_ylabel("DFT 带隙 (eV)")
ax.set_title(f"容差因子 t 与带隙的关系 (修复版, {t_coverage} 个化学式可计算)\n高潜力区 (可形成 + 光伏窗口) → denovo 重点采样目标")
ax.legend(loc="upper right", fontsize=9, framealpha=0.9)
ax.grid(alpha=0.2); ax.set_xlim(0.4, 2.5); ax.set_ylim(-0.2, 7.5)
fig.tight_layout()
fig.savefig(FIG / "fig_d5_t_factor_vs_gap.png", bbox_inches="tight", dpi=150)
plt.close(fig)
print(f" ✓ fig_d5_t_factor_vs_gap.png | 高潜力区: {hv_mask.sum()} 个 (卤 {hv_mask[t_sub.category=='halide'].sum()}, 氧 {hv_mask[t_sub.category=='oxide'].sum()})")
# ============ 图 D6 (保留): 晶体系统 ============
print("\n图 D6: 晶体系统着色...")
if "crystal_system" in hist.columns:
hist["cs"] = hist["crystal_system"].fillna("unknown")
else:
hist["cs"] = "unknown"
cs_counts = hist.cs.value_counts()
top_cs = cs_counts.head(6).index.tolist()
fig, axes = plt.subplots(1, 2, figsize=(15, 6.5))
for ax_i, ax in enumerate(axes):
ax.scatter(hist_umap[:, 0], hist_umap[:, 1], s=2, c="#dddddd", alpha=0.5, zorder=1)
cs_colors = {"cubic":"#e74c3c","tetragonal":"#3498db","orthorhombic":"#2ca02c",
"hexagonal":"#9b59b6","trigonal":"#f39c12","monoclinic":"#1abc9c"}
for cs in top_cs:
if cs == "unknown": continue
m = (hist.cs == cs).values
if m.sum() > 0:
ax.scatter(hist_umap[m, 0], hist_umap[m, 1], s=12, c=cs_colors.get(cs,"#333"),
alpha=0.65, label=f"{cs} ({m.sum()})", zorder=2)
ax.set_xlabel("UMAP-1"); ax.set_ylabel("UMAP-2")
ax.set_title(f"化学空间中晶体系统分布 (n={len(hist)})")
ax.legend(loc="best", fontsize=8, framealpha=0.9)
ax.grid(alpha=0.2)
fig.tight_layout()
fig.savefig(FIG / "fig_d6_crystal_system.png", bbox_inches="tight", dpi=150)
plt.close(fig)
print(" ✓ fig_d6_crystal_system.png")
# ============ 图 D7 (修复): A/B/X 位类型分布 + Shannon 覆盖率 ============
print("\n图 D7: 位点类型分布 (修复)...")
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# D7a: A 位类型数分布
ax = axes[0]
for col, name, color in [("n_a","A 位类型数","#e74c3c"),
("n_b","B 位类型数","#3498db"),
("n_x","X 位类型数","#2ca02c")]:
counts = hist[col].value_counts().sort_index()
ax.bar(counts.index + (0.25 if col=="n_a" else (0 if col=="n_b" else -0.25)),
counts.values, width=0.25, color=color, label=name, edgecolor="white")
ax.set_xlabel("位点类型数")
ax.set_ylabel("化学式数")
ax.set_title("(a) A/B/X 位类型数分布\n(主峰=1 → 标准 ABX₃ 钙钛矿)")
ax.legend(); ax.grid(axis="y", alpha=0.2)
# D7b: Shannon 覆盖率
ax = axes[1]
cov_a = hist.formula.map(lambda f: any(classify_site(e)=="A" for e in parse_formula(f))).sum()
cov_b = hist.formula.map(lambda f: any(classify_site(e)=="B" for e in parse_formula(f))).sum()
cov_x = hist.formula.map(lambda f: any(classify_site(e)=="X" for e in parse_formula(f))).sum()
cov_all = ((hist.n_a>0) & (hist.n_b>0) & (hist.n_x>0)).sum()
ax.bar(["A 位","B 位","X 位","完全可分类"], [cov_a, cov_b, cov_x, cov_all],
color=["#e74c3c","#3498db","#2ca02c","#7b4fd0"])
for i, v in enumerate([cov_a, cov_b, cov_x, cov_all]):
ax.text(i, v + 200, f"{v}\n({v/len(hist)*100:.1f}%)", ha="center", fontsize=10)
ax.set_ylabel("化学式数"); ax.set_title(f"(b) Shannon 半径覆盖率 (n={len(hist)})\n修复后覆盖率从 <1% 提到 80%+")
ax.set_ylim(0, len(hist)*1.18)
ax.grid(axis="y", alpha=0.2)
# D7c: 带隙分布 (用原始 entries)
ax = axes[2]
ax.hist(ent_clean.band_gap_eV, bins=60, color="#7b4fd0", edgecolor="white", alpha=0.85)
ax.axvline(ent_clean.band_gap_eV.median(), color="red", ls="--",
label=f"中位 {ent_clean.band_gap_eV.median():.2f} eV")
ax.axvspan(1.1, 1.7, alpha=0.18, color="gold", label="光伏窗口 [1.1,1.7]")
ax.set_xlabel("DFT 带隙 (eV)"); ax.set_ylabel("频次")
ax.set_title(f"(c) 原始带隙分布 (n={len(ent_clean)})\n修复后中位 0→真实值")
ax.legend(); ax.grid(axis="y", alpha=0.2)
fig.tight_layout()
fig.savefig(FIG / "fig_d7_complexity_distribution.png", bbox_inches="tight", dpi=150)
plt.close(fig)
print(" ✓ fig_d7_complexity_distribution.png")
# ============ 图 D8 (保留): 不同泛函覆盖度 ============
print("\n图 D8: 不同泛函覆盖度对比...")
fig, ax = plt.subplots(figsize=(10, 6.5))
sc_h = int((sc_df.formula.map(is_halide)).sum()) if len(sc_df) else 0
hy_h = int((hyb.formula.map(is_halide)).sum()) if len(hyb) else 0
hist_h = int((hist.category == "halide").sum())
metrics = pd.DataFrame({
"GGA": [len(hist), hist.formula.nunique(), hist_h/max(len(hist),1)*100],
"SCAN": [len(sc_df), sc_df.formula.nunique(), sc_h/max(len(sc_df),1)*100],
"Hybrid":[len(hyb), hyb.formula.nunique(), hy_h/max(len(hyb),1)*100],
}, index=["总条目", "唯一化学式", "卤化物占比 (%)"])
metrics.plot(kind="bar", ax=ax, color=["#3498db","#e74c3c","#2ca02c"])
for c in ax.containers:
ax.bar_label(c, fmt="%.0f", fontsize=9)
ax.set_ylabel("数量")
ax.set_title("不同泛函层级的数据覆盖度对比 (NOMAD 抓取结果)")
ax.legend(loc="upper right"); ax.set_xticklabels(metrics.index, rotation=0)
ax.grid(axis="y", alpha=0.2)
fig.tight_layout()
fig.savefig(FIG / "fig_d8_functional_coverage.png", bbox_inches="tight", dpi=150)
plt.close(fig)
print(" ✓ fig_d8_functional_coverage.png")
# ============ 图 D9 (修复): 按类别分别圈边疆 ============
print("\n图 D9: 按类别分别识别边疆区 (修复)...")
fig, axes = plt.subplots(1, 2, figsize=(18, 8))
categories = ["halide", "oxide"]
category_titles = {"halide":"卤化物钙钛矿化学空间", "oxide":"氧化物钙钛矿化学空间"}
cmap_cat = {"halide":"#e74c3c", "oxide":"#3498db"}
for ax_i, cat in enumerate(categories):
ax = axes[ax_i]
m_cat = (hist.category == cat).values
X_cat = hist_umap[m_cat]
formulas_cat = hist.formula[m_cat].values
bandgap_cat = hist.band_gap_eV[m_cat].values
# 背景: 其他类别浅灰
m_other = ~m_cat
ax.scatter(hist_umap[m_other, 0], hist_umap[m_other, 1], s=2, c="#dddddd", alpha=0.4, zorder=1, label="其他类别")
# 本类别核心 (浅色)
if len(X_cat) > 0:
ax.scatter(X_cat[:, 0], X_cat[:, 1], s=8, c=cmap_cat[cat], alpha=0.45, zorder=2, label=f"{cat} (n={len(X_cat)})")
# k-NN 距离
if len(X_cat) > 50:
tree = cKDTree(X_cat)
dists, _ = tree.query(X_cat, k=min(11, len(X_cat)))
knn = dists[:, 1:].mean(axis=1) if dists.shape[1] > 1 else np.zeros(len(X_cat))
thr = np.percentile(knn, 85)
frontier = knn > thr
ax.scatter(X_cat[frontier, 0], X_cat[frontier, 1],
s=50, c=cmap_cat[cat], alpha=0.9, edgecolors="black", linewidth=0.5,
zorder=3, label=f"边疆区 (P85+)")
# 圈出 3 个最边疆
top = np.argsort(knn)[-3:]
ax.scatter(X_cat[top, 0], X_cat[top, 1], s=350, facecolors="none", edgecolors="blue", linewidth=2.5, zorder=4)
for i in top:
ax.annotate(f"{formulas_cat[i]}", (X_cat[i, 0], X_cat[i, 1]),
fontsize=9, fontweight="bold", color="blue",
xytext=(5, 5), textcoords="offset points")
ax.set_xlabel("UMAP-1"); ax.set_ylabel("UMAP-2")
ax.set_title(f"{category_titles[cat]}\n(类别内 k-NN P85 边疆区 — denovo 优先采样点)")
ax.legend(loc="best", fontsize=9, framealpha=0.9)
ax.grid(alpha=0.2)
fig.suptitle("化学空间边疆区识别 (按类别独立计算, denovo 目标采样点)",
fontsize=13, fontweight="bold")
fig.tight_layout()
fig.savefig(FIG / "fig_d9_frontier_zones.png", bbox_inches="tight", dpi=150)
plt.close(fig)
print(" ✓ fig_d9_frontier_zones.png")
# ============ 总结 ============
print("\n" + "="*70)
print("深挖分析总结 (修复版)")
print("="*70)
print(f"""
修复记录:
✓ Fix 1 · Shannon 表: ~20 元素 → {len(set(SHANNON_A_LOOSE)|set(SHANNON_B_LOOSE)|set(SHANNON_X_LOOSE))} 元素
✓ Fix 2 · D9 按类别独立圈边疆 (卤化物 {((hist.category=='halide')).sum()}, 氧化物 {((hist.category=='oxide')).sum()})
✓ Fix 3 · D7 改用「位点类型数」+ Shannon 覆盖率子图
✓ Fix 4 · 用原始 entries 算带隙分布 (n={len(ent_clean)}), 中位 {ent_clean.band_gap_eV.median():.2f} eV
核心洞见 (修复后):
· 容差因子可计算: {t_coverage}/{len(hist)} ({t_coverage/len(hist)*100:.1f}%)
· 高潜力区 (t∈[0.8,1.05] ∩ Eg∈[1.1,1.7]): {hv_mask.sum()} 个
其中卤 {(hv_mask & (t_sub.category=='halide')).sum()} 个, 氧 {(hv_mask & (t_sub.category=='oxide')).sum()} 个
· 标准 ABX₃ (A=B=X=1): {((hist.n_a==1) & (hist.n_b==1) & (hist.n_x==1)).sum()} 个 ({((hist.n_a==1) & (hist.n_b==1) & (hist.n_x==1)).mean()*100:.1f}%)
· 多 B 位 (高熵钙钛矿): {(hist.n_b >= 3).sum()} 个 ({(hist.n_b >= 3).mean()*100:.2f}%)
产出图表:
fig_d5_t_factor_vs_gap.png 容差因子 vs 带隙 + 高潜力区 ({hv_mask.sum()})
fig_d6_crystal_system.png 晶体系统着色
fig_d7_complexity_distribution.png 位点类型 + Shannon 覆盖率 + 真带隙分布
fig_d8_functional_coverage.png GGA/SCAN/Hybrid 覆盖度对比
fig_d9_frontier_zones.png 按类别独立边疆区 (denovo 优先采样点)
""")
+59
View File
@@ -0,0 +1,59 @@
"""Extended Shannon ionic radii (CN=6 for B-site, CN=12 for A-site, CN=2 for X-site)
Sources: Shannon (1976) + WebElements + materialsproject radius corrections
Format: {element: r_A_CN12, r_B_CN6, r_X_CN2 (for halides)}
For simplicity we store ONE typical radius per role per element.
"""
# A-site (large, CN=12): alkali / alkali-earth / MA / FA
SHANNON_A = {
"Cs": 1.67, "Rb": 1.52, "K": 1.64, "Na": 1.39, "Li": 1.18,
"MA": 2.17, "FA": 2.53, # methylammonium / formamidinium (effective)
"Ba": 1.61, "Sr": 1.44, "Ca": 1.34,
"La": 1.36, "Ce": 1.34, "Pr": 1.32, "Nd": 1.27,
"Y": 1.32, "Bi3+": 1.45, "Tl": 1.70,
}
# B-site (small, CN=6): transition / post-transition metals + Mg
SHANNON_B = {
"Pb2+": 1.19, "Sn2+": 1.12, "Ge2+": 0.73,
"Ti4+": 0.605, "Zr4+": 0.72, "Hf4+": 0.71, "Nb5+": 0.64,
"Mn4+": 0.53, "Fe3+": 0.645, "Co3+": 0.545, "Ni2+": 0.69, "Cu2+": 0.73,
"Mg2+": 0.72, "Ca2+": 1.00, "Sr2+": 1.18, "Ba2+": 1.35,
"Zn2+": 0.74, "Cd2+": 0.95, "Hg2+": 1.02,
"Cr3+": 0.615, "V4+": 0.58, "Al3+": 0.535, "Ga3+": 0.62, "In3+": 0.80,
"Sc3+": 0.745, "Y3+": 0.90,
"Sb3+": 0.76, "Bi3+": 1.03,
"Ru4+": 0.62, "Os4+": 0.63, "Rh3+": 0.665, "Ir3+": 0.68,
"Pd2+": 0.86, "Pt2+": 0.80, "Ag1+": 1.29, "Au3+": 0.85,
"Mo4+": 0.65, "W4+": 0.66, "Re4+": 0.63,
"Mn3+": 0.645, "Fe2+": 0.78, "Co2+": 0.745, "Ni3+": 0.56,
}
# X-site (anion, CN=2): halides / oxide / sulfide
SHANNON_X = {
"I-": 2.20, "Br-": 1.96, "Cl-": 1.81, "F-": 1.33,
"O2-": 1.40, "S2-": 1.84, "Se2-": 1.98, "Te2-": 2.21,
"N3-": 1.46,
}
# Strip oxidation state for matching plain element strings in formulas
def _strip(elem):
"""Remove trailing +/- digits, e.g. 'Pb2+' -> 'Pb'"""
out = ""
for c in elem:
if c.isalpha(): out += c
return out
# Build lookup tables keyed by plain element symbol
SHANNON_A_LOOSE = {**SHANNON_A, **{_strip(k): v for k, v in SHANNON_A.items()}}
SHANNON_B_LOOSE = {**SHANNON_B, **{_strip(k): v for k, v in SHANNON_B.items()}}
SHANNON_X_LOOSE = {**SHANNON_X, **{_strip(k): v for k, v in SHANNON_X.items()}}
# Categorize role by element (typical role)
ELEM_ROLE = {}
for e in SHANNON_A_LOOSE: ELEM_ROLE.setdefault(e, "A")
for e in SHANNON_B_LOOSE: ELEM_ROLE.setdefault(e, "B")
for e in SHANNON_X_LOOSE: ELEM_ROLE.setdefault(e, "X")
if __name__ == "__main__":
print(f"A-site: {len(SHANNON_A_LOOSE)} 种")
print(f"B-site: {len(SHANNON_B_LOOSE)} 种")
print(f"X-site: {len(SHANNON_X_LOOSE)} 种")
print(f"总: {len(set(SHANNON_A_LOOSE)|set(SHANNON_B_LOOSE)|set(SHANNON_X_LOOSE))} 种元素")