File size: 15,500 Bytes
ef53368 90b47cf ef53368 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 | """Core data loading and analysis for the leveling-effect study (NS372 and delta22). No plotting.
Reads the two datasets, decodes the stored shieldings, and runs the per-nucleus correlation / PCA
analysis. The figure notebook (built by build_nb_leveling.py) and test_leveling.py import from here.
The claim tested, on each dataset separately (never pooled): once a linear scaling is allowed for,
any two shielding methods are near-perfectly correlated, PC1 carries almost all between-method
variance, and the methods fall on a 1D curve in (PC1, PC2).
"""
import numpy as np
import pandas as pd
import h5py
import openpyxl
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from fixed_point import decode_fixed_point as _decode_fixed_point
from stats import rmse as _rmse
# canonical functional-family ordering used to sort method columns in every figure
FAMILY_ORDER = ['LDA', 'GGA', 'mGGA', 'GH', 'RSH', 'LH', 'DH', 'WFT', 'ref']
def sort_by_family(M, methods, families):
"""Stable-sort method columns of M into FAMILY_ORDER (reference family last)."""
rank = {f: i for i, f in enumerate(FAMILY_ORDER)}
idx = sorted(range(len(methods)), key=lambda i: (rank.get(families[i], 99), i))
return M[:, idx], [methods[i] for i in idx], [families[i] for i in idx]
def log_r(R):
"""-log10(1-|r|): 1 -> r=0.9, 2 -> 0.99, 3 -> 0.999, ..."""
return -np.log10(np.clip(1 - np.abs(R), 1e-6, None))
def scaled_rmse(method, ref):
"""RMSE of residuals after the per-method linear fit method ~ a*ref + b."""
a, b = np.polyfit(ref, method, 1)
return _rmse(method, a * ref + b)
def _parabola_fit(xn, yn, n_angles=720):
# best degree-2 fit y_rot ~ x_rot searched over rotation angle theta
best = (0.0, -np.inf, None)
for th in np.linspace(0, np.pi, n_angles):
c, s = np.cos(th), np.sin(th)
xr, yr = c * xn + s * yn, -s * xn + c * yn
A = np.column_stack([np.ones_like(xr), xr, xr ** 2])
coef, *_ = np.linalg.lstsq(A, yr, rcond=None)
ss_res = ((yr - A @ coef) ** 2).sum()
ss_tot = ((yr - yr.mean()) ** 2).sum()
r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
if r2 > best[1]:
best = (th, r2, coef)
return best
def analyze_nucleus(M, methods, ref=None):
"""Correlation, PCA, and parabola-fit diagnostics for one nucleus.
M is an (n_observations, n_methods) shielding matrix; ref (optional) is the reference method's
values, used to compute each method's scaled RMSE against it.
"""
R = np.corrcoef(M, rowvar=False)
iu = np.triu_indices_from(R, k=1)
rv = R[iu]
Z = StandardScaler().fit_transform(M)
pca = PCA().fit(Z)
ev = pca.explained_variance_ratio_
pc1, pc2 = pca.components_[0].copy(), pca.components_[1].copy()
if pc1.mean() < 0: # cosmetic sign convention
pc1 = -pc1
mx, my = pc1.mean(), pc2.mean()
sx, sy = max(pc1.std(), 1e-12), max(pc2.std(), 1e-12)
xn, yn = (pc1 - mx) / sx, (pc2 - my) / sy
th, pr2, coef = _parabola_fit(xn, yn)
c, s = np.cos(th), np.sin(th)
par = dict(theta=th, coef=coef, r2=pr2, mx=mx, sx=sx, my=my, sy=sy,
xr=c * xn + s * yn)
out = dict(R=R, methods=list(methods), pc1=pc1, pc2=pc2, ev=ev, par=par,
r_min=float(rv.min()), r_med=float(np.median(rv)),
r_max=float(rv.max()))
if ref is not None:
out['scaled_rmse'] = {m: scaled_rmse(M[:, i], ref)
for i, m in enumerate(methods)}
return out
def parabola_curve(par, npts=300):
"""Sample points along the fitted parabola (from analyze_nucleus's `par`) in PC1/PC2 space."""
th, coef = par['theta'], par['coef']
c, s = np.cos(th), np.sin(th)
xr = par['xr']
vertex = -coef[1] / (2 * coef[2]) if coef[2] != 0 else float(xr.mean())
lo, hi = min(xr.min(), vertex), max(xr.max(), vertex)
pad = 0.15 * (hi - lo + 1e-9)
t = np.linspace(lo - pad, hi + pad, npts)
yr = coef[0] + coef[1] * t + coef[2] * t ** 2
xn, yn = c * t - s * yr, s * t + c * yr
return xn * par['sx'] + par['mx'], yn * par['sy'] + par['my']
def summarize(data, res):
"""Per-nucleus leveling diagnostics (analyze_nucleus results) as one summary DataFrame."""
import pandas as pd
rows = []
for nuc, r in res.items():
rows.append(dict(nucleus=nuc,
n_obs=data[nuc]['M'].shape[0],
n_methods=data[nuc]['M'].shape[1],
r_min=r['r_min'], r_median=r['r_med'],
PC1_pct=r['ev'][0] * 100, PC2_pct=r['ev'][1] * 100,
PC3plus_pct=(1 - r['ev'][0] - r['ev'][1]) * 100,
parabola_R2=r['par']['r2']))
return pd.DataFrame(rows)
def dataset_logr_max(res):
"""Largest -log10(1-|r|) over every nucleus of a dataset (lower triangle of each R)."""
vm = 0.0
for r in res.values():
nm = len(r['methods']); m = nm - 1
sub = r['R'][1:, :-1]
hide = np.triu(np.ones((m, m), dtype=bool), k=1)
vm = max(vm, float(np.nanmax(np.where(hide, np.nan, log_r(sub)))))
return float(np.ceil(vm * 10) / 10)
# ----------------------------------------------------------------------------
# NS372 (Kaupp) loader
# ----------------------------------------------------------------------------
# (functional, tau-treatment, %HF, family, display label)
# one tau-variant per functional: tauC (Gaussian default) for tau-dependent
# meta-GGAs / hybrids, tauD for local hybrids. Match keys use real Unicode.
METHODS = [
('SVWN', None, 0, 'LDA', 'SVWN'),
('BLYP', None, 0, 'GGA', 'BLYP'), ('BP86', None, 0, 'GGA', 'BP86'),
('PBE', None, 0, 'GGA', 'PBE'), ('B97D', None, 0, 'GGA', 'B97D'),
('HCTH', None, 0, 'GGA', 'HCTH'),
('KT1', None, 0, 'GGA', 'KT1'), ('KT2', None, 0, 'GGA', 'KT2'), ('KT3', None, 0, 'GGA', 'KT3'),
('TPSS', 'τC', 0, 'mGGA', 'TPSS/τC'), ('τ-HCTH', 'τC', 0, 'mGGA', 'τ-HCTH/τC'),
('M06-L', 'τC', 0, 'mGGA', 'M06-L/τC'), ('VSXC', 'τC', 0, 'mGGA', 'VSXC/τC'),
('MN15-L', 'τC', 0, 'mGGA', 'MN15-L/τC'), ('B97M-V', 'τC', 0, 'mGGA', 'B97M-V/τC'),
('SCAN', 'τC', 0, 'mGGA', 'SCAN/τC'), ('rSCAN', 'τC', 0, 'mGGA', 'rSCAN/τC'),
('r2SCAN', 'τC', 0, 'mGGA', 'r2SCAN/τC'),
('TPSSh', 'τC', 10, 'GH', 'TPSSh/τC'), ('B3LYP', None, 20, 'GH', 'B3LYP'),
('B97-2', None, 21, 'GH', 'B97-2'), ('PBE0', None, 25, 'GH', 'PBE0'),
('M06', 'τC', 27, 'GH', 'M06/τC'), ('PW6B95', 'τC', 28, 'GH', 'PW6B95/τC'),
('MN15', 'τC', 44, 'GH', 'MN15/τC'), ('BHLYP', None, 50, 'GH', 'BHLYP'),
('M06-2X', 'τC', 54, 'GH', 'M06-2X/τC'),
('CAM-B3LYP', None, 65, 'RSH', 'CAM-B3LYP'),
('ωB97X-D', None, 100, 'RSH', 'ωB97X-D'), ('ωB97X-V', None, 100, 'RSH', 'ωB97X-V'),
('ωB97M-V', 'τC', 100, 'RSH', 'ωB97M-V/τC'),
('LH07s-SVWN', None, 22, 'LH', 'LH07s-SVWN'),
('MPSTS', 'τD', 30, 'LH', 'mPSTS/τD'),
('LHJ14', 'τD', 10, 'LH', 'LHJ14/τD'),
('LH07t-SVWN', 'τD', 48, 'LH', 'LH07t-SVWN/τD'),
('LH12ct-SsirPW92', 'τD', 65, 'LH', 'LH12ct-SsirPW92/τD'),
('LH12ct-SsifPW92', 'τD', 71, 'LH', 'LH12ct-SsifPW92/τD'),
('LH14t-calPBE', 'τD', 50, 'LH', 'LH14t-calPBE/τD'),
('LH20t', 'τD', 71, 'LH', 'LH20t/τD'),
('B2PLYP', None, 53, 'DH', 'B2PLYP'), ('B2GP-PLYP', None, 65, 'DH', 'B2GP-PLYP'),
('DSD-PBEP86', None, 70, 'DH', 'DSD-PBEP86'),
('Hartree-Fock', None, 100, 'WFT', 'HF'), ('MP2', None, 100, 'WFT', 'MP2'),
]
# (display nucleus, source sheet, in-sheet nucleus filter, molecules to drop)
NUCLEI = [
('1H', 'S6 - 1H Shieldings', None, ()),
('11B', 'S7 - 11B Shieldings', None, ('BH',)),
('13C', 'S8 - 13C Shieldings', None, ()),
('15N', 'S9 - 15N Shieldings', None, ()),
('17O', 'S10 - 17O Shieldings', None, ('O3',)),
('19F', 'S11 - 19F Shieldings', None, ('F3-',)),
('31P', 'S12 - 31P - 33S Shieldings', '31P', ()),
('33S', 'S12 - 31P - 33S Shieldings', '33S', ()),
]
def read_nucleus(wb, sheet, nuc_filter=None, exclude=()):
"""Read the CCSD(T) reference plus each method's shielding vector from one Kaupp SI sheet."""
ws = wb[sheet]
rows = []; last_nuc = None
for r in range(4, ws.max_row + 1):
v = ws.cell(r, 1).value
if v:
last_nuc = v
if isinstance(ws.cell(r, 4).value, (int, float)):
mol = str(ws.cell(r, 2).value or '').strip()
if mol in exclude:
continue
if nuc_filter is None or last_nuc == nuc_filter:
rows.append(r)
ref = np.array([ws.cell(r, 4).value for r in rows], dtype=float)
last_f = None; cols = []
for c in range(7, ws.max_column + 1):
h = ws.cell(1, c).value
if h is not None:
last_f = h
cols.append((c, last_f, ws.cell(2, c).value))
data = {}
for c, f, sub in cols:
if f is None:
continue
vals = []; ok = True
for r in rows:
v = ws.cell(r, c).value
if not isinstance(v, (int, float)):
ok = False; break
vals.append(v)
if not ok or (f, sub) in data:
continue
data[(f, sub)] = np.array(vals, dtype=float)
return ref, data
def load_ns372(path):
"""Load the NS372 (Kaupp) shielding matrix, sorted by family, for each of the 8 nuclei."""
wb = openpyxl.load_workbook(path, data_only=True)
out = {}
for nuc, sheet, filt, excl in NUCLEI:
ref, data = read_nucleus(wb, sheet, filt, excl)
used = [m for m in METHODS if (m[0], m[1]) in data]
cols = [data[(m[0], m[1])] for m in used]
labels = [m[4] for m in used] + ['CCSD(T)']
fams = [m[3] for m in used] + ['ref']
M = np.column_stack(cols + [ref])
M, labels, fams = sort_by_family(M, labels, fams)
out[nuc] = dict(M=M, methods=labels, ref=ref, families=fams)
return out
# ----------------------------------------------------------------------------
# main-text Figure 2D (convergence to the electronic-structure limit)
# ----------------------------------------------------------------------------
# The 1H / pcSseg-3 slice of the Kaupp SI, as a raw-named table, plus the two
# per-method statistics the 3D panel plots (slope and scaled RMSE vs CCSD(T)).
# These reuse the NS372 reader above; nothing new is read from disk.
def load_ns372_1h_convergence(path):
"""1H CCSD(T) reference plus each benchmarked functional's shieldings, as columns.
Tau policy differs from the SI S3 leveling analysis: Figure 2D uses the tauD kinetic-energy-density
treatment for every tau-dependent functional, whereas load_ns372/METHODS uses tauC for meta-GGAs.
So this selects tauD when present, otherwise the functional's single column, keyed by raw
functional name ('Hartree-Fock' renamed 'HF' for the labels).
"""
wb = openpyxl.load_workbook(path, data_only=True)
ref, data = read_nucleus(wb, 'S6 - 1H Shieldings')
names = {m[0] for m in METHODS} # the 44 benchmarked functionals
variants = {} # functional -> {tau: values}
for (func, tau), vals in data.items():
if func in names:
variants.setdefault(func, {})[tau] = vals
cols = {'CCSD(T)': ref}
for func, by_tau in variants.items():
vals = by_tau['τD'] if 'τD' in by_tau else next(iter(by_tau.values()))
cols['HF' if func == 'Hartree-Fock' else func] = vals
return pd.DataFrame(cols)
def convergence_statistics(cc_df, reference='CCSD(T)'):
"""Per method: the slope of method ~ a*CCSD(T) + b, and the scaled RMSE that Figure 2D plots.
The prediction is rescaled back into reference units, y_scaled = (method - b) / a, and compared
to CCSD(T); this equals the residual RMSE divided by the slope, so it differs from scaled_rmse,
which the S3 bar plot leaves in the method's own units. These are the x/y coordinates of the
Figure 2D points.
"""
ref = cc_df[reference].to_numpy(float)
rows = []
for method in cc_df.columns:
if method == reference:
continue
y = cc_df[method].to_numpy(float)
slope, intercept = np.polyfit(ref, y, 1)
y_scaled = (y - intercept) / slope
rows.append({'Method': method, 'Slope': slope,
'RMSE (scaled)': _rmse(ref, y_scaled)})
return pd.DataFrame(rows)
# ----------------------------------------------------------------------------
# delta22 loader (reads the released file; shieldings are decoded on read)
# ----------------------------------------------------------------------------
DELTA22_REF = 'DSD-PBEP86'
def load_delta22(path):
"""Load the delta22 shielding matrix, sorted by family, for 1H and 13C sites."""
with h5py.File(path, 'r') as f:
mn = [m.decode() for m in f['conventional_nmr_method_names'][:]]
order = ['hf', 'blyp_d3bj', 'bp86_d3bj', 'b97d3_d3bj', 'tpsstpss_d3bj',
'b3lyp_d3bj', 'pbe0_d3bj', 'm062x_d3', 'wb97xd', 'wp04', 'wc04',
'B2PLYP', 'mPW2PLYP', 'B2GP_PLYP', 'dsd_pbep86', 'revdsd_pbep86',
'mp2', 'dlpno_mp2']
disp = {'hf': 'HF', 'blyp_d3bj': 'BLYP-D3', 'bp86_d3bj': 'BP86-D3',
'b97d3_d3bj': 'B97D3', 'tpsstpss_d3bj': 'TPSS-D3', 'b3lyp_d3bj': 'B3LYP-D3',
'pbe0_d3bj': 'PBE0-D3', 'm062x_d3': 'M06-2X-D3', 'wb97xd': 'ωB97X-D',
'wp04': 'WP04', 'wc04': 'WC04', 'B2PLYP': 'B2PLYP', 'mPW2PLYP': 'mPW2PLYP',
'B2GP_PLYP': 'B2GP-PLYP', 'dsd_pbep86': 'DSD-PBEP86',
'revdsd_pbep86': 'revDSD-PBEP86', 'mp2': 'MP2', 'dlpno_mp2': 'DLPNO-MP2'}
fam = {'hf': 'WFT', 'blyp_d3bj': 'GGA', 'bp86_d3bj': 'GGA', 'b97d3_d3bj': 'GGA',
'tpsstpss_d3bj': 'mGGA', 'b3lyp_d3bj': 'GH', 'pbe0_d3bj': 'GH',
'm062x_d3': 'GH', 'wb97xd': 'RSH', 'wp04': 'GH', 'wc04': 'GH',
'B2PLYP': 'DH', 'mPW2PLYP': 'DH', 'B2GP_PLYP': 'DH', 'dsd_pbep86': 'DH',
'revdsd_pbep86': 'DH', 'mp2': 'WFT', 'dlpno_mp2': 'WFT'}
gas = {}
for i, m in enumerate(mn):
func, basis, smodel, _ = m.split(',')
if smodel == 'gas':
gas.setdefault(func, {})[basis] = i
sel, labels, families = [], [], []
for func in order:
b = gas[func]
big = 'pcSseg3' if 'pcSseg3' in b else ('pcSseg2' if 'pcSseg2' in b else 'pcSseg1')
sel.append(b[big]); labels.append(disp[func]); families.append(fam[func])
GEOM = 1 # geometries axis: 0 = AIMNet2, 1 = PBE0/cc-pVTZ
rows = {1: [], 6: []}
for s in f['solutes']:
an = f['solutes'][s]['atomic_numbers'][:]
cs = _decode_fixed_point(
f['solutes'][s]['stationary_and_pcm']['conventional_shieldings'][:, GEOM, :])
for ai, z in enumerate(an):
if z in (1, 6):
rows[int(z)].append(cs[sel, ai])
out = {}
for nuc, z in (('1H', 1), ('13C', 6)):
M = np.array(rows[z])
M, labs, fams = sort_by_family(M, list(labels), list(families))
# use DSD-PBEP86 as the reference vector for delta22 so the scaled RMSE
# bar plot mirrors NS372's CCSD(T) role
ref_idx = labs.index(DELTA22_REF)
out[nuc] = dict(M=M, methods=labs, ref=M[:, ref_idx], families=fams)
return out
|