ChEMBL、RDKit、SHAP、BRICS を活用した EGFR 阻害剤発見のためのスクフォールド分割ランダムフォレスト QSAR コーサイエンティストの構築
本チュートリアルは、ChEMBL データと RDKit を活用して EGFR 阻害剤の発見を自動化するエンドツーエンドの AI コサイエンティストワークフローを実装し、BRICS による分子再結合で新規候補薬を生成・評価する実用的な手法を示している。
キーポイント
自律的なデータ前処理と特徴量抽出
ChEMBL と UniProt から EGFR ターゲットのデータを取得し、RDKit を用いて分子の標準化、塩の除去、Morgan フィンガープリント計算などを行い、化学的に意味のあるモデル学習データを構築する。
スキャフォールド分割による頑健な QSAR モデル
ランダム森林アルゴリズムを用いてスキャフォールド分割(Scaffold-split)で訓練し、未見の化学構造体に対する一般化能力を評価することで、過学習を防ぐ堅牢な予測モデルを構築する。
SHAP による解釈可能性と特徴量解析
SHAP(SHapley Additive exPlanations)やモデル重要度を用いて活性に寄与する分子サブ構造を可視化し、ブラックボックス化された予測の根拠を化学的に解釈可能にする。
BRICS による生成設計と候補薬スクリーニング
強力な活性化合物から BRICS フラグメントを再結合して仮想アナログを生成し、活性、ドラッグライクネス、合成可能性、新規性などの多重ゲートで評価・選別する。
自動ターゲット解決とデータ量に基づく選定
ユーザー入力のクエリ(EGFR)から、ヒト単一タンパク質かつIC50データが豊富なChEMBL IDを自動的に特定し、CHEMBL203を最終ターゲットとして選定します。
C797S変異に対する次世代阻害剤の設計目標
既存のオシメルチニブが効かないC797S変異という耐性文脈を特定し、共有結合アニーカ(covalent anchor)に依存しない新規な4世代EGFR阻害剤の創出を目指します。
ChEMBLとUniProtの連携による文脈構築
ChEMBLからターゲットIDを取得し、UniProt APIを通じてタンパク質の機能情報を取得することで、薬物設計の生物学的背景を自動的に補完します。
重要な引用
In this tutorial, we build an end-to-end autonomous AI co-scientist workflow for next-generation EGFR inhibitor discovery
train a scaffold-split Random Forest QSAR model, evaluate its ability to generalize to unseen chemotypes
interpret potency-driving features with SHAP or model importances
recombining BRICS fragments from potent drug-like actives
"Goal of this run: learn the chemistry of known EGFR inhibitors and propose NOVEL, drug-like analogs as starting points for a C797S-active 4th-generation series."
"Resistance context: 1st/2nd/3rd-gen EGFR TKIs lose potency once tumours acquire the C797S mutation, which abolishes the covalent cysteine anchor exploited by osimertinib."
影響分析・編集コメントを表示
影響分析
この記事は、単なる予測モデルの構築にとどまらず、データ収集から分子生成、評価に至るまでを自動化した「AI コサイエンティスト」の実装例を提供しており、創薬現場における AI ツールの実用化への道筋を示しています。特にスキャフォールド分割と BRICS 再結合を組み合わせた手法は、化学的合理性を保ちつつ新規性を生み出すための標準的なアプローチとして業界に広く影響を与える可能性があります。
編集コメント
本記事は、理論的な枠組みだけでなく、具体的な Python コードとライブラリ(ChEMBL, RDKit, SHAP)を用いた実装例を提供しており、創薬研究者が即座に適用可能な価値の高い技術ドキュメントです。
本チュートリアルでは、非小細胞肺癌における C797S オシメルチニブ耐性変異に焦点を当てた、次世代 EGFR 阻害剤発見のためのエンドツーエンドの自律型 AI コーサイエンティストワークフローを構築します。まず ChEMBL と UniProt を用いて生物学的標的を特定し、キュレーション済みの EGFR IC50 生体活性レコードを検索して、クリーンな pIC50 モデリングデータセットに変換します。RDKit を使用して分子の標準化、塩の除去、反復測定の集約、Morgan ファインガープリント(Morgan fingerprints)の計算、物性記述子の抽出、および scaffold 多様性の解析を行い、モデルが生化学的に意味のある表現から学習し、生文字列から学習しないようにします。その後、scaffold-split ランダムフォレスト QSAR モデルを訓練し、見えない化学構造への一般化能力を評価し、SHAP やモデル重要度を用いて活性に寄与する特徴を解釈し、影響力のある分子サブストラクチャを可視化します。最後に、予測を超えて生成設計へと移行し、強力なドラッグライク活性物質から BRICS 断片を再結合して、得られた仮想類似体の活性、ドラッグライクネス、合成可能性、新規性、および開発可能性の各ゲートをスコアリングし、選抜された候補を PubChem でクロスチェックします。
EGFR ターゲット設定
コードをコピーしました。別のブラウザを使用してください
import sys, subprocess, importlib, warnings, time, os, random, json
warnings.filterwarnings("ignore")
def _pip(*pkgs):
subprocess.run([sys.executable, "-m", "pip", "install", "-q", *pkgs], check=False)
for mod, pkg in [("rdkit", "rdkit"), ("shap", "shap"), ("requests", "requests")]:
try:
importlib.import_module(mod)
except Exception:
print(f"Installing {pkg} ...")
_pip(pkg)
import numpy as np
import pandas as pd
import requests
import matplotlib.pyplot as plt
from scipy.stats import spearmanr
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score, mean_squared_error, roc_auc_score
from sklearn.decomposition import PCA
from rdkit import Chem, DataStructs, RDLogger
from rdkit.Chem import Descriptors, Draw, QED, rdMolDescriptors, BRICS, rdFingerprintGenerator
from rdkit.Chem.Scaffolds import MurckoScaffold
RDLogger.DisableLog("rdApp.*")
try:
from rdkit.Chem.MolStandardize import rdMolStandardize
_HAS_STD = True
except Exception:
_HAS_STD = False
try:
from rdkit.Chem import RDConfig
sys.path.append(os.path.join(RDConfig.RDContribDir, "SA_Score"))
import sascorer
_HAS_SA = True
except Exception:
_HAS_SA = False
TARGET_CHEMBL_ID = "CHEMBL203"
TARGET_QUERY = "Epidermal growth factor receptor"
FALLBACK_CHEMBL_ID = "CHEMBL203"
NBITS, RADIUS = 2048, 2
RANDOM_STATE = 42
MAX_ACTIVITIES = 9000
MAX_UNIQUE = 4000
ACTIVE_PIC50 = 7.0
BRICS_MAX_TRIES = 4000
N_FRAG_PARENTS = 60
N_SHORTLIST = 12
np.random.seed(RANDOM_STATE); random.seed(RANDOM_STATE)
BASE = "https://www.ebi.ac.uk/chembl/api/data"
HDRS = {"Accept": "application/json", "User-Agent": "ai-coscientist-tutorial/1.0"}
def banner(title):
print("\n" + "=" * 86 + f"\n {title}\n" + "=" * 86)
def http_json(url, params=None, tries=3, timeout=45):
for k in range(tries):
try:
r = requests.get(url, params=params, headers=HDRS, timeout=timeout)
if r.status_code == 200:
return r.json()
if r.status_code == 404:
return None
except Exception:
pass
time.sleep(1.5 * (k + 1))
return None
banner("[1/9] TARGET INTELLIGENCE (ChEMBL + UniProt)")
print("Question: What target are we drugging, and why is it hard?\n")
def ic50_count(tid):
js = http_json(f"{BASE}/activity", {"target_chembl_id": tid, "standard_type": "IC50",
"pchembl_value__isnull": "false", "limit": 1, "format": "json"})
try:
return int(js["page_meta"]["total_count"])
except Exception:
return 0
target_id, target_name, uniprot_acc = None, TARGET_QUERY, None
if TARGET_CHEMBL_ID:
target_id = TARGET_CHEMBL_ID
else:
srch = http_json(f"{BASE}/target/search", {"q": TARGET_QUERY, "format": "json"})
cands = []
if srch:
for t in srch.get("targets", []):
if t.get("organism") == "Homo sapiens" and t.get("target_type") == "SINGLE PROTEIN":
cands.append(t)
cands = sorted(cands, key=lambda t: float(t.get("score", 0)), reverse=True)[:8]
scored = [(t, ic50_count(t["target_chembl_id"])) for t in cands]
scored = [(t, n) for t, n in scored if n > 0]
if scored:
best = max(scored, key=lambda x: x[1])[0]
target_id, target_name = best["target_chembl_id"], best.get("pref_name", TARGET_QUERY)
print(f" Auto-resolved '{TARGET_QUERY}' by data volume -> {target_id}")
else:
target_id = FALLBACK_CHEMBL_ID
print(f" Auto-resolve found no data; falling back to {FALLBACK_CHEMBL_ID}")
det = http_json(f"{BASE}/target/{target_id}", {"format": "json"})
if det and det.get("pref_name"):
target_name = det["pref_name"]
if det:
for comp in det.get("target_components", []):
if comp.get("accession"):
uniprot_acc = comp["accession"]; break
print(f" Resolved target : {target_name}")
print(f" ChEMBL ID : {target_id}")
print(f" UniProt : {uniprot_acc}")
if uniprot_acc:
uni = http_json(f"https://rest.uniprot.org/uniprotkb/{uniprot_acc}.json")
if uni:
try:
fn = next(c["texts"][0]["value"] for c in uni.get("comments", [])
if c.get("commentType") == "FUNCTION")
print("\n Function (UniProt):")
print(" ", (fn[:340] + " ...") if len(fn) > 340 else fn)
except Exception:
pass
print("""
Resistance context: 1st/2nd/3rd-gen EGFR TKIs lose potency once tumours acquire the
C797S mutation, which abolishes the covalent cysteine anchor exploited by osimertinib.
Goal of this run: learn the chemistry of known EGFR inhibitors and propose NOVEL,
drug-like analogs as starting points for a C797S-active 4th-generation series.""")
まず、ワークフローに必要な化学・モデリング・プロット・API の依存関係をすべて備えた完全な科学計算環境を準備し、不足しているライブラリをインストールします。EGFR ターゲットの設定を構成し、モデリング定数を定義して再現可能な乱数シードを初期化し、バナー表示や堅牢な JSON API 呼び出しのためのヘルパー関数を作成します。その後、ChEMBL のターゲット情報を取得し、利用可能な場合は UniProt コンテキストも参照しつつ、EGFR C797S 耐性を中心に生物学的動機付けを構築します。
ChEMBL のバイオアッセイデータマイニング
コードをコピーしました(コピー済み)
異なるブラウザを使用してください
banner("[2/9] BIOACTIVITY MINING (ChEMBL activities -> pIC50)")
def pull_activities(tid, cap):
url, rows = f"{BASE}/activity", []
params = {"target_chembl_id": tid, "standard_type": "IC50",
"pchembl_value__isnull": "false", "limit": 1000, "format": "json"}
js = http_json(url, params)
pages = 0
while js and pages < 60:
rows.extend(js.get("activities", []))
pages += 1
if len(rows) >= cap:
break
nxt = js.get("page_meta", {}).get("next")
if not nxt:
break
nurl = nxt if nxt.startswith("http") else "https://www.ebi.ac.uk" + nxt
js = http_json(nurl)
return rows[:cap]
raw = pull_activities(target_id, MAX_ACTIVITIES)
print(f" Pulled {len(raw)} raw IC50 records with a curated pChEMBL value.")
recs = []
for a in raw:
smi, pv = a.get("canonical_smiles"), a.get("pchembl_value")
if not smi or pv in (None, ""):
continue
if a.get("standard_relation") != "=":
continue
if a.get("standard_units") not in ("nM", None):
continue
try:
pv = float(pv)
except Exception:
continue
recs.append({"chembl_id": a.get("molecule_chembl_id"), "smiles": smi, "pIC50": pv})
raw_df = pd.DataFrame(recs, columns=["chembl_id", "smiles", "pIC50"])
print(f" After quality filters: {len(raw_df)} measurements.")
if len(raw_df) == 0:
print("\n STOP: no usable IC50 data was retrieved for this target.\n"
" Fix: set TARGET_CHEMBL_ID to a target that has inhibitor data\n"
" (e.g. CHEMBL203=EGFR, CHEMBL5251=BTK, CHEMBL2971=JAK2),\n"
" or set TARGET_CHEMBL_ID=\"\" to auto-resolve TARGET_QUERY by data volume.")
raise SystemExit("No bioactivity data for the selected target.")
banner("[3/9] MOLECULAR CURATION (standardize, de-salt, aggregate)")
_lfc = rdMolStandardize.LargestFragmentChooser() if _HAS_STD else None
_unc = rdMolStandardize.Uncharger() if _HAS_STD else None
def standardize(smi):
m = Chem.MolFromSmiles(smi)
if m is None:
return None, None
try:
if _HAS_STD:
m = _lfc.choose(m); m = _unc.uncharge(m)
else:
frags = Chem.GetMolFrags(m, asMols=True, sanitizeFrags=True)
if frags:
m = max(frags, key=lambda x: x.GetNumHeavyAtoms())
return m, Chem.MolToSmiles(m)
except Exception:
return None, None
canon, keep_mol = [], {}
for _, r in raw_df.iterrows():
m, cs = standardize(r["smiles"])
if cs is None or m.GetNumHeavyAtoms() < 6:
continue
canon.append({"smiles": cs, "pIC50": r["pIC50"], "chembl_id": r["chembl_id"]})
keep_mol[cs] = m
cdf = pd.DataFrame(canon, columns=["smiles", "pIC50", "chembl_id"])
data = (cdf.groupby("smiles")
.agg(pIC50=("pIC50", "median"), n=("pIC50", "size"),
chembl_id=("chembl_id", "first")).reset_index())
if len(data) > MAX_UNIQUE:
data = data.sample(MAX_UNIQUE, random_state=RANDOM_STATE).reset_index(drop=True)
data["mol"] = data["smiles"].map(keep_mol)
n_active = int((data["pIC50"] >= ACTIVE_PIC50).sum())
print(f" Unique curated molecules : {len(data)}")
print(f" Potent actives (IC50<=100nM): {n_active} ({100*n_active/len(data):.1f}%)")
print(f" pIC50 range: {data.pIC50.min():.2f} - {data.pIC50.max():.2f} "
f"(median {data.pIC50.median():.2f})")
mfpgen = rdFingerprintGenerator.GetMorganGenerator(radius=RADIUS, fpSize=NBITS)
DESC = [("MolWt", Descriptors.MolWt), ("MolLogP", Descriptors.MolLogP),
("TPSA", Descriptors.TPSA), ("HBD", Descriptors.NumHDonors),
("HBA", Descriptors.NumHAcceptors), ("RotB", Descriptors.NumRotatableBonds),
("AromRings", Descriptors.NumAromaticRings), ("FracCSP3", Descriptors.FractionCSP3),
("HeavyAtoms", Descriptors.HeavyAtomCount),
("NumRings", lambda m: rdMolDescriptors.CalcNumRings(m))]
FEAT_NAMES = [f"bit_{i}" for i in range(NBITS)] + [n for n, _ in DESC]
def fp_array(m):
a = np.zeros((NBITS,), dtype=np.int8)
DataStructs.ConvertToNumpyArray(mfpgen.GetFingerprint(m), a)
return a
def featurize(mols):
Xb = np.zeros((len(mols), NBITS), dtype=np.int8)
Xd = np.zeros((len(mols), len(DESC)), dtype=np.float32)
for i, m in enumerate(mols):
Xb[i] = fp_array(m)
for j, (_, fn) in enumerate(DESC):
try:
Xd[i, j] = fn(m)
except Exception:
Xd[i, j] = 0.0
return np.nan_to_num(np.hstack([Xb, Xd]).astype(np.float32))
X = featurize(list(data["mol"]))
y = data["pIC50"].values
print(f" Feature matrix: {X.shape[0]} molecules x {X.shape[1]} features "
f"({NBITS} ECFP bits + {len(DESC)} descriptors)")
ChEMBL からキュレーションされた IC50 バイオアッセイ測定値を抽出し、生きた活動記録を利用可能な pIC50 データセットに変換します。下流の QSAR モデルがよりクリーンな活性値で学習できるように、不完全・非正確・一貫性のない測定値は除外します。その後、RDKit を用いて分子を標準化し、塩類や小さな断片を除去し、重複する分子については pIC50 の中央値で集約します。さらに、各分子を Morgan フィンガープリントビットと解釈可能な物理化学記述子に変換します。
スキャフォールド分析および QSAR
コードをコピーしました。別のブラウザを使用してください
banner("[4/9] CHEMICAL SPACE & SCAFFOLD ANALYSIS")
def murcko(m):
try:
s = MurckoScaffold.GetScaffoldForMol(m)
cs = Chem.MolToSmiles(s)
return cs if cs else "(acyclic)"
except Exception:
return "(error)"
data["scaffold"] = data["mol"].map(murcko)
top_scaf = data["scaffold"].value_counts().head(10)
print(" Top recurring Murcko scaffolds (chemotype families):")
for i, (sc, c) in enumerate(top_scaf.items(), 1):
print(f" {i:2d}. n={c:4d} {sc[:70]}")
pca = PCA(n_components=2, random_state=RANDOM_STATE)
emb = pca.fit_transform(X[:, :NBITS])
fig, ax = plt.subplots(1, 3, figsize=(18, 4.8))
sc0 = ax[0].scatter(emb[:, 0], emb[:, 1], c=y, cmap="viridis", s=10, alpha=0.6)
ax[0].set(title="Chemical space (ECFP-PCA), coloured by potency",
xlabel=f"PC1 ({pca.explained_variance_ratio_[0]*100:.0f}%)",
ylabel=f"PC2 ({pca.explained_variance_ratio_[1]*100:.0f}%)")
plt.colorbar(sc0, ax=ax[0], label="pIC50")
ax[1].hist(y, bins=40, color="#3b7dd8", edgecolor="white")
ax[1].axvline(ACTIVE_PIC50, color="crimson", ls="--", label=f"active cut (pIC50={ACTIVE_PIC50})")
ax[1].set(title="Potency distribution", xlabel="pIC50", ylabel="molecules"); ax[1].legend()
ax[2].barh([s[:22] + "..." for s in top_scaf.index[::-1]], top_scaf.values[::-1], color="#6c5ce7")
ax[2].set(title="Top 10 scaffolds", xlabel="count")
plt.tight_layout(); plt.savefig("fig1_chemical_space.png", dpi=120); plt.show()
banner("[5/9] INTERPRETABLE QSAR MODEL (scaffold-split, leakage-free)")
def scaffold_split(scaffolds, test_frac=0.2, seed=RANDOM_STATE):
groups = {}
for i, s in enumerate(scaffolds):
groups.setdefault(s, []).append(i)
order = list(groups.values())
random.Random(seed).shuffle(order)
n_test = int(len(scaffolds) * test_frac)
test, train = [], []
for g in order:
(test if len(test) < n_test else train).extend(g)
return np.array(sorted(train)), np.array(sorted(test))
tr, te = scaffold_split(data["scaffold"].values, 0.2)
model = RandomForestRegressor(n_estimators=400, max_features="sqrt",
n_jobs=-1, random_state=RANDOM_STATE)
model.fit(X[tr], y[tr])
pred = model.predict(X[te])
r2 = r2_score(y[te], pred)
rmse = mean_squared_error(y[te], pred) ** 0.5
rho = spearmanr(y[te], pred).statistic
ycls = (y[te] >= ACTIVE_PIC50).astype(int)
auc = roc_auc_score(ycls, pred) if len(np.unique(ycls)) == 2 else float("nan")
print(f" Held-out (new-scaffold) performance on {len(te)} molecules:")
print(f" R^2 = {r2:.3f}")
print(f" RMSE (pIC50) = {rmse:.3f} (~{rmse:.2f} log units)")
print(f" Spearman rho = {rho:.3f}")
print(f" ROC-AUC active = {auc:.3f} (ranking potent vs weak)")
model_full = RandomForestRegressor(n_estimators=400, max_features="sqrt",
n_jobs=-1, random_state=RANDOM_STATE).fit(X, y)
curated な化学空間を解析するために、Murcko スケフォールドを抽出し、データセット内で最も一般的な化学型ファミリーを特定します。Morgan ファインガープリントを PCA で投影して、EGFR 阻害剤が化学空間にどのように分布しているか、およびその領域全体で活性がどう変化するかを可視化します。その後、スケフォールド分割を用いてランダムフォレスト QSAR モデルを訓練し、モデルが近縁な類似体を単に暗記するのではなく、未見の分子スケフォールドに対して一般化できるかを評価できるようにします。
解釈可能性と BRICS 生成
Copy CodeCopiedUse a different Browser
banner("[6/9] モデルの解釈可能性(どの部分構造が活性を駆動するか?)")
top_feat_idx, shap_ok = None, False
try:
import shap
samp = np.random.RandomState(RANDOM_STATE).choice(len(te), min(300, len(te)), replace=False)
expl = shap.TreeExplainer(model)
sv = expl.shap_values(X[te][samp])
if isinstance(sv, list):
sv = sv[0]
imp = np.abs(sv).mean(0)
shap_ok = True
print(" SHAP TreeExplainer を使用しています(保持された分子の平均 |SHAP| 値)。")
except Exception as e:
imp = model_full.feature_importances_
print(f" SHAP が利用できません ({type(e).__name__});ランダムフォレストの重要度を使用します。")
order = np.argsort(imp)[::-1]
top_feat_idx = [i for i in order[:25]]
top_desc = [(FEAT_NAMES[i], imp[i]) for i in order if i >= NBITS][:8]
top_bits = [i for i in order if i < NBITS][:6]
print("\n 最も影響力のある物理化学記述子:")
for name, v in top_desc:
print(f" {name:12s} 重要度={v:.4f}")
train_smis = list(data["smiles"].iloc[tr])
def bit_exemplar(bit):
for smi in train_smis:
m = Chem.MolFromSmiles(smi)
if m is None:
continue
ao = rdFingerprintGenerator.AdditionalOutput(); ao.AllocateBitInfoMap()
_ = mfpgen.GetFingerprint(m, additionalOutput=ao)
bi = ao.GetBitInfoMap()
if bit in bi and len(bi[bit]):
atom, rad = bi[bit][0]
atoms, bonds = {atom}, []
if rad > 0:
env = Chem.FindAtomEnvironmentOfRadiusN(m, rad, atom)
bonds = list(env)
for bidx in env:
b = m.GetBondWithIdx(bidx)
atoms.update((b.GetBeginAtomIdx(), b.GetEndAtomIdx()))
try:
return Draw.MolToImage(m, size=(300, 240),
highlightAtoms=list(atoms), highlightBonds=bonds)
except Exception:
return None
return None
try:
imgs = [(b, bit_exemplar(b)) for b in top_bits]
imgs = [(b, im) for b, im in imgs if im is not None]
if imgs:
fig, ax = plt.subplots(1, len(imgs), figsize=(3.1 * len(imgs), 3.3))
if len(imgs) == 1:
ax = [ax]
for a, (b, im) in zip(ax, imgs):
a.imshow(im); a.axis("off"); a.set_title(f"ECFP bit {b}\n(ランク重要度)", fontsize=9)
plt.suptitle("モデルが活性と関連付ける部分構造", y=1.02)
plt.tight_layout(); plt.savefig("fig2_potency_substructures.png", dpi=120, bbox_inches="tight")
plt.show()
except Exception as e:
print(f" (部分構造の描画をスキップしました: {type(e).__name__})")
banner("[7/9] 生成設計(BRICS 断片再結合による新規類似体)")
seed = data[data["pIC50"] >= ACTIVE_PIC50].copy()
seed["mw"] = seed["mol"].map(Descriptors.MolWt)
seed = seed[(seed.mw >= 250) & (seed.mw <= 500)].sort_values("pIC50", ascending=False).head(N_FRAG_PARENTS)
print(f" {len(seed)} の活性が高く、医薬品様性を備えた親分子で生成設計を開始します。")
frags = set()
for m in seed["mol"]:
try:
frags.update(BRICS.BRICSDecompose(m))
except Exception:
pass
frag_mols = [f for f in (Chem.MolFromSmiles(s) for s in frags) if f is not None]
print(f" 断片プール: {len(frag_mols)} の BRICS 断片。")
known = set(data["smiles"])
gen = {}
try:
for i, prod in enumerate(BRICS.BRICSBuild(frag_mols, scrambleReagents=True, maxDepth=2)):
if i >= BRICS_MAX_TRIES:
break
try:
prod.UpdatePropertyCache(strict=False)
Chem.SanitizeMol(prod)
cs = Chem.MolToSmiles(prod)
except Exception:
continue
if cs in known or cs in gen:
continue
mw = Descriptors.MolWt(prod)
if 250 <= mw <= 600 and 8 <= prod.GetNumHeavyAtoms() <= 45:
gen[cs] = prod
except Exception as e:
print(f" (BRICS 構築が早期終了しました: {type(e).__name__})")
print(f" {len(gen)} の一意で新規、サイズが妥当な仮想分子を生成しました。")
訓練された QSAR モデルを解釈し、予測活性に最も強く寄与する記述子やフィンガープリントビットを特定します。SHAP が利用可能な場合はそれを使用し、そうでない場合はワークフローの堅牢性を保つためにランダムフォレストの特徴重要度を用います。また、影響力のある ECFP ビットに関連する代表的な分子サブ構造を可視化し、強力なドラッグライクな親分子を BRICS 断片に分解して再結合することで新規仮想類似体を生成する創薬設計を開始します。
多パラメータ候補スコアリング
コードをコピーしました。別のブラウザを使用してください
banner("[8/9] MULTI-PARAMETER PRIORITISATION")
gsmiles = list(gen.keys())
gmols = [gen[s] for s in gsmiles]
gX = featurize(gmols)
gpred = model_full.predict(gX)
train_fps = [mfpgen.GetFingerprint(m) for m in data["mol"]]
def novelty(m):
sims = DataStructs.BulkTanimotoSimilarity(mfpgen.GetFingerprint(m), train_fps)
return 1.0 - (max(
原文を表示
In this tutorial, we build an end-to-end autonomous AI co-scientist workflow for next-generation EGFR inhibitor discovery, focusing on the C797S osimertinib-resistance mutation in non-small cell lung cancer. We start by resolving the biological target through ChEMBL and UniProt, then mine curated EGFR IC50 bioactivity records and convert them into a clean pIC50 modeling dataset. We use RDKit to standardize molecules, remove salts, aggregate replicate measurements, compute Morgan fingerprints, extract physicochemical descriptors, and analyze scaffold diversity so that our model learns from chemically meaningful representations rather than raw strings. From there, we train a scaffold-split Random Forest QSAR model, evaluate its ability to generalize to unseen chemotypes, interpret potency-driving features with SHAP or model importances, and visualize influential molecular substructures. Finally, we move beyond prediction into generative design by recombining BRICS fragments from potent drug-like actives, scoring the resulting virtual analogs across potency, drug-likeness, synthesizability, novelty, and developability gates, and cross-checking the shortlisted candidates against PubChem.
EGFR Target Setup
Copy CodeCopiedUse a different Browser
import sys, subprocess, importlib, warnings, time, os, random, json
warnings.filterwarnings("ignore")
def _pip(*pkgs):
subprocess.run([sys.executable, "-m", "pip", "install", "-q", *pkgs], check=False)
for mod, pkg in [("rdkit", "rdkit"), ("shap", "shap"), ("requests", "requests")]:
try:
importlib.import_module(mod)
except Exception:
print(f"Installing {pkg} ...")
_pip(pkg)
import numpy as np
import pandas as pd
import requests
import matplotlib.pyplot as plt
from scipy.stats import spearmanr
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score, mean_squared_error, roc_auc_score
from sklearn.decomposition import PCA
from rdkit import Chem, DataStructs, RDLogger
from rdkit.Chem import Descriptors, Draw, QED, rdMolDescriptors, BRICS, rdFingerprintGenerator
from rdkit.Chem.Scaffolds import MurckoScaffold
RDLogger.DisableLog("rdApp.*")
try:
from rdkit.Chem.MolStandardize import rdMolStandardize
_HAS_STD = True
except Exception:
_HAS_STD = False
try:
from rdkit.Chem import RDConfig
sys.path.append(os.path.join(RDConfig.RDContribDir, "SA_Score"))
import sascorer
_HAS_SA = True
except Exception:
_HAS_SA = False
TARGET_CHEMBL_ID = "CHEMBL203"
TARGET_QUERY = "Epidermal growth factor receptor"
FALLBACK_CHEMBL_ID = "CHEMBL203"
NBITS, RADIUS = 2048, 2
RANDOM_STATE = 42
MAX_ACTIVITIES = 9000
MAX_UNIQUE = 4000
ACTIVE_PIC50 = 7.0
BRICS_MAX_TRIES = 4000
N_FRAG_PARENTS = 60
N_SHORTLIST = 12
np.random.seed(RANDOM_STATE); random.seed(RANDOM_STATE)
BASE = "https://www.ebi.ac.uk/chembl/api/data"
HDRS = {"Accept": "application/json", "User-Agent": "ai-coscientist-tutorial/1.0"}
def banner(title):
print("\n" + "=" * 86 + f"\n {title}\n" + "=" * 86)
def http_json(url, params=None, tries=3, timeout=45):
for k in range(tries):
try:
r = requests.get(url, params=params, headers=HDRS, timeout=timeout)
if r.status_code == 200:
return r.json()
if r.status_code == 404:
return None
except Exception:
pass
time.sleep(1.5 * (k + 1))
return None
banner("[1/9] TARGET INTELLIGENCE (ChEMBL + UniProt)")
print("Question: What target are we drugging, and why is it hard?\n")
def ic50_count(tid):
js = http_json(f"{BASE}/activity", {"target_chembl_id": tid, "standard_type": "IC50",
"pchembl_value__isnull": "false", "limit": 1, "format": "json"})
try:
return int(js["page_meta"]["total_count"])
except Exception:
return 0
target_id, target_name, uniprot_acc = None, TARGET_QUERY, None
if TARGET_CHEMBL_ID:
target_id = TARGET_CHEMBL_ID
else:
srch = http_json(f"{BASE}/target/search", {"q": TARGET_QUERY, "format": "json"})
cands = []
if srch:
for t in srch.get("targets", []):
if t.get("organism") == "Homo sapiens" and t.get("target_type") == "SINGLE PROTEIN":
cands.append(t)
cands = sorted(cands, key=lambda t: float(t.get("score", 0)), reverse=True)[:8]
scored = [(t, ic50_count(t["target_chembl_id"])) for t in cands]
scored = [(t, n) for t, n in scored if n > 0]
if scored:
best = max(scored, key=lambda x: x[1])[0]
target_id, target_name = best["target_chembl_id"], best.get("pref_name", TARGET_QUERY)
print(f" Auto-resolved '{TARGET_QUERY}' by data volume -> {target_id}")
else:
target_id = FALLBACK_CHEMBL_ID
print(f" Auto-resolve found no data; falling back to {FALLBACK_CHEMBL_ID}")
det = http_json(f"{BASE}/target/{target_id}", {"format": "json"})
if det and det.get("pref_name"):
target_name = det["pref_name"]
if det:
for comp in det.get("target_components", []):
if comp.get("accession"):
uniprot_acc = comp["accession"]; break
print(f" Resolved target : {target_name}")
print(f" ChEMBL ID : {target_id}")
print(f" UniProt : {uniprot_acc}")
if uniprot_acc:
uni = http_json(f"https://rest.uniprot.org/uniprotkb/{uniprot_acc}.json")
if uni:
try:
fn = next(c["texts"][0]["value"] for c in uni.get("comments", [])
if c.get("commentType") == "FUNCTION")
print("\n Function (UniProt):")
print(" ", (fn[:340] + " ...") if len(fn) > 340 else fn)
except Exception:
pass
print("""
Resistance context: 1st/2nd/3rd-gen EGFR TKIs lose potency once tumours acquire the
C797S mutation, which abolishes the covalent cysteine anchor exploited by osimertinib.
Goal of this run: learn the chemistry of known EGFR inhibitors and propose NOVEL,
drug-like analogs as starting points for a C797S-active 4th-generation series.""")
We begin by preparing the full scientific computing environment and installing any missing chemistry, modeling, plotting, and API dependencies required by the workflow. We configure the EGFR target settings, define modeling constants, initialize reproducible random seeds, and create helper functions for banners and robust JSON API calls. We then resolve the ChEMBL target, retrieve UniProt context when available, and frame the biological motivation around EGFR C797S resistance.
Mining ChEMBL Bioactivity Data
Copy CodeCopiedUse a different Browser
banner("[2/9] BIOACTIVITY MINING (ChEMBL activities -> pIC50)")
def pull_activities(tid, cap):
url, rows = f"{BASE}/activity", []
params = {"target_chembl_id": tid, "standard_type": "IC50",
"pchembl_value__isnull": "false", "limit": 1000, "format": "json"}
js = http_json(url, params)
pages = 0
while js and pages < 60:
rows.extend(js.get("activities", []))
pages += 1
if len(rows) >= cap:
break
nxt = js.get("page_meta", {}).get("next")
if not nxt:
break
nurl = nxt if nxt.startswith("http") else "https://www.ebi.ac.uk" + nxt
js = http_json(nurl)
return rows[:cap]
raw = pull_activities(target_id, MAX_ACTIVITIES)
print(f" Pulled {len(raw)} raw IC50 records with a curated pChEMBL value.")
recs = []
for a in raw:
smi, pv = a.get("canonical_smiles"), a.get("pchembl_value")
if not smi or pv in (None, ""):
continue
if a.get("standard_relation") != "=":
continue
if a.get("standard_units") not in ("nM", None):
continue
try:
pv = float(pv)
except Exception:
continue
recs.append({"chembl_id": a.get("molecule_chembl_id"), "smiles": smi, "pIC50": pv})
raw_df = pd.DataFrame(recs, columns=["chembl_id", "smiles", "pIC50"])
print(f" After quality filters: {len(raw_df)} measurements.")
if len(raw_df) == 0:
print("\n STOP: no usable IC50 data was retrieved for this target.\n"
" Fix: set TARGET_CHEMBL_ID to a target that has inhibitor data\n"
" (e.g. CHEMBL203=EGFR, CHEMBL5251=BTK, CHEMBL2971=JAK2),\n"
" or set TARGET_CHEMBL_ID=\"\" to auto-resolve TARGET_QUERY by data volume.")
raise SystemExit("No bioactivity data for the selected target.")
banner("[3/9] MOLECULAR CURATION (standardize, de-salt, aggregate)")
_lfc = rdMolStandardize.LargestFragmentChooser() if _HAS_STD else None
_unc = rdMolStandardize.Uncharger() if _HAS_STD else None
def standardize(smi):
m = Chem.MolFromSmiles(smi)
if m is None:
return None, None
try:
if _HAS_STD:
m = _lfc.choose(m); m = _unc.uncharge(m)
else:
frags = Chem.GetMolFrags(m, asMols=True, sanitizeFrags=True)
if frags:
m = max(frags, key=lambda x: x.GetNumHeavyAtoms())
return m, Chem.MolToSmiles(m)
except Exception:
return None, None
canon, keep_mol = [], {}
for _, r in raw_df.iterrows():
m, cs = standardize(r["smiles"])
if cs is None or m.GetNumHeavyAtoms() < 6:
continue
canon.append({"smiles": cs, "pIC50": r["pIC50"], "chembl_id": r["chembl_id"]})
keep_mol[cs] = m
cdf = pd.DataFrame(canon, columns=["smiles", "pIC50", "chembl_id"])
data = (cdf.groupby("smiles")
.agg(pIC50=("pIC50", "median"), n=("pIC50", "size"),
chembl_id=("chembl_id", "first")).reset_index())
if len(data) > MAX_UNIQUE:
data = data.sample(MAX_UNIQUE, random_state=RANDOM_STATE).reset_index(drop=True)
data["mol"] = data["smiles"].map(keep_mol)
n_active = int((data["pIC50"] >= ACTIVE_PIC50).sum())
print(f" Unique curated molecules : {len(data)}")
print(f" Potent actives (IC50<=100nM): {n_active} ({100*n_active/len(data):.1f}%)")
print(f" pIC50 range: {data.pIC50.min():.2f} - {data.pIC50.max():.2f} "
f"(median {data.pIC50.median():.2f})")
mfpgen = rdFingerprintGenerator.GetMorganGenerator(radius=RADIUS, fpSize=NBITS)
DESC = [("MolWt", Descriptors.MolWt), ("MolLogP", Descriptors.MolLogP),
("TPSA", Descriptors.TPSA), ("HBD", Descriptors.NumHDonors),
("HBA", Descriptors.NumHAcceptors), ("RotB", Descriptors.NumRotatableBonds),
("AromRings", Descriptors.NumAromaticRings), ("FracCSP3", Descriptors.FractionCSP3),
("HeavyAtoms", Descriptors.HeavyAtomCount),
("NumRings", lambda m: rdMolDescriptors.CalcNumRings(m))]
FEAT_NAMES = [f"bit_{i}" for i in range(NBITS)] + [n for n, _ in DESC]
def fp_array(m):
a = np.zeros((NBITS,), dtype=np.int8)
DataStructs.ConvertToNumpyArray(mfpgen.GetFingerprint(m), a)
return a
def featurize(mols):
Xb = np.zeros((len(mols), NBITS), dtype=np.int8)
Xd = np.zeros((len(mols), len(DESC)), dtype=np.float32)
for i, m in enumerate(mols):
Xb[i] = fp_array(m)
for j, (_, fn) in enumerate(DESC):
try:
Xd[i, j] = fn(m)
except Exception:
Xd[i, j] = 0.0
return np.nan_to_num(np.hstack([Xb, Xd]).astype(np.float32))
X = featurize(list(data["mol"]))
y = data["pIC50"].values
print(f" Feature matrix: {X.shape[0]} molecules x {X.shape[1]} features "
f"({NBITS} ECFP bits + {len(DESC)} descriptors)")
We mine curated IC50 bioactivity measurements from ChEMBL and convert the raw activity records into a usable pIC50 dataset. We filter out incomplete, non-exact, or inconsistent measurements so that the downstream QSAR model trains on cleaner potency values. We then standardize the molecules with RDKit, remove salts or smaller fragments, aggregate duplicate molecules by median pIC50, and convert each molecule into Morgan fingerprint bits plus interpretable physicochemical descriptors.
Scaffold Analysis and QSAR
Copy CodeCopiedUse a different Browser
banner("[4/9] CHEMICAL SPACE & SCAFFOLD ANALYSIS")
def murcko(m):
try:
s = MurckoScaffold.GetScaffoldForMol(m)
cs = Chem.MolToSmiles(s)
return cs if cs else "(acyclic)"
except Exception:
return "(error)"
data["scaffold"] = data["mol"].map(murcko)
top_scaf = data["scaffold"].value_counts().head(10)
print(" Top recurring Murcko scaffolds (chemotype families):")
for i, (sc, c) in enumerate(top_scaf.items(), 1):
print(f" {i:2d}. n={c:4d} {sc[:70]}")
pca = PCA(n_components=2, random_state=RANDOM_STATE)
emb = pca.fit_transform(X[:, :NBITS])
fig, ax = plt.subplots(1, 3, figsize=(18, 4.8))
sc0 = ax[0].scatter(emb[:, 0], emb[:, 1], c=y, cmap="viridis", s=10, alpha=0.6)
ax[0].set(title="Chemical space (ECFP-PCA), coloured by potency",
xlabel=f"PC1 ({pca.explained_variance_ratio_[0]*100:.0f}%)",
ylabel=f"PC2 ({pca.explained_variance_ratio_[1]*100:.0f}%)")
plt.colorbar(sc0, ax=ax[0], label="pIC50")
ax[1].hist(y, bins=40, color="#3b7dd8", edgecolor="white")
ax[1].axvline(ACTIVE_PIC50, color="crimson", ls="--", label=f"active cut (pIC50={ACTIVE_PIC50})")
ax[1].set(title="Potency distribution", xlabel="pIC50", ylabel="molecules"); ax[1].legend()
ax[2].barh([s[:22] + "..." for s in top_scaf.index[::-1]], top_scaf.values[::-1], color="#6c5ce7")
ax[2].set(title="Top 10 scaffolds", xlabel="count")
plt.tight_layout(); plt.savefig("fig1_chemical_space.png", dpi=120); plt.show()
banner("[5/9] INTERPRETABLE QSAR MODEL (scaffold-split, leakage-free)")
def scaffold_split(scaffolds, test_frac=0.2, seed=RANDOM_STATE):
groups = {}
for i, s in enumerate(scaffolds):
groups.setdefault(s, []).append(i)
order = list(groups.values())
random.Random(seed).shuffle(order)
n_test = int(len(scaffolds) * test_frac)
test, train = [], []
for g in order:
(test if len(test) < n_test else train).extend(g)
return np.array(sorted(train)), np.array(sorted(test))
tr, te = scaffold_split(data["scaffold"].values, 0.2)
model = RandomForestRegressor(n_estimators=400, max_features="sqrt",
n_jobs=-1, random_state=RANDOM_STATE)
model.fit(X[tr], y[tr])
pred = model.predict(X[te])
r2 = r2_score(y[te], pred)
rmse = mean_squared_error(y[te], pred) ** 0.5
rho = spearmanr(y[te], pred).statistic
ycls = (y[te] >= ACTIVE_PIC50).astype(int)
auc = roc_auc_score(ycls, pred) if len(np.unique(ycls)) == 2 else float("nan")
print(f" Held-out (new-scaffold) performance on {len(te)} molecules:")
print(f" R^2 = {r2:.3f}")
print(f" RMSE (pIC50) = {rmse:.3f} (~{rmse:.2f} log units)")
print(f" Spearman rho = {rho:.3f}")
print(f" ROC-AUC active = {auc:.3f} (ranking potent vs weak)")
model_full = RandomForestRegressor(n_estimators=400, max_features="sqrt",
n_jobs=-1, random_state=RANDOM_STATE).fit(X, y)
We analyze the curated chemical space by extracting Murcko scaffolds and identifying the most common chemotype families in the dataset. We project Morgan fingerprints with PCA to visualize how EGFR inhibitors distribute across chemical space and how potency varies across that landscape. We then train a Random Forest QSAR model using a scaffold split, which helps us evaluate whether the model generalizes to unseen molecular scaffolds rather than memorizing close analogs.
Interpretability and BRICS Generation
Copy CodeCopiedUse a different Browser
banner("[6/9] MODEL INTERPRETABILITY (which substructures drive potency?)")
top_feat_idx, shap_ok = None, False
try:
import shap
samp = np.random.RandomState(RANDOM_STATE).choice(len(te), min(300, len(te)), replace=False)
expl = shap.TreeExplainer(model)
sv = expl.shap_values(X[te][samp])
if isinstance(sv, list):
sv = sv[0]
imp = np.abs(sv).mean(0)
shap_ok = True
print(" Using SHAP TreeExplainer (mean |SHAP| over held-out molecules).")
except Exception as e:
imp = model_full.feature_importances_
print(f" SHAP unavailable ({type(e).__name__}); using RandomForest importances.")
order = np.argsort(imp)[::-1]
top_feat_idx = [i for i in order[:25]]
top_desc = [(FEAT_NAMES[i], imp[i]) for i in order if i >= NBITS][:8]
top_bits = [i for i in order if i < NBITS][:6]
print("\n Most influential physicochemical descriptors:")
for name, v in top_desc:
print(f" {name:12s} importance={v:.4f}")
train_smis = list(data["smiles"].iloc[tr])
def bit_exemplar(bit):
for smi in train_smis:
m = Chem.MolFromSmiles(smi)
if m is None:
continue
ao = rdFingerprintGenerator.AdditionalOutput(); ao.AllocateBitInfoMap()
_ = mfpgen.GetFingerprint(m, additionalOutput=ao)
bi = ao.GetBitInfoMap()
if bit in bi and len(bi[bit]):
atom, rad = bi[bit][0]
atoms, bonds = {atom}, []
if rad > 0:
env = Chem.FindAtomEnvironmentOfRadiusN(m, rad, atom)
bonds = list(env)
for bidx in env:
b = m.GetBondWithIdx(bidx)
atoms.update((b.GetBeginAtomIdx(), b.GetEndAtomIdx()))
try:
return Draw.MolToImage(m, size=(300, 240),
highlightAtoms=list(atoms), highlightBonds=bonds)
except Exception:
return None
return None
try:
imgs = [(b, bit_exemplar(b)) for b in top_bits]
imgs = [(b, im) for b, im in imgs if im is not None]
if imgs:
fig, ax = plt.subplots(1, len(imgs), figsize=(3.1 * len(imgs), 3.3))
if len(imgs) == 1:
ax = [ax]
for a, (b, im) in zip(ax, imgs):
a.imshow(im); a.axis("off"); a.set_title(f"ECFP bit {b}\n(rank imp.)", fontsize=9)
plt.suptitle("Substructures the model associates with potency", y=1.02)
plt.tight_layout(); plt.savefig("fig2_potency_substructures.png", dpi=120, bbox_inches="tight")
plt.show()
except Exception as e:
print(f" (substructure drawing skipped: {type(e).__name__})")
banner("[7/9] GENERATIVE DESIGN (BRICS fragment recombination -> novel analogs)")
seed = data[data["pIC50"] >= ACTIVE_PIC50].copy()
seed["mw"] = seed["mol"].map(Descriptors.MolWt)
seed = seed[(seed.mw >= 250) & (seed.mw <= 500)].sort_values("pIC50", ascending=False).head(N_FRAG_PARENTS)
print(f" Seeding generative design with {len(seed)} potent, drug-like parent molecules.")
frags = set()
for m in seed["mol"]:
try:
frags.update(BRICS.BRICSDecompose(m))
except Exception:
pass
frag_mols = [f for f in (Chem.MolFromSmiles(s) for s in frags) if f is not None]
print(f" Fragment pool: {len(frag_mols)} BRICS fragments.")
known = set(data["smiles"])
gen = {}
try:
for i, prod in enumerate(BRICS.BRICSBuild(frag_mols, scrambleReagents=True, maxDepth=2)):
if i >= BRICS_MAX_TRIES:
break
try:
prod.UpdatePropertyCache(strict=False)
Chem.SanitizeMol(prod)
cs = Chem.MolToSmiles(prod)
except Exception:
continue
if cs in known or cs in gen:
continue
mw = Descriptors.MolWt(prod)
if 250 <= mw <= 600 and 8 <= prod.GetNumHeavyAtoms() <= 45:
gen[cs] = prod
except Exception as e:
print(f" (BRICS build ended early: {type(e).__name__})")
print(f" Generated {len(gen)} unique, novel, size-reasonable virtual molecules.")
We interpret the trained QSAR model by estimating which descriptors and fingerprint bits contribute most strongly to predicted potency. We use SHAP when available; otherwise, we fall back to Random Forest feature importances to keep the workflow robust. We also visualize representative molecular substructures associated with influential ECFP bits, then begin generative design by decomposing potent drug-like parent molecules into BRICS fragments and recombining them to generate novel virtual analogs.
Multi-Parameter Candidate Scoring
Copy CodeCopiedUse a different Browser
banner("[8/9] MULTI-PARAMETER PRIORITISATION")
gsmiles = list(gen.keys())
gmols = [gen[s] for s in gsmiles]
gX = featurize(gmols)
gpred = model_full.predict(gX)
train_fps = [mfpgen.GetFingerprint(m) for m in data["mol"]]
def novelty(m):
sims = DataStructs.BulkTanimotoSimilarity(mfpgen.GetFingerprint(m), train_fps)
return 1.0 - (max(
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み