From In-Silico to Wet-Lab: Evaluating AI Protein Design Performance Anthropic's claude-protein-binder-design dataset, containing 1,440 AI-designed miniprotein binders tested against 16 targets, was used in a tutorial to evaluate AI protein design performance, finding that structure predictors and combined predictions can help identify successful binders, with wet-lab results from two independent labs providing ground truth. In this tutorial, we use Anthropic’s claude-protein-binder-design https://huggingface.co/datasets/Anthropic/claude-protein-binder-design dataset, which contains 1,440 AI-designed miniprotein binders tested against 16 targets. Because the release includes both computational predictions and real wet-lab results from two independent labs, we can go beyond simply studying the designs. We evaluate how well structure predictors identify successful binders, whether combining predictions improves performance, how rankings translate into practical testing budgets, and how much disagreement comes from the assays themselves. Also, we train a target-aware classifier to test whether these signals can reliably predict experimental success. python import subprocess, sys, warnings, itertools, math warnings.filterwarnings "ignore" import importlib.util needed = {"huggingface hub": "huggingface hub =0.24", "pyarrow": "pyarrow", "pandas": "pandas", "sklearn": "scikit-learn", "matplotlib": "matplotlib", "scipy": "scipy"} missing = pkg for mod, pkg in needed.items if importlib.util.find spec mod is None if missing: print "installing:", ", ".join missing subprocess.run sys.executable, "-m", "pip", "install", "-q", missing , check=False import numpy as np import pandas as pd import matplotlib.pyplot as plt from scipy import stats from huggingface hub import HfApi, hf hub download from sklearn.metrics import roc auc score, cohen kappa score, average precision score from sklearn.model selection import GroupKFold, StratifiedKFold from sklearn.ensemble import HistGradientBoostingClassifier from sklearn.inspection import permutation importance SEED = 0 rng global = np.random.default rng SEED pd.set option "display.width", 200 pd.set option "display.max columns", 100 plt.rcParams.update {"figure.dpi": 110, "font.size": 9, "axes.grid": True, "grid.alpha": 0.25, "axes.spines.top": False, "axes.spines.right": False} REPO = "Anthropic/claude-protein-binder-design" BAR = "=" 78 def head n, title : prefix = f"{n}. " if str n else "" print f"\n{BAR}\n {prefix}{title}\n{BAR}" head 1, "TABLE DISCOVERY" api = HfApi repo files = api.list repo files REPO, repo type="dataset" TABLES = {} for f in repo files: if f.startswith "data/tables/" and f.endswith ".parquet" : key = f len "data/tables/" : -len ".parquet" .replace "/", " " TABLES key = f print f"Found {len TABLES } Parquet tables:" for k in sorted TABLES : print f" - {k:38s} {TABLES k }" def load table name: str - pd.DataFrame: """Load a subset by its viewer name, with a datasets-library fallback.""" if name in TABLES: return pd.read parquet hf hub download REPO, TABLES name , repo type="dataset" from datasets import load dataset return load dataset REPO, name, split="full" .to pandas ds = load table "design summary" print f"\ndesign summary: {ds.shape 0 :,} rows x {ds.shape 1 } columns" We start by installing only what the runtime is actually missing, then enumerate the repository once and build a {subset to path} map instead of hard-coding file locations. This matters because the naming is not uniform; the subset wetlab summary lives at data/tables/wetlab/summary.parquet, and a guessed path would fail silently. With the map in place we pull design summary, one row per design, 1,440 rows wide enough to carry every join we need downstream. head 2, "SCHEMA + EVALUABLE SET" CALLS = {"binder", "non binder"} tested = ds "adaptyv binding" .isin CALLS | ds "twist binding" .isin CALLS ev = ds tested .copy ev "y" = ev "binder final" .astype int print f"All designs : {len ds :,}" print f"Evaluable =1 vendor call : {len ev :,}" print f"Confirmed binders : {int ev 'y' .sum :,} " f" {100 ev 'y' .mean :.1f}% base rate " print f"Never measured : {len ds - len ev :,}" print "\nCategorical levels:" for c in "design model", "campaign", "generator", "sequence design method", "vendor agreement" : vals = ds c .astype str .value counts print f" {c:24s} {len vals } : {', '.join vals.index :6 }" + " ..." if len vals 6 else "" print f"\nTargets {ds 'target' .nunique } : {', '.join sorted ds 'target' .unique }" print f"Binder length: {ds.binder length.min }-{ds.binder length.max } aa " f" median {ds.binder length.median :.0f} " head 3, "HIT-RATE LANDSCAPE" def wilson k, n, z=1.96 : if n == 0: return np.nan, np.nan, np.nan p = k / n d = 1 + z 2 / n c = p + z 2 / 2 n / d h = z math.sqrt p 1 - p / n + z 2 / 4 n 2 / d return p, max 0.0, c - h , min 1.0, c + h def rate table df, by : rows = for key, g in df.groupby by, dropna=False : p, lo, hi = wilson int g.y.sum , len g rows.append {by: key, "n": len g , "hits": int g.y.sum , "rate": p, "lo": lo, "hi": hi} return pd.DataFrame rows .sort values "rate", ascending=False .reset index drop=True for dim in "design model", "campaign", "generator", "sequence design method" : t = rate table ev, dim print f"\n--- hit rate by {dim} ---" print t.to string index=False, formatters={"rate": "{:.3f}".format, "lo": "{:.3f}".format, "hi": "{:.3f}".format} tt = rate table ev, "target" fig, ax = plt.subplots figsize= 9, 4.2 ax.bar tt.target, tt.rate, color=" 4C72B0" ax.errorbar tt.target, tt.rate, yerr= tt.rate - tt.lo .clip lower=0 , tt.hi - tt.rate .clip lower=0 , fmt="none", ecolor="0.25", capsize=3, lw=1 ax.axhline ev.y.mean , ls="--", c="crimson", lw=1, label=f"pooled {ev.y.mean :.2f}" ax.set ylabel "experimental hit rate" ; ax.set title "Hit rate by target Wilson 95% CI " ax.tick params axis="x", rotation=55 ; ax.legend ; plt.tight layout ; plt.show print "\nRead this plot as the dominant effect size in the dataset: target choice " "swamps generator choice. Any model comparison that does not stratify by " "target is mostly measuring which targets that model was pointed at." We define the evaluable set by filtering on actual vendor calls rather than on binder final, because that column is a bool and so records the 120 never-measured designs as False rather than missing. From there we compute hit rates by model, campaign, generator, and target, wrapping each in a Wilson interval since several subgroups sit in the small-n regime where the normal approximation misbehaves. The target plot is the one to read first: it shows antigen choice swamping every other factor we compare. head 4, "PER-PREDICTOR DISCRIMINATIVE POWER" PREDICTORS = sorted {c len "ipsae min " : for c in ds.columns if c.startswith "ipsae min " } print f"Predictors {len PREDICTORS } : {', '.join PREDICTORS }" def auc ci y, s, n boot=300, seed=SEED : s = np.asarray s, dtype=float ; y = np.asarray y, dtype=int m = ~np.isnan s y, s = y m , s m if len y < 30 or len np.unique y < 2: return dict auc=np.nan, lo=np.nan, hi=np.nan, n=len y , ap=np.nan base = roc auc score y, s ap = average precision score y, s rng = np.random.default rng seed idx, boots = np.arange len y , for in range n boot : b = rng.choice idx, len idx , replace=True if len np.unique y b 1: boots.append roc auc score y b , s b lo, hi = np.percentile boots, 2.5, 97.5 if boots else np.nan, np.nan return dict auc=base, lo=lo, hi=hi, n=len y , ap=ap rows = for p in PREDICTORS: for metric in "ipsae min", "sc dockq" : col = f"{metric} {p}" if col in ev.columns: r = auc ci ev.y, ev col rows.append {"predictor": p, "metric": metric, r} perf = pd.DataFrame rows piv = perf.pivot index="predictor", columns="metric", values="auc" .sort values "ipsae min", ascending=False print "\nAUC vs experimental binder final:" print perf.sort values "auc", ascending=False .to string index=False, formatters={c: "{:.3f}".format for c in "auc", "lo", "hi", "ap" } fig, ax = plt.subplots figsize= 9, 4.2 x = np.arange len piv ; w = 0.38 for i, metric, colr in enumerate "ipsae min", " 4C72B0" , "sc dockq", " DD8452" : sub = perf perf.metric == metric .set index "predictor" .reindex piv.index lo err = sub.auc - sub.lo .clip lower=0 .fillna 0 hi err = sub.hi - sub.auc .clip lower=0 .fillna 0 ax.bar x + i - 0.5 w, sub.auc, w, label=metric, color=colr ax.errorbar x + i - 0.5 w, sub.auc, yerr= lo err, hi err , fmt="none", ecolor="0.3", capsize=2, lw=0.9 ax.axhline 0.5, ls="--", c="crimson", lw=1 ax.set xticks x ; ax.set xticklabels piv.index, rotation=45, ha="right" ax.set ylabel "AUC" ; ax.set ylim 0.35, None ax.set title "In-silico score vs wet-lab binding, by structure predictor" ax.legend ; plt.tight layout ; plt.show print "Interpretation: AUCs land well above chance but far below the ~0.9 you " "would need to trust a single filter. That gap is the entire practical " "reason this dataset exists." head 5, "CONSENSUS SCORING" ips cols = f"ipsae min {p}" for p in PREDICTORS if f"ipsae min {p}" in ev.columns dq cols = f"sc dockq {p}" for p in PREDICTORS if f"sc dockq {p}" in ev.columns def pct rank df, cols : return df cols .rank pct=True, na option="keep" R ips, R dq = pct rank ev, ips cols , pct rank ev, dq cols ev "cons ipsae" = R ips.mean axis=1 ev "cons dockq" = R dq.mean axis=1 ev "cons all" = pd.concat R ips, R dq , axis=1 .mean axis=1 ev "cons median" = pd.concat R ips, R dq , axis=1 .median axis=1 ev "cons min" = pd.concat R ips, R dq , axis=1 .min axis=1 ev "cons disagree" = pd.concat R ips, R dq , axis=1 .std axis=1 best single = perf.loc perf.auc.idxmax print f"Best single column: {best single.metric} {best single.predictor} AUC={best single.auc:.3f}" print for name in "cons ipsae", "cons dockq", "cons all", "cons median", "cons min", "cons disagree" : r = auc ci ev.y, ev name print f" {name:16s} AUC={r 'auc' :.3f} {r 'lo' :.3f}, {r 'hi' :.3f} AP={r 'ap' :.3f}" corr = ev ips cols .corr method="spearman" fig, ax = plt.subplots figsize= 6.2, 5.2 im = ax.imshow corr.values, cmap="viridis", vmin=0, vmax=1 lbl = c.replace "ipsae min ", "" for c in ips cols ax.set xticks range len lbl ; ax.set xticklabels lbl, rotation=90 ax.set yticks range len lbl ; ax.set yticklabels lbl ax.set title "Spearman correlation between predictors ipSAE " ax.grid False ; fig.colorbar im, shrink=0.8 ; plt.tight layout ; plt.show print "\nIf every off-diagonal cell were ~1.0 there would be no ensemble gain to " "harvest. The moderate correlations are why cons all typically edges out " "the best single predictor — and why disagreement itself carries signal." We score all ten predictors against the wet-lab label, on both ipSAE and self-consistency DockQ, with bootstrapped confidence intervals so we can see which differences are real. We then rank-normalize each column to percentiles and aggregate them, which keeps the comparison scale-free across metrics that live on different ranges and pile up differently at zero. The Spearman heatmap explains why the ensemble helps at all; if the predictors agreed perfectly there would be nothing left to harvest. python head 6, "BUDGET CURVES precision@N " def budget curve df, score col, max n=400 : d = df score col, "y" .dropna .sort values score col, ascending=False hits = d.y.values.cumsum n = np.arange 1, len d + 1 k = min max n, len d return n :k , hits / n :k fig, ax = plt.subplots figsize= 8, 4.4 best col = f"{best single.metric} {best single.predictor}" for col, lab, style in best col, f"best single {best col} ", "-" , "cons all", "consensus rank-avg, all ", "-" , "cons min", "consensus unanimity/min ", "--" : n, prec = budget curve ev, col ax.plot n, prec, style, lw=1.8, label=lab ax.axhline ev.y.mean , ls=":", c="crimson", lw=1.4, label=f"random baseline {ev.y.mean :.2f} " ax.set xlabel "designs ordered for wet-lab testing N, best-first " ax.set ylabel "hit rate among top N" ; ax.set title "How much does in-silico triage buy you?" ax.legend ; plt.tight layout ; plt.show print "Enrichment at small budgets:" for N in 25, 50, 100, 200 : line = f" N={N:4d} | random {ev.y.mean :.3f}" for col, lab in best col, "best-single" , "cons all", "consensus" : n, prec = budget curve ev, col, max n=N line += f" | {lab} {prec -1 :.3f} {prec -1 / ev.y.mean :.2f}x " print line head 7, "VENDOR CONCORDANCE" both = ev ev.adaptyv binding.isin CALLS & ev.twist binding.isin CALLS ct = pd.crosstab both.adaptyv binding, both.twist binding print f"Designs with calls from BOTH vendors: {len both :,}\n" print ct.to string if len both 10: kappa = cohen kappa score both.adaptyv binding, both.twist binding agree = both.adaptyv binding == both.twist binding .mean print f"\nRaw agreement: {agree:.3f} Cohen's kappa: {kappa:.3f}" print "Kappa well under 1.0 means part of the 'unpredictable' variance above " "is assay disagreement, not model failure." kd = ev "adaptyv kd nM", "twist kd nM" .dropna kd = kd kd 0 .all axis=1 if len kd 10: rho, pv = stats.spearmanr kd.adaptyv kd nM, kd.twist kd nM fig, ax = plt.subplots figsize= 4.8, 4.6 ax.scatter kd.adaptyv kd nM, kd.twist kd nM, s=16, alpha=0.6, c=" 4C72B0", edgecolor="none" lims = min kd.min 0.5, max kd.max 2 ax.plot lims, lims, "k--", lw=1 ax.set xscale "log" ; ax.set yscale "log" ax.set xlabel "Adaptyv KD nM " ; ax.set ylabel "Twist KD nM " ax.set title f"Cross-vendor KD, n={len kd }, Spearman rho={rho:.2f}" plt.tight layout ; plt.show med ratio = np.median kd.twist kd nM / kd.adaptyv kd nM print f"Median KD ratio Twist/Adaptyv : {med ratio:.2f}x - systematic format offset, " "so treat absolute KD across vendors as ordinal, not interchangeable." We convert ranking performance into precision@N, because no lab orders 1,300 constructs and AUC quietly hides how a score behaves at the top of the list. The enrichment table then tells us what triage actually buys at budgets of 25, 50, 100, and 200. We follow it with Cohen’s κ and a log-log KD comparison between vendors, which sets the ceiling: label noise bounds how high any AUC above can honestly climb. head 8, "EXPRESSION CONFOUND" if "twist expression mg per mL" in ev.columns: g = ev.dropna subset= "twist expression mg per mL" a = g.loc g.y == 1, "twist expression mg per mL" b = g.loc g.y == 0, "twist expression mg per mL" if len a 5 and len b 5: u, pv = stats.mannwhitneyu a, b print f"Titer mg/mL binders median {a.median :.2f} n={len a } | " f"non-binders {b.median :.2f} n={len b } Mann-Whitney p={pv:.2e}" r = auc ci g.y, g.twist expression mg per mL print f"AUC of raw expression titer alone as a 'binder' predictor: {r 'auc' :.3f}" fig, axes = plt.subplots 1, 2, figsize= 9, 3.6 axes 0 .hist b, a , bins=25, label= "non-binder", "binder" , color= " BBBBBB", " 4C72B0" , density=True axes 0 .set xlabel "Twist titer mg/mL " ; axes 0 .set ylabel "density" ; axes 0 .legend axes 0 .set title "Expression by outcome" if "adaptyv expression" in ev.columns: ex = ev.groupby ev.adaptyv expression.astype str .y.agg "mean", "size" ex = ex ex "size" = 10 .sort values "mean" axes 1 .barh ex.index, ex "mean" , color=" DD8452" axes 1 .set xlabel "hit rate" ; axes 1 .set title "Hit rate by Adaptyv expression class" plt.tight layout ; plt.show print "\nTakeaway: if expression alone scores meaningfully above 0.5, then part of " "every AUC in section 4 is a solubility signal riding along. To isolate " "interface quality, re-run section 4 restricted to designs that expressed." expressed = ev ev.adaptyv expression.astype str .isin "medium", "high" if "adaptyv expression" in ev.columns else ev if len expressed 100: r all = auc ci ev.y, ev.cons all r exp = auc ci expressed.y, expressed.cons all print f" consensus AUC, all evaluable : {r all 'auc' :.3f} n={r all 'n' } " print f" consensus AUC, expressed only : {r exp 'auc' :.3f} n={r exp 'n' } " head 9, "EPITOPE CONVERGENCE" def parse epitope s : if not isinstance s, str or not s.strip : return frozenset out = set for tok in s.split ";" : tok = tok.strip if not tok: continue out.add tok.split ":" -1 return frozenset out ev "epi" = ev "epitope residues" .apply parse epitope def mean pairwise jaccard sets, max pairs=4000, seed=SEED : sets = s for s in sets if len s 0 if len sets < 2: return np.nan pairs = list itertools.combinations range len sets , 2 rng = np.random.default rng seed if len pairs max pairs: pairs = pairs i for i in rng.choice len pairs , max pairs, replace=False vals = for i, j in pairs: u = len sets i | sets j vals.append len sets i & sets j / u if u else 0.0 return float np.mean vals rows = for tgt, g in ev.groupby "target" : B = g.loc g.y == 1, "epi" .tolist N = g.loc g.y == 0, "epi" .tolist if len B = 3 and len N = 3: rows.append {"target": tgt, "n bind": len B , "n non": len N , "J binders": mean pairwise jaccard B , "J nonbinders": mean pairwise jaccard N } epi = pd.DataFrame rows if len epi : epi "delta" = epi.J binders - epi.J nonbinders print epi.sort values "delta", ascending=False .to string index=False, formatters={c: "{:.3f}".format for c in "J binders", "J nonbinders", "delta" } w = stats.wilcoxon epi.J binders, epi.J nonbinders if len epi = 6 else None if w: print f"\nPaired Wilcoxon across targets: p={w.pvalue:.4f} " f" binders more epitope-convergent than failures? " tgt = epi.sort values "n bind", ascending=False .target.iloc 0 sub = ev ev.target == tgt freq b = pd.Series r for s in sub sub.y == 1 .epi for r in s .value counts freq n = pd.Series r for s in sub sub.y == 0 .epi for r in s .value counts top = freq b.head 18 .index fig, ax = plt.subplots figsize= 9, 3.8 xx = np.arange len top ax.bar xx - 0.2, freq b.reindex top .fillna 0 / max 1, sub.y == 1 .sum , 0.4, label="binders", color=" 4C72B0" ax.bar xx + 0.2, freq n.reindex top .fillna 0 / max 1, sub.y == 0 .sum , 0.4, label="non-binders", color=" BBBBBB" ax.set xticks xx ; ax.set xticklabels top, rotation=70, ha="right" ax.set ylabel "fraction of designs contacting" ; ax.set title f"Epitope usage on {tgt}" ax.legend ; plt.tight layout ; plt.show We test whether expression titer alone discriminates binders, and if it does, we know part of every score from above is solubility riding along under another name. Re-running consensus on expressed-only designs isolates interface quality from biophysics. We then parse the epitope contact lists into residue sets and ask, per target and paired across targets, whether confirmed binders converge on a shared patch more than the failures do. head 10, "MODELLING WITH HONEST CROSS-VALIDATION" AAS = "ACDEFGHIKLMNPQRSTVWY" KD HYDRO = dict zip AAS, 1.8, 2.5, -3.5, -3.5, 2.8, -0.4, -3.2, 4.5, -3.9, 3.8, 1.9, -3.5, -1.6, -3.5, -4.5, -0.8, -0.7, 4.2, -0.9, -1.3 CHARGE = {"K": 1, "R": 1, "H": 0.1, "D": -1, "E": -1} def seq features seq : seq = "".join ch for ch in str seq .upper if ch in AAS L = max 1, len seq counts = {a: seq.count a / L for a in AAS} f = {f"aa {a}": counts a for a in AAS} f "length" = len seq f "net charge" = sum CHARGE.get c, 0 for c in seq f "charge density" = f "net charge" / L f "gravy" = float np.mean KD HYDRO c for c in seq if seq else 0.0 f "aromatic" = sum counts a for a in "FWY" f "helix prone" = sum counts a for a in "AELM" f "beta prone" = sum counts a for a in "VIYFT" f "gly pro" = counts "G" + counts "P" p = np.array counts a for a in AAS ; p = p p 0 f "entropy" = float - p np.log2 p .sum run, best = 0, 0 for c in seq: run = run + 1 if KD HYDRO c 1.5 else 0 best = max best, run f "max hydrophobic run" = best return f SF = pd.DataFrame seq features s for s in ev.sequence , index=ev.index seq cols = list SF.columns sil cols = c for c in ev.columns if c.startswith "ipsae min ", "sc dockq " + \ "cons all", "cons min", "cons disagree" meta cols = c for c in "rank", "n optimization rounds", "epitope n residues" if c in ev.columns X all = pd.concat ev sil cols + meta cols , SF , axis=1 y = ev.y.values groups = ev.target.values FEATURE SETS = { "in-silico only": sil cols + meta cols, "sequence only": seq cols, "in-silico + sequence": sil cols + meta cols + seq cols, } def cv auc X, y, splitter, groups=None : aucs = it = splitter.split X, y, groups if groups is not None else splitter.split X, y for tr, te in it: if len np.unique y te < 2: continue m = HistGradientBoostingClassifier max depth=4, max iter=250, learning rate=0.06, random state=SEED m.fit X.iloc tr , y tr aucs.append roc auc score y te , m.predict proba X.iloc te :, 1 return float np.mean aucs , float np.std aucs , len aucs print f"{'feature set':24s} {'random 5-fold': 18s} {'grouped-by-target': 20s}" print "-" 66 results = {} for name, cols in FEATURE SETS.items : X = X all cols r mean, r sd, = cv auc X, y, StratifiedKFold 5, shuffle=True, random state=SEED g mean, g sd, nf = cv auc X, y, GroupKFold n splits=5 , groups=groups results name = r mean, g mean print f"{name:24s} {r mean:.3f} +/- {r sd:.3f} {g mean:.3f} +/- {g sd:.3f}" gap = results "in-silico + sequence" 0 - results "in-silico + sequence" 1 print f"\nRandom-CV minus grouped-CV for the full feature set: {gap:+.3f}" print "That gap is leakage: features that encode target identity epitope size, " "length priors, generator habits let a randomly-split model recover the " "per-target base rate instead of learning what makes a binder. Report the " "grouped number; the random one is what a target-blind reviewer will catch." Xt = pd.get dummies pd.Series groups, index=ev.index , prefix="tgt" r mean, , = cv auc Xt, y, StratifiedKFold 5, shuffle=True, random state=SEED print f"\nControl - target one-hot ONLY, random CV: AUC={r mean:.3f} " " pure base-rate memorisation, zero design signal ." gkf = GroupKFold n splits=5 tr, te = next iter gkf.split X all, y, groups model = HistGradientBoostingClassifier max depth=4, max iter=250, learning rate=0.06, random state=SEED .fit X all FEATURE SETS "in-silico + sequence" .iloc tr , y tr imp = permutation importance model, X all FEATURE SETS "in-silico + sequence" .iloc te , y te , n repeats=12, random state=SEED, scoring="roc auc" order = np.argsort imp.importances mean -18: names = np.array FEATURE SETS "in-silico + sequence" order fig, ax = plt.subplots figsize= 7, 5 ax.barh names, imp.importances mean order , xerr=imp.importances std order , color=" 55A868" ax.set xlabel "drop in AUC when permuted" ax.set title "Permutation importance held-out target block " plt.tight layout ; plt.show head "", "SUMMARY" print f""" Evaluable designs : {len ev :,} base hit rate {ev.y.mean :.3f} Best single in-silico : {best col} AUC {best single.auc:.3f} Rank-average consensus : AUC {auc ci ev.y, ev.cons all 'auc' :.3f} Honest ML grouped CV : AUC {results 'in-silico + sequence' 1 :.3f} <- the one to report Same model, random CV : AUC {results 'in-silico + sequence' 0 :.3f} gap = {gap:+.3f} leakage Five things this dataset teaches that a design paper usually cannot: 1. Target identity dominates every other factor; always stratify. 2. Structure-predictor confidence is real but weak signal AUC ~0.6-0.75 , nowhere near a standalone go/no-go filter. 3. Ensembling across predictors is a cheap, reliable few-points-of-AUC win. 4. Cross-vendor label noise caps how high any AUC here can honestly go. 5. Expression failure masquerades as binding failure. Condition on it. Extensions worth trying: - load table 'insilico cofold predictions' for all 5 seeds/predictor, and test whether seed VARIANCE beats seed-best as a confidence signal - load table 'adaptyv fit curves' to refit kinetics yourself and flag designs whose reported KD rests on a poorly-conditioned fit - load table 'insilico provenance steps' to relate optimisation-round count to eventual success - snapshot download ..., allow patterns='data/designs/EGFR/