structural-biology.com

ChemSage: A hybrid, chemically aware LLM for drug discovery: RAG for facts, a QLoRA-tuned model for behavior

2C19 · 5-(4-Carboxy-2-oxo-butylsulfanyl)-4-oxo-pentanoic acid acid bound to Porphobilinogen synthase from Pseudomonas aeruginosa · X-ray · 2.05 Γ… · Open the four lenses →

ChemSage

A hybrid, chemically aware LLM for drug discovery: RAG for facts, a QLoRA-tuned model for behaviour, and live RDKit/PyMOL tool calls for chemical truth. Fine-tuned and served locally on Apple Silicon with MLX-LM.

live MLX-LM fine-tune base RAG models author

🌐 Appchemsage.mdeller.comβœ‰οΈ Contactmarc@marcdeller.comπŸ™ GitHubbellcheddar/ChemSage

ChemSage is a locally hosted, open-source assistant that reasons about small molecules (SMILES, scaffolds, properties), drives the cheminformatics and structural tools a medicinal chemist actually uses (RDKit, PyMOL, docking, PLIP), and grounds every factual claim in your own corpus.

Why it matters: off-the-shelf models invent chemistry (wrong logP, malformed SMILES) and cannot see your private SAR data. ChemSage fixes both by separating concerns: volatile knowledge lives in retrieval, stable behaviour lives in the weights, and deterministic chemical facts are computed live by trusted tools rather than guessed. It is useful for: interrogating your own assay and SAR data, triaging compounds, generating correct PyMOL/RDKit scripts, and interpreting docking and interaction output, all on local hardware. A live, no-install terminal session against the real fine-tuned model runs at chemsage.mdeller.com (see Web deployment below).

Initial idea: Fed up with token, model and upload document limits? Then why not run your own AI models on Mac hardware? Thanks to @ai.christianson on TikTok for highlighting the following method to implement MLX-LM optimised fine-tuning of a local LLM on Mac hardware: https://www.tiktok.com/@ai.christianson/video/7538150476467014943?lang=en

Disclaimer: I am not a professional coder! This is an exercise in Vibe Coding as a Structural Biologist to get the job done. Use at your own risk, and double check the science. I am still working on validation of the Chemistry and Biology. This is a proof of concept exercise to demonstrate what can be achieved using moderate hardware and limited coding experience.

Project links:

πŸ€— Models on HuggingFace Hub

ModelHuggingFaceBaseRound
ChemSage 7B v1Dellboy/chem_sage_7b_v1Qwen2.5-7B-Instruct-4bitRound 1
ChemSage 32B v2Dellboy/chem_sage_32b_v2Qwen2.5-32B-Instruct-4bitRound 2
ChemSage 32B v3Dellboy/chem_sage_32b_v3Qwen2.5-32B-Instruct-4bitRound 3
ChemSage 32B v4Dellboy/chem_sage_32b_v4Qwen2.5-32B-Instruct-4bitRound 4
ChemSage 32B v5 ⭐Dellboy/chem_sage_32b_v5Qwen2.5-32B-Instruct-4bitRound 5 (current)

βš™οΈ The hybrid in one line

RAG keeps it truthful today; the fine-tune makes it consistent tomorrow; RDKit makes it correct always.

πŸš€ Quick start

See PROJECT_PLAN.md for the full step-by-step build (phases 0 to 8). Short version:

StepWhatScript / command
0Env: MLX-LM + RDKit + GUI(requirements.txt)
1Base model (Qwen 2.5 32B, MLX 4-bit)config/train_config.yaml
2RAG first (ship this alone)scripts/ingest_rag.py
2aDownload ChEMBL corpusscripts/download_chembl.py + scripts/download_chembl_extended.py
2bDownload PubChem corpusscripts/download_pubchem.py
2cDownload PDB corpusscripts/download_pdb.py + scripts/download_pdb_supplement.py
3Build + validate SFT datasetscripts/build_dataset.py
4QLoRA fine-tunemlx_lm.lora --config config/train_config.yaml --train
5Fuse + servemlx_lm.fuse β†’ mlx_lm.server --port 8081
6Close the hybrid looprag/tool_exec.py
7Chemistry-aware evaluationeval/eval_chem.py

▢️ How to run

Terminal 1 β€” start the model server:

cd /Users/dellboy/Documents/Vibe_Coding/chem_sage
source .venv/bin/activate
mlx_lm.server --model models/chem_sage_32b_v5 --port 8080

Terminal 2 β€” run chat:

cd /Users/dellboy/Documents/Vibe_Coding/chem_sage
source .venv/bin/activate
python scripts/chat.py --model models/chem_sage_32b_v5

Wait for β€œHTTP server listening” in Terminal 1 before starting Terminal 2.

πŸŽ“ Training

πŸŽ“ Training data

Β Round 1 (7B)Round 2 (32B)Round 3 (32B)Round 4 (32B)Round 5 (32B)
Total examples1,5001,5005,0008,00020,000
Train / valid / test1,200 / 150 / 1501,200 / 150 / 1504,000 / 500 / 5006,400 / 800 / 80016,000 / 2,000 / 2,000
Behaviour classes88195978
Seed SMILES252551 + corpus drugs51 + corpus drugs51 + corpus drugs
Corpus drug examplesβ€”β€”~1,000~1,000 + protein family PDBs~1,200 + protein family PDBs
Code-block densityLow (~50%)Low (~50%)High (~80%)High (~85%)High (~90%)
Generator script--n 1500--n 1500--n 5000 --seed 43--n 8000 --seed 47--n 20000 --seed 51
ValidationRDKit + SMILESRDKit + SMILESRDKit + SMILESRDKit + SMILESRDKit + SMILES
Token lengths (p50/p99/max)β€”β€”β€”β€”322 / 488 / 558
Rejected examplesβ€”β€”243 (95%)β€”707 (96.5%)

Behaviour classes by round:

Round 3 additions (targeting R2 eval gaps):

ClassWeightPurpose
single_property2xOne property at a time with code block
full_profile2xComplete property profile via code
qed_score1xQED drug-likeness score
molecular_formula1xFormula, atom counts, ring analysis
smiles_validation1xValid/invalid SMILES handling
property_comparison1xSide-by-side two-molecule comparison
corpus_drug2xNamed drugs from approved_drugs.csv
murcko_sar1xScaffold comparison with SAR context

Round 4 additions (structural biology + fidelity):

ClassWeightPurpose
pymol_selection2xPyMOL selection algebra (within, byres, chain, resi)
pymol_viz2xPyMOL visualization (surface, alignment, symmetry, B-factor, figures)
plip_analysis2xPLIP interaction types (H-bond, hydrophobic, pi-stack, salt bridge, halogen)
pdb_format2xPDB file format (ATOM/HETATM, altloc, B-factor interpretation)
pdbtools2xpdb-tools CLI recipes (selchain, selres, tidy, splitmodel)
biopython2xBio.PDB patterns (SMCRA, NeighborSearch, Superimposer)
chimerax2xChimeraX commands (hbonds, clashes, matchmaker)
gemmi1xgemmi mmCIF/PDB parsing and neighbour search
struct_prep1xStructure preparation (PDBFixer, protonation, 3D conformers)
docking_interp2xDocking result interpretation (Vina scores, RMSD, Kd estimation)
plotly2xPlotly Python visualization (MW vs logP, radar charts)
r_recipe2xR/ggplot2/bio3d/Plotly-R (SAR plots, RMSD, RMSF)
protein_family3xKinases, GPCRs, antibodies, GTPases, cytokines, receptors (real PDB data)
code_then_quote3xβ€œCompute then quote” pattern for numerical fidelity
rounding_precision2xExact rounding conventions (MW 1dp, logP 2dp, TPSA 1dp)
personality_drug3xLipinski verdicts with βœ…/❌ cards, emoji, and medchem humor
personality_valid2xSMILES validation with flair and emoji
personality_struct3xStructural observations with character and humor
refusal2xOut-of-scope and invalid query handling

πŸŽ“ Training parameters

ParameterRound 1 (7B)Round 2 (32B)Round 3 (32B)Round 4 (32B)Round 5 (32B, complete)
Base modelQwen2.5-7B-Instruct-4bitQwen2.5-32B-Instruct-4bitQwen2.5-32B-Instruct-4bitQwen2.5-32B-Instruct-4bitQwen2.5-32B-Instruct-4bit
Fine-tune typeQLoRA (4-bit base)QLoRA (4-bit base)QLoRA (4-bit base)QLoRA (4-bit base)QLoRA (4-bit base)
use_rsloratruetruetruetruetrue
LoRA rank3232323264
LoRA scale64.064.064.064.090.0
LoRA keysq/k/v/o_proj + gate/up/down_projq/k/v/o_proj + gate/up/down_projq/k/v/o_proj + gate/up/down_projq/k/v/o_proj + gate/up/down_projq/k/v/o_proj + gate/up/down_proj
num_layers16 (of 28)32 (of 64)32 (of 64)32 (of 64)32 (of 64)
iters7507501,500 (stopped at 625)1,500 (complete)3,000
batch_size44444
learning_rate2e-52e-52e-52e-52e-5
LR scheduleCosine, 50-step warmupCosine, 50-step warmupCosine, 50-step warmupCosine, 50-step warmupCosine, 100-step warmup
max_seq_length20482048204820483072
steps_per_report10101025 (aligned with eval)50 (aligned with eval)
val_batchesβ€”β€”β€”2550
seed4242435151
Trainable params~26M (0.34%)134M (0.41%)134M (0.41%)134M (0.41%)268M (0.82%)
Peak memory~12 GB24.9 GB25.2 GB24.9 GB~33 GB est.
Training data1,200 examples1,200 examples4,000 examples6,400 examples16,000 examples
Adapter pathadapters/chem_sage_7b_v1_lora/adapters/chem_sage_32b_v2_lora/adapters/chem_sage_32b_v3_lora/adapters/chem_sage_32b_v4_lora/adapters/chem_sage_32b_v5_lora/
Fused model pathmodels/chem_sage_7b_v1/models/chem_sage_32b_v2/models/chem_sage_32b_v3/models/chem_sage_32b_v4/models/chem_sage_32b_v5/

Notes: Round 3 was configured for 1,500 iters but stopped early at actual iter 625 after val loss plateaued at ~0.060. Training was resumed from iter 100 checkpoint after a battery shutdown. 64 layers was attempted first but caused swap thrashing at 31.5 GB peak; reverted to 32 layers. batch_size=8 was also attempted but ran 4.5x slower due to memory pressure; reverted to 4. Round 4 uses 32 layers (same as R3) β€” 48 and 64 layers were found to be too slow (~2–2.5 min/iter) on M1 Max; 32 layers is the throughput sweet spot at ~40 sec/iter. Round 5 doubles the LoRA rank to 64 with RSLoRA scale 90.0 (effective alpha β‰ˆ 11.25, same order as R4’s 11.31), triples the iters to 3,000, extends max_seq_length to 3,072, and doubles val_batches to 50 for a more stable eval signal. Trainable parameters doubled to 268M (0.82%).

πŸŽ“ Training loss curves

Validation loss measured every 25 steps over 25 batches. Round 3 was resumed from iter 100 checkpoint after a battery shutdown; actual iter = logged iter + 100 for the resumed portion.

IterR1 ValR2 ValR3 ValR4 ValR5 ValNotes
13.8003.1912.1102.3972.210Baseline; R5 lower than R4 despite 2.5Γ— data β€” rank=64 adapter fits better
251.0200.5800.2380.480β€”Β 
500.7400.5330.1450.277β€”R3 already below R2’s best
750.6300.5270.1220.200β€”Β 
1000.5800.4950.1100.146β€”R3 battery shutdown; resumed from here
2000.4900.4490.1160.109β€”Β 
3000.4400.4160.0760.082β€”Β 
4000.4200.4000.0590.074β€”Β 
5000.4050.3750.0600.071β€”Β 
6000.3950.3590.0600.059β€”R4 matches R3’s best
625β€”β€”0.0540.055β€”Best R3 checkpoint (fused)
650β€”β€”β€”0.052β€”R4 beats R3 best
6750.3890.347β€”0.069β€”Best R1/R2 checkpoint
7000.3900.372β€”0.058β€”Β 
750β€”β€”β€”0.056β€”Β 
775β€”β€”β€”0.049β€”Β 
800β€”β€”β€”0.051β€”Β 
950β€”β€”β€”0.041β€”Best R4 checkpoint (fused)
1000β€”β€”β€”0.043β€”Β 
1100β€”β€”β€”0.043β€”Β 
1425β€”β€”β€”0.041β€”Ties R4 best at iter 950
1500β€”β€”β€”0.050β€”R4 final iter
1600β€”β€”β€”β€”0.055Best R5 checkpoint (fused)
2000β€”β€”β€”β€”0.061R5 early stop

Round 3 val loss (0.054) is 6.4x better than Round 2 (0.347) and 7.2x better than Round 1 (0.389). The improvement is driven by dataset quality (19 behaviour classes with code-heavy examples) and 3.3x more training data (4,000 vs 1,200 examples). Round 3 was stopped early at iter 625 of 1,500 after val loss plateaued.

Round 4 completed 2026-06-27 (1,500 iters, ~43 h). Best val loss: 0.041 at iter 950, beating R3’s best of 0.054 by 24%. The best was also tied at iter 1,425. Fused from iter 950 checkpoint into models/chem_sage_32b_v4/ (~17.2 GB). Peak memory 25.5 GB, 32 adapted layers, 134M trainable parameters (0.41%).

Round 5 early-stopped 2026-06-29 at iter 2000 of 3,000. Val loss plateaued at 0.055 from iter 1600 (true best 0.054 at iters 1650 and 1950, but fell between checkpoint saves). Fused from iter 1600 checkpoint into models/chem_sage_32b_v5/ (~17.2 GB). Higher floor than R4 (0.055 vs 0.041) reflects the harder task: 78 behaviour classes vs 59, 20k examples vs 6.4k.

πŸ§ͺ Evaluation results

Original scorecard eval (per-round, eval/eval_chem.py)

MetricRound 1 (7B)Round 2 (32B)Round 3 (32B)
SMILES validity0/0 (n/a)25/25 (100%)494/499 (99%)
Tool executability0/8 (0%)72/174 (41%)437/442 (99%)
Numerical fidelity0/0 (n/a)7/73 (10%)363/533 (68%)
Code blocks generated8174442
Overall0%51%89%

Scorecard files: eval/scorecards/scorecard_r1_7b.html, scorecard_r2_32b.html, scorecard_r3_32b_v2.html.

Comparative eval β€” all 5 rounds (2026-06-29, eval/compare/eval_compare.py)

100 shared R5 test examples (seed=42). Full report: eval/compare/results/compare_20260629_1928.html.

Scores: per-example pass/fail (examples where all instances correct / 100). Metrics degrade gracefully to N/A when a metric’s trigger type never appears in a model’s responses.

MetricRound 1Round 2Round 3Round 4Round 5Ξ” vs R4
SMILES validity100%100%100%100%100%+0%
SMARTS validityN/A0%100%55%100%+45%
Tool executability0%28%79%71%95%+24%
Code attempted14%41%100%97%100%+3%
Python exec (extended)36%34%78%69%99%+30%
Code→Quote fidelityN/A0%47%19%61%+42%
Numerical fidelityN/A18%57%47%89%+42%
Rounding precision100%100%98%98%99%+1%
Refusal accuracy98%98%97%98%100%+2%
QED range100%100%100%100%100%+0%
PDB ID validity100%100%100%100%100%+0%
PyMOL syntax77%97%89%89%90%+1%
Degeneration-free93%96%91%98%100%+2%
Overall72%63%87%80%95%+15%

R5 wins on all exec/fidelity targets: exec +24%, PyExec +30%, fidelity +42%, Code→Quote +42%, SMARTS +45%, Degeneration-free 100%, Refusal 100%.

The R4 exec regression vs R3 (71% vs 79%) was caused by dataset distribution shift towards structural biology content that reduced the density of pure-RDKit drills. R5 corrected this with pyexec_drill (Γ—6), code_then_quote_v2 (Γ—5), and fidelity_multistep (Γ—5) generators.

πŸ“š RAG corpus

The data/corpus/ directory contains the full retrieval knowledge base: tool reference docs plus two databases of experimental chemistry data:

πŸ“š ChEMBL (data/corpus/chembl/, ~70 MB)

FileRecordsContents
approved_drugs.csv3,257All FDA/EMA approved small molecules with SMILES + properties
bioactivity_key_targets.csv112,357IC50/Ki/Kd for EGFR, CDK2, HIV protease, SARS-CoV-2 3CLpro, VEGFR2, Bcr-Abl, COX-2, BRAF, ALK, JAK1/2, hERG
bioactivity_extended_targets.csv~200kIC50/Ki for 36 additional targets: BTK, PARP1, CDK4/6, FLT3, mTOR, AKT1, PI3Ka, RET, MET, SRC, HDAC1, BRD4, EZH2, AR, ERa, PPARg, GLP-1R, Factor Xa, Thrombin, ACE, HMG-CoA, PDE5, DHFR, IDH1/2, KRAS + more
admet_data.csv~50kCaco-2 permeability, CLint, t1/2, fu,plasma, solubility, logD, CYP3A4/2D6/2C9/1A2/2C19 inhibition
clinical_candidates_phase2_3.csv~10kPhase 2-3 small molecules with properties
fragment_actives.csv10,000MW < 300 fragments with pIC50 >= 4
natural_products.csv~5kNatural product-derived compounds
drug_mechanisms.csv7,561Mechanism of action + target for all drugs
drug_indications.csv60,055Disease indications for all drugs
atc_classification.csv5,579Full ATC therapeutic class hierarchy

πŸ“š PubChem (data/corpus/pubchem/, ~6.6 MB)

FileRecordsContents
fda_drugs_with_pubchem_cids.csv3,257ChEMBL approved drugs cross-referenced to PubChem CIDs
tox21_panel.csv~90k9 Tox21 nuclear receptor / stress-pathway assays (AhR, AR, ERa, NFkB, Nrf2, MMP, p53, ATAD5, TR)
pkis2_kinase_panel.csv~50kPublished Kinase Inhibitor Set 2: 367-kinase selectivity panel
drug_repurposing_hub.csv~6kBroad Institute Drug Repurposing Hub clinical annotations
adme_cyp_hts.csv19,803NCATS Caco-2 permeability, P-gp efflux, CYP2C9 inhibition HTS
bindingdb_affinities.csvβ€”BindingDB Ki/IC50/Kd (literature-curated)
nci60_gi50.csvβ€”NCI-60 cancer cell line growth inhibition

πŸ“š PDB (data/corpus/pdb/, ~111 MB)

FileRecordsContents
pdb_all_entries.csv255,239All PDB structures: ID, method, resolution, compound, organism, deposit date
pdb_ligands_all.csv16,171All unique PDB ligands with SMILES, InChI, InChIKey, formula, MW, type, ChEMBL/DrugBank/PubChem cross-references
pdb_ligands_druglike.csv382Drug-like CCD subset (MW 100-800 Da, non-polymer, with SMILES)
pdb_ligand_structure_pairs.csv121,541Protein-ligand pairs: which ligands are bound in which structures (with resolution + method)
pdb_druglike_structure_pairs.csv1,671Drug-like protein-ligand pairs only
pdb_binding_affinities.csv46,885Measured Kd/Ki/IC50 from PDBbind-annotated structures (9,209 entries)
sifts_pdb_uniprot.csv965,937SIFTS: PDB chain -> UniProt accession (canonical target identity bridge)
sifts_pdb_pfam.csv1,022,861SIFTS: PDB chain -> Pfam domain family (fold-level annotation)

πŸ“ Recent changes

Live web deployment (2026-07-24)

  • chemsage.mdeller.com is live. The real scripts/chat.py CLI now runs in the browser via an xterm.js terminal, bridged over WebSocket to a server-side PTY β€” same Rich output, corpus tables, and slash commands as the local CLI, not a rebuilt chat-bubble UI.
  • No local GPU on the droplet: mlx_lmβ€˜s stream_generate/generate are patched at import time (chat_remote.py) to call a HuggingFace Space running the real fused 32B model on ZeroGPU, then the unmodified chat.py is run unaware of the substitution.
  • Serving stack: gunicorn (gevent worker, 1 worker / 100 connections β€” a PTY session is I/O-bound, not CPU-bound) behind nginx with WebSocket-upgrade support on /ws, systemd service, Let’s Encrypt TLS. Full detail in Web deployment below.

Chat CLI and dependency fixes (2026-06-30)

  • mlx-lm 0.31.3 API fix (scripts/chat.py): stream_generate() and generate() no longer accept verbose=False as a keyword argument β€” removed from both call sites.
  • Corpus fast-path (rag/corpus_lookup.py): Added "get" to the lookup trigger words so queries like "get all EGFR structures" route directly to the corpus table view instead of falling through to the LLM.
  • System prompt (scripts/chat.py): Added explicit instruction to render retrieved structured data (PDB structures, bioactivity measurements) as markdown tables rather than generating external API code.
  • Dependencies (requirements.txt): Added torch explicitly (required by sentence-transformers for local embeddings).
  • How to run: Added quick-start run instructions to the main README β€” single terminal, venv python, --no-rag flag for model-only mode.
  • All 5 models published to HuggingFace Hub (Dellboy/chem_sage_*).

πŸ§ͺ Eval harness fixes and 5-round comparative eval (2026-06-29)

Tokenizer fix (all models):
– tokenizer_config.json in chem_sage_7b_v1, chem_sage_32b_v2, chem_sage_32b_v3, chem_sage_32b_v4 had extra_special_tokens as a JSON list of strings. Newer transformers calls .keys() on this field β€” causing an AttributeError on load. The server silently swallowed the error, loaded a broken tokenizer, and then hung indefinitely on every inference request (all queries timed out). Fixed by converting to an empty dict {} in all four files. chem_sage_32b_v5 was already in the correct format.

Eval harness model-path fix (eval/compare/eval_compare.py):
– query_model() previously sent "model": "chemsage" in every HTTP POST to the local mlx_lm.server. Since mlx_lm 0.31.3, an unknown model name triggers a 120-second HuggingFace lazy-load timeout, causing all R1–R4 evals to time out. Fixed by passing the full local model directory path (model_path) as the "model" field.

Per-example pass/fail scoring:
– Changed the eval metric methodology throughout eval_compare.py from per-instance accuracy (ok / total_attempts) to per-example pass/fail (passed_examples / 100). An example β€œpasses” a metric if every instance in that response is correct (ok == tot, tot > 0). This makes round-over-round comparisons meaningful: R1 calling 2 SMILES correctly is no longer the same score as R3 calling 100 SMILES correctly. The _per_example_agg() helper re-derives this from results[].scores in raw_results.json.

5-round comparative eval (complete, 2026-06-29):
– All 5 rounds evaluated on 100 shared R5 test examples (seed=42). Full report: eval/compare/results/compare_20260629_1928.html. R5 overall 95% (vs R4 80%, R3 87%). R5 wins on exec (+24%), PyExec (+30%), fidelity (+42%), SMARTS (+45%). All README eval tables updated.

πŸŽ“ Round 5: Higher rank + 2.5Γ— corpus (2026-06-28, training in progress)

  • Expanded SFT dataset from 8,000 to 20,000 examples (16,000 train / 2,000 val / 2,000 test) across 78 behaviour classes (19 new vs R4)
  • Raised LoRA rank to 64 (from 32); scale set to 90.0 (RSLoRA effective alpha β‰ˆ 11.25, same order as R4); trainable parameters doubled to 268M (0.82%)
  • Extended max_seq_length to 3,072 (from 2,048); all corpus examples fit (p99=488, max=558 tokens)
  • Tripled training iters to 3,000; doubled val_batches to 50 for a more stable eval signal
  • New generators targeting R4 exec/PyExec gap and numerical fidelity: pyexec_drill (Γ—6), code_then_quote_v2 (Γ—5), rounding_explicit (Γ—5), fidelity_multistep (Γ—5)
  • New content generators: herg_liability, selectivity_profile, prodrug_bcs, sar_delta, mdanalysis, dssp, ppi_interface, uniprot_api, conformer_3d, mcs_search, reaction_smarts, recap_fragmentation, electron_density, biological_assembly, drug_target_family (Γ—3-4 each)
  • 707 examples rejected during validation (96.5% pass rate); token lengths all under max_seq_length (p99=488)
  • Training launched 2026-06-28 with caffeinate -i under single-process MLX launch (/tmp/r5_launch.py) β€” memory limits set in-process before importing mlx_lm; cache 16.7 GB / memory limit 50.1 GB
  • Iter 1 val loss: 2.210 (lower than R4’s 2.397 β€” rank=64 adapter has more capacity from the start)
  • Config: config/train_config_r5.yaml; adapter path: adapters/chem_sage_32b_v5_lora/
  • Early stop at iter 2000 β€” val loss plateaued at 0.055 from iter 1600; fused from iter 1600 checkpoint into models/chem_sage_32b_v5/ (~17.2 GB)

πŸŽ“ Round 4: Expanded dataset training (2026-06-25 to 2026-06-27, complete)

  • Expanded SFT dataset from 5,000 to 8,000 examples (6,400 train / 800 val / 800 test) across 59 behaviour classes
  • New behaviour classes: drug mechanisms/indications/ATC, multi-step reasoning, refusals, personality, structural biology, docking, visualisation (Plotly, R/ggplot2), numerical fidelity drills (code_then_quote, rounding_precision)
  • steps_per_report aligned with steps_per_eval (both 25) so train/val loss appear at the same iters
  • Training ran to completion: 1,500 iters, ~43 h (2026-06-25 15:39 to 2026-06-27 10:59 BST)
  • Best val loss: 0.041 at iter 950 β€” beats R3’s all-time best of 0.054 by 24% (tied at iter 1425)
  • Peak memory 25.5 GB, 32 adapted layers, 134M trainable parameters (0.41%)
  • Fused from iter 950 checkpoint into models/chem_sage_32b_v4/ (~17.2 GB, 4 safetensors shards)

πŸ§ͺ Comparative eval harness (2026-06-26)

New script eval/compare/eval_compare.py runs all 4 model rounds side-by-side on the same 100 R4 test examples and produces a rich HTML + Markdown comparison report.

Features:
– Option A auto-server: spawns and kills mlx_lm.server per model automatically (no manual terminal juggling); health-checks the OpenAI-compatible endpoint before starting eval; sends SIGTERM to the process group on completion
– Shared examples: 100 examples sampled from the R4 test set with a fixed seed (reproducible)
– All 13 metrics: 3 core + 7 new metrics, all reporting n/a gracefully on older models
– R3 as baseline: metric table includes a Ξ” column (Β±%) vs Round 3; colour-coded green/red
– Model profile panel: full spec comparison across architecture, training config, training outcomes, corpus, and eval runtime
– Runtime resource stats: server process CPU mean/peak %, RSS mean/peak GB (via psutil), mean latency (s), throughput (tok/s)
– Validation loss curves: SVG comparison chart of all 4 models’ val loss curves (clipped at 0.60; dashed line for estimated curves)
– Per-example table: 100 collapsible rows; click β€œresponses” to see all model outputs side-by-side
– Resume flag: --resume loads raw_results.json cache and skips already-evaluated models
– Branding: ChemSage ASCII art (pyfiglet), clickable marcdeller.com links throughout

Usage (run after R4 training completes and fuse is done):

cd chem_sage/
# Full run β€” all 4 models
python eval/compare/eval_compare.py

# Subset and resume
python eval/compare/eval_compare.py --models round3 round4 --resume

# Quick smoke-test (10 examples)
python eval/compare/eval_compare.py --limit 10

Config: eval/compare/models.yaml β€” all model metadata, loss curves, and training stats in one place; edit to update R4 final values after training.

πŸ”— ChromaDB 1.x compatibility fix (2026-06-25)

  • ChromaDB 1.0+ switched to a Rust connection backend; PersistentClient timed out on databases created with the old Python backend
  • Fix: pass Settings(allow_reset=True, anonymized_telemetry=False) to PersistentClient in both rag/retrieve.py and scripts/ingest_rag.py
  • No data loss, no re-ingest β€” all 55,742 indexed chunks preserved; confirmed connected and serving queries
  • Downgrade to chromadb<1.0.0 is not possible on Python 3.14 (no pre-built chroma-hnswlib wheel)

πŸ“Š Chat CLI: Streaming output and UX polish (2026-06-25)

Streaming generation:
– Response tokens now stream live to the terminal in real-time using mlx_lm.stream_generate + Rich Live; raw text streams token-by-token during generation, then the final response re-renders as formatted Markdown β€” no more waiting behind a β€œThinking…” spinner
– Falls back to blocking generate + spinner if stream_generate is unavailable

New slash commands:
– /save β€” exports the full conversation to a timestamped Markdown file on the Desktop (includes marcdeller.com attribution header)
– /info β€” shows a panel with loaded model name/size, training config (iters, RSLoRA rank, lr, ctx), corpus stats (chunks, CSV files, MB), and embed model; clickable hyperlinks to marcdeller.com and marc@marcdeller.com
– /retry β€” re-generates the previous response using the same RAG context; replaces the last assistant turn in history

UX refinements:
– Tab completion: type / and press Tab for a dropdown of all slash commands; completion menu styled to match the ChemSage colour scheme
– RAG retrieval indicator: πŸ” Retrieving context… spinner during retrieval, then πŸ“š N chunks retrieved confirmation line before generation begins
– Context window indicator in toolbar: bottom toolbar now shows ctx N/3 alongside the turn counter so you can see how much of the 3-turn context window is filled
– /reset confirmation: asks Clear history? [y/N] before wiping β€” no accidental data loss
– Generation stats: now includes elapsed seconds β€” ⚑ N tok Β· X.X tok/s Β· X.Xs
– Styled goodbye: exit via quit/q/Ctrl-C shows a full-screen Panel with the ChemSage ASCII banner, β€œThank you for using ChemSage”, and clickable links to marcdeller.com and marc@marcdeller.com
– Hyperlinks throughout: banner, /help, /info, /save output, and goodbye Panel all include clickable [link=...] Rich markup pointing to marcdeller.com and marc@marcdeller.com

Earlier polish (same date):
– prompt_toolkit input with arrow-key history and styled You: prompt
– Persistent bottom toolbar: ⬑ ChemSage Β· model Β· QLoRA Β· RAG Β· turn N Β· ctx N/3
– Corpus tables: Rich Table with rounded cyan borders, single-line rows (ellipsis overflow), abbreviated headers, βœ“/βœ— booleans, method abbreviations
– RDKit output in yellow-bordered Panel; retrieval errors in red-bordered Panel
– Terminal title set to ChemSage on launch and /clear
– Added rich, prompt_toolkit, pyfiglet to requirements.txt

πŸ§ͺ Eval harness: R4 extended metrics (2026-06-25)

Extended eval/eval_chem.py with new metrics that degrade gracefully to n/a when run against R1-R3 models/datasets, enabling a clean side-by-side comparison across all rounds:

New metricWhat it measuresWhy added
Rounding precisionMW 1 d.p. Β· logP 2 d.p. Β· TPSA 1 d.p. Β· QED 2 d.p.R4 drills exact decimal-place conventions
Code-then-quoteprose values match what code blocks actually printed to stdoutDistinguishes quoting own code vs. recalling from memory
Extended executabilityall python blocks (RDKit + Plotly + Biopython)R4 adds Plotly and Biopython code generation
Refusal accuracyout-of-scope prompts correctly declinedR4 adds explicit refusal training
QED rangeall stated QED values in [0, 1]R4 adds QED computation training

Backwards compatibility: R1-R3 test sets have no refusal examples (refusal shows n/a), no QED mentions (QED shows n/a), code blocks may not print (code-then-quote shows n/a). All five new metrics return (0, 0) when inapplicable. The core 3-metric overall score formula is unchanged for direct cross-round comparison.

New scorecard features: per-class breakdown table (if test examples carry a class field), overall score card, collapsible response viewer in per-example table, extended exec breakdown by library category, mean latency in header, cleaner terminal summary with n/a annotation.

πŸŽ“ Round 3: Code-heavy dataset and extended training (2026-06-25)

  • Expanded SFT dataset from 1,500 to 5,000 examples (4,000 train) across 19 behaviour classes
  • 8 new generator classes targeting eval gaps: single_property, full_profile, qed_score, molecular_formula, smiles_validation, property_comparison, corpus_drug, murcko_sar
  • 51 seed SMILES + ~1,000 corpus drug examples from approved_drugs.csv
  • Configured for 1,500 iters; stopped early at iter 625 after val loss plateaued at ~0.060
  • Best val loss 0.054 at iter 625 (vs 0.347 for Round 2): 6.4x improvement
  • Attempted 64 layers (swap thrashing at 31.5 GB) and batch_size=8 (4.5x slower); reverted to 32 layers / batch 4
  • Resumed from iter 100 checkpoint after battery shutdown; no data loss
  • Eval: SMILES 99%, Exec 99%, Fidelity 68% (vs R2: 100%, 41%, 10%)
  • Fused model saved to models/chem_sage_32b_v3/ (17 GB)

Round 2: Qwen 2.5 32B upgrade (2026-06-24)

  • Upgraded base model from Qwen 2.5 7B to 32B (4-bit, MLX); trained with 32 adapted layers (vs 16 for 7B)
  • Best val loss 0.347 at step 675 (vs 0.389 for 7B): 10.8% improvement
  • Fused model saved to models/chem_sage_32b_v2/ (17 GB); serves on 64 GB Apple Silicon at 24.9 GB peak memory
  • Eval shows 100% SMILES validity, 41% tool executability, 10% numerical fidelity (7B scored 0% across the board)
  • Added tqdm progress bar and live metrics to eval/eval_chem.py
  • System prompt injection in eval harness (test examples lack system messages)
  • Fixed merge_export.py: uses sys.executable -m mlx_lm fuse (corrected deprecated syntax), resolves adapter path relative to config directory

πŸ“š Round 1: Corpus fast-path and chat CLI

  • All 17 corpus CSVs searchable directly from the chat CLI via keyword fast-path (rag/corpus_lookup.py) that bypasses the LLM for enumeration queries
  • Up to 500 matching rows loaded per source; pageable in 50-row pages with CSV export
  • PDB binding affinity cross-reference: resolves target names to PDB IDs via pdb_all_entries.csv
  • Deduplication by comp_id sorted by comp_mw descending (highest-MW representative per compound)
  • Intent filtering: SMILES/affinity/sequence queries drop irrelevant sources from the menu
  • _CAPS_STOPWORDS prevents format words (β€œSMILES”) from winning keyword extraction over target names (β€œEGFR”)
  • RAG hybrid loop: top-5 corpus chunks per query via ChromaDB (BAAI/bge-base-en-v1.5, 55k chunks)
  • RDKit auto-execution of code blocks in model responses
  • ASCII art banner (ChemSage, small_slant font), Rich Markdown rendering, Rich Table corpus tables with rounded cyan borders
  • Column display config (_COL_CFG) maps all corpus column names to abbreviated headers and per-column max widths; ellipsis overflow keeps all rows single-line

🌐 Web deployment

Live at chemsage.mdeller.com. Not a rebuilt chat-bubble interface: the browser gets a real terminal (xterm.js) connected over WebSocket to a server-side PTY running the actual, unmodified scripts/chat.py β€” the same Rich-rendered markdown, corpus tables, and slash commands (/save, /info, /retry, tab completion) as running it locally.

No GPU on the droplet. chat_remote.py is a drop-in launcher: at import time it patches mlx_lm.generate/mlx_lm.stream_generate to call a HuggingFace Space running the real fused 32B model on ZeroGPU (a serverless, on-demand GPU allocation), then runs chat.py completely unaware anything is different. chat.py itself is never modified for the hosted path.

Serving stack: gunicorn with a single gevent worker (100 concurrent connections) rather than sync workers β€” a terminal session is I/O-bound (waiting on the remote model and on keystrokes), not CPU-bound, so one greenlet-based worker serves many concurrent sessions cheaply. nginx reverse-proxies both the WebSocket endpoint (/ws, with Upgrade/Connection headers and a long read/send timeout for idle-but-open sessions) and the static app shell, behind a Let’s Encrypt certificate. Runs as a dedicated systemd service on the droplet alongside AlphaFraud and BoltzMaker’s own web apps.

Source isolation: the web app (web/flask_app/) lives on a separate web git branch/worktree, so it never touches the tested training/eval code on main.

🧱 Stack

Everything runs locally on Apple Silicon via MLX-LM (mlx_lm.lora to train, mlx_lm.fuse to merge, mlx_lm.server for an OpenAI-compatible endpoint the GUI talks to). Unified memory means no VRAM wall: 16 GB handles ~8B, 32 GB ~14B, 64 GB ~32B. QLoRA is automatic because the base is a 4-bit MLX model.

The chat CLI (scripts/chat.py) uses Rich for streaming Markdown output (Live), corpus tables (rounded borders, ellipsis overflow, abbreviated headers), panels, progress spinners, and a themed console; prompt_toolkit for the interactive input session (arrow-key history, slash-command tab completion, styled prompt, persistent bottom toolbar with context window indicator); ChromaDB + sentence-transformers for the RAG retrieval layer; and RDKit for live chemical property computation. Responses stream token-by-token via mlx_lm.stream_generate and re-render as Markdown on completion. New commands: /save (export conversation to Markdown), /info (session/model details), /retry (regenerate last response).

βœ… To Do

Completed (Rounds 1-5)

  • βœ… Increase SFT dataset from 1,500 to 5,000 examples with code-heavy generators (Round 3)
  • βœ… Add examples that teach β€œemit RDKit code, don’t compute manually” (Round 3: single_property, full_profile)
  • βœ… Include SMILES validation examples (Round 3: smiles_validation)
  • βœ… Increase iters from 750 to 1,500 (Round 3; stopped early at 625 β€” val loss plateaued)
  • βœ… Try num_layers: 64 β€” caused swap thrashing at 31.5 GB; reverted to 32
  • βœ… Try batch_size: 8 β€” 4.5x slower due to memory pressure; reverted to 4
  • βœ… Add structural biology generators: PyMOL selections/viz, PLIP, PDB format, pdb-tools, Biopython, ChimeraX, gemmi
  • βœ… Add Plotly and R/ggplot2/bio3d recipe generators
  • βœ… Add protein family generators: kinases, GPCRs, antibodies, GTPases, cytokines, receptors (real PDB data)
  • βœ… Add numerical fidelity drills: code_then_quote (3x), rounding_precision (2x)
  • βœ… Add refusal/out-of-scope examples
  • βœ… Expand dataset to 8,000 examples across 51 behaviour classes

Round 5 dataset (complete)

  • βœ… 20,000 examples across 78 behaviour classes (build_dataset.py --n 20000 --seed 51)
  • βœ… 19 new generator classes targeting R4 exec/PyExec gap: pyexec_drill (Γ—6), code_then_quote_v2 (Γ—5), rounding_explicit (Γ—5), fidelity_multistep (Γ—5), herg_liability, selectivity_profile, prodrug_bcs, sar_delta, mdanalysis, dssp, ppi_interface, uniprot_api, conformer_3d, mcs_search, reaction_smarts, recap_fragmentation, electron_density, biological_assembly, drug_target_family
  • βœ… Token lengths p50=322, p99=488, max=558 β€” all under max_seq_length=3072
  • βœ… 707 examples rejected (96.5% pass rate); 78 classes, class balance max:median < 3:1
  • βœ… data/sft_r5/ β€” 16,000 train / 2,000 val / 2,000 test

πŸŽ“ Round 5 training (complete β€” early stop)

  • βœ… Config: config/train_config_r5.yaml β€” rank=64, scale=90, iters=3000, max_seq=3072, val_batches=50
  • βœ… Launch: single-process caffeinate -i .venv/bin/python /tmp/r5_launch.py (cache 16.7 GB, memory limit 50.1 GB)
  • βœ… Training started 2026-06-28 β€” Iter 1 val loss 2.210
  • βœ… No rank=64 instability β€” converged normally
  • βœ… Early stop at iter 2000 β€” val loss plateaued at 0.055 from iter 1600 (best: 0.054 at iters 1650 and 1950, unsaved)
  • βœ… Best available checkpoint: iter 1600, val loss 0.055 β†’ copied to adapters.safetensors
  • βœ… Fused into models/chem_sage_32b_v5/ (~17.2 GB)
  • βœ… Run eval/compare/eval_compare.py after fuse

Round 4 dataset (complete)

  • βœ… 8,000 examples across 59 behaviour classes (build_dataset.py --n 8000 --seed 47)
  • βœ… Protein family coverage: kinases (7,687 PDB), antibodies (3,322), GPCRs (103), GTPases (359), cytokines (769), receptors (6,891) β€” real PDB IDs and ligand SMILES from corpus
  • βœ… Numerical fidelity drills: code_then_quote enforces β€œcompute then quote” pattern; rounding_precision teaches MW 1dp, logP 2dp, TPSA 1dp
  • βœ… Structural biology: PyMOL selection algebra, visualization recipes, PLIP interaction analysis, PDB format, pdb-tools, Biopython Bio.PDB, ChimeraX, gemmi, structure preparation, docking interpretation
  • βœ… Visualization: Plotly Python (scatter, radar), R/ggplot2 (SAR plots), bio3d (RMSD, RMSF)
  • βœ… Set steps_per_report: 25 to align train/val reporting at same iters

πŸŽ“ Round 4 training (complete)

  • βœ… num_layers: 32 β€” 48 and 64 layers tested and found too slow (~2 min/iter); 32 is the M1 Max sweet spot
  • βœ… Adapter path: adapters/chem_sage_32b_v4_lora (checkpoints saved every 200 iters)
  • βœ… Training completed 2026-06-27 10:59 BST β€” 1,500 iters, ~43 h
  • βœ… Best checkpoint: iter 950, val loss 0.041 (also tied at iter 1425)
  • βœ… Copied 0000950_adapters.safetensors β†’ adapters.safetensors
  • βœ… Fused into models/chem_sage_32b_v4/ (~17.2 GB)
  • βœ… Updated eval/compare/models.yaml with final stats and complete val loss curve
  • βœ… Raised rank to 64 in Round 5 for more adapter expressivity

πŸ§ͺ Evaluation and model quality

  • βœ… Complete Round 3 eval: SMILES 99%, Exec 99%, Fidelity 68%
  • βœ… Comparative eval harness: eval/compare/eval_compare.py with 13 metrics, loss curves, resource stats
  • βœ… Comparative eval run after R4 fuse: full 4-round report eval/compare/results/compare_20260627_1753.html
  • βœ… Run R5 comparative eval after fuse: add Round 5 to eval/compare/models.yaml and run eval_compare.py
  • βœ… Improve exec/PyExec from R4’s 74%/75% to >85% (R5 result: exec 95%, PyExec 99%)
  • ⬜ Add perplexity benchmark against base (unfine-tuned) 32B
  • ⬜ Add hallucination rate metric: count invented PDB IDs, ChEMBL IDs, SMILES not in corpus

Infrastructure

  • βœ… ChromaDB 1.x fix: allow_reset=True in rag/retrieve.py and scripts/ingest_rag.py β€” 55,742 chunks preserved, no re-ingest required
  • βœ… psutil added to requirements.txt for server process monitoring in comparative eval
  • βœ… Set up HuggingFace Hub: all 5 models published at huggingface.co/Dellboy
  • ⬜ Create scripts/run_eval.sh (start server, wait, run eval, stop server)

Publishing and deployment

  • βœ… Live web deployment at chemsage.mdeller.com β€” shipped as an xterm.js terminal in the browser, not a chat-bubble Gradio UI: the real, unmodified scripts/chat.py runs server-side in a PTY, bridged to the browser over a WebSocket, so the hosted session is identical to running the CLI locally (same slash commands, same Rich rendering). No local GPU on the droplet, so mlx_lmβ€˜s generate/stream_generate are patched at import time to call a HuggingFace Space running the real fused model on ZeroGPU instead β€” see Web deployment below.
  • ⬜ Packaged CLI installer β€” publish chat.py as a pipx-installable tool (pyproject.toml entry point); users with a local Qwen 32B model can pipx install chemsage and point it at their own mlx_lm.server endpoint.

Round 6 (planned)

Ideas to try next, based on learnings from Rounds 1–5:

  • ⬜ Weighted class-balanced sampling across all 78 classes. Distribution shift was the proven cause of the R4 regression (exec 79β†’71%, SMARTS 100β†’55%) and SMARTS has swung 100β†’55β†’100 across three rounds purely on dataset composition. Lock the fragile metrics by controlling sampling density rather than relying on raw example counts.
  • ⬜ Triple the code_then_quote_v2 drill. Codeβ†’Quote fidelity moved from 19% (R4) to 61% (R5) but is still the weakest metric by 28 points. The failure mode is copy-fidelity, not reasoning β€” add examples where the quoted value must byte-match the printed stdout token, including trailing zeros and units, so the model learns to transcribe rather than re-derive.
  • ⬜ Add a dedicated pymol_syntax_drill generator. PyMOL syntax has sat at 89–90% for two rounds and is the lowest structural biology metric. Failures cluster in selection algebra and command chaining β€” target those specifically rather than adding more generic structural biology classes.
  • ⬜ Two-phase curriculum: full run then a short fidelity-focused continuation. Train the full 78-class dataset to plateau (~1,600 iters, matching R5 pattern), then run a short ~400-iter second phase at lower LR on a weak-metrics-only subset (codeβ†’quote, PyMOL, fidelity). Phase 2 is cheap (~7 h) and concentrates remaining capacity on the actual gaps rather than fighting the plateau.
  • ⬜ Configure for ~2,000 iters, not 3,000. R3 stopped at 42% of configured iters, R5 at 67% β€” both plateaued well before the ceiling. Stop burning 15–20 hours on a flat val curve; redirect that budget into the phase-2 continuation or more dataset quality work.
  • ⬜ Try rank 96 (not 128). The rank 32β†’64 jump in R5 tracked with the 95% overall score but the val floor rose to 0.055 with 78 classes, suggesting the harder task is capacity-limited. Rank 96 (~400M trainable params) should sit comfortably under the R5 peak of 30.3 GB; skip rank 128 to avoid swap risk on 64 GB unified memory.
  • ⬜ Extend fidelity_multistep with longer chains and distractor numbers. Fidelity reached 89%, but the residual 11% is multi-step cases where an intermediate value is transcribed incorrectly. Adding 4–5 step chains with adversarial distractor numbers forces the model to track which value to quote rather than defaulting to the largest or last number.
  • ⬜ Add per-class eval breakdown and run it on a mid-training checkpoint (~iter 1,000). R4’s exec/SMARTS regression only surfaced after 43 hours of training. A per-class pass/fail on the 100-example shared test set at iter 1,000 would expose class-level dilution early β€” while there is still time to rebalance the dataset and restart.

πŸ‘€ Author

Marc C. Deller, D.Phil.
Structural biologist & drug discovery scientist

🌐marcdeller.comβœ‰οΈmarc@marcdeller.comπŸ™github.com/bellcheddar/ChemSage