~/kaustubh-lall

~/projects $ cat projects/drug-transporter-ml.md

Drug Transporter ML

archived

UC San Diego · 2018 – 2020 · San Diego, CA

The code behind a cited biomedical publication (Journal of Biological Chemistry, 2020).

Feature-engineering and classification pipeline (brute-force, genetic-algorithm, and NEAT-based) for OAT1/OAT3 drug-transporter substrate prediction. The working code behind published research.

  • 3 datasets verified: 51x32 (3-class), 73x32 (3-class), 137x32 (6-class) rows x molecular descriptors
  • Brute-force path (10-fold CV, Random Forest + Decision Tree) runs correctly end to end on real data
  • Both NEAT-based paths fail on a stock checkout: missing no_fitness_termination config key (neat-python 2.0.0)
  • Patched in a scratch copy only: NEAT fitness climbed 0.451 to 0.569 over 5 generations

Overview

This repository is the machine learning implementation behind a published, peer-reviewed paper: Nigam AK, Li JG, Lall K, Shi D, Bush KT, Bhatnagar V, Abagyan R, Nigam SK, "Unique metabolite preferences of the drug transporters OAT1 and OAT3 analyzed by machine learning," Journal of Biological Chemistry, 2020 (PMID 31896576, DOI 10.1074/jbc.RA119.010729). It attacks one classification problem three different ways: exhaustive brute-force search, a NEAT-evolved network used directly as a classifier, and a NEAT-evolved network used as a feature transformer ahead of a Random Forest, asking whether letting evolution search the model space beats searching the feature space directly.

This case study is based on running the code, not just reading it. The brute-force path works exactly as documented: it loads the real 51-sample and 73-sample datasets, runs 10-fold cross-validated Random Forest and Decision Tree classifiers, and produces real accuracy numbers on live data. The two NEAT-based paths, as currently packaged, do not run: the bundled config files are missing a parameter, no_fitness_termination, that current neat-python versions require, so both feature_selection_GA.py and feature_engineering_GA.py crash on the first line of setup. The exact cause was diagnosed and the one-line fix confirmed in a scratch copy, with a correct evolutionary run reproduced once patched. The tracked repo itself still needs that fix before its own README instructions work end to end.

The problem

OAT1 (SLC22A6) and OAT3 (SLC22A8) are kidney transporters that move small organic anions, including a large share of prescribed drugs and their metabolites, out of the blood for elimination. Which transporter handles a given compound affects dosing, drug-drug interaction risk, and nephrotoxicity screening. Given a molecule's physicochemical descriptors, molecular weight, volume, LogP, LogS, polar surface area, ring counts, charge counts, 32 features per compound in total, can you predict whether it is an OAT1 substrate, an OAT3 substrate, both, or neither, well enough to say something about which molecular properties actually drive the preference? The repo packages the ML side of that work: three metabolite datasets and three independent algorithms for finding the feature subsets and models that separate these classes.

Approach and architecture

Three datasets, each a CSV of molecules with 32 shared descriptor columns and a transporter-class label: OAT1OAT3Small.csv (51 metabolites, 3-class: 17 Both, 12 OAT1-only, 22 OAT3-only), OAT1OAT3Big.csv (73 metabolites, same 3-class scheme: 17 Both, 21 OAT1, 35 OAT3), and OAT1OAT3OATP.csv (137 metabolites combining kidney and liver transporters, 6-class: 73 kidney rows plus 64 liver rows split 29 Both OATP, 20 OATP1B1, 15 OATP1B3).

Three algorithms run against any of the three datasets. Brute force exhaustively evaluates every size-k combination of the 32 features, training a fresh Random Forest and Decision Tree on each combination with 10-fold cross-validation, and writes every combination's accuracy to CSV, the "ground truth" search if you are willing to pay for C(32, k) model fits. Feature selection GA uses NEAT (NeuroEvolution of Augmenting Topologies) to evolve a small neural network that takes all 32 raw features and outputs a class score directly, fitness defined as training-set classification accuracy, replacing the classifier itself with an evolved network. Feature engineering GA also uses NEAT, but the evolved network transforms the 32 raw features into 6 new engineered features, scored by a downstream Random Forest under cross-validation, closer to automated feature construction than classification.

All three share a data loader for CSV loading and cleaning and a small shared utility module for the accuracy metric, NEAT population bootstrapping, and CLI prompts. An interactive CLI ties it together: pick a dataset, pick an algorithm, run. A legacy/ directory holds six earlier implementations across four conceptual iterations, kept rather than deleted, an honest record of how the analysis evolved from a five-classifier brute force to NEAT.

drug-transporter-ml architectureThree CSV datasets load through a shared DataLoaderMetabolite into three independent algorithms: brute-force search, a NEAT feature-selection GA, and a NEAT feature-engineering GA. Each produces its own output artifacts, with core/utilities.py and module/configs as shared support underneath.OAT1OAT3Small.csv51 rows x 32 features, 3-classOAT1OAT3Big.csv73 rows x 32 features, 3-classOAT1OAT3OATP.csv137 rows x 32 features, 6-classDataLoaderMetabolitecore/dataloader.py -> X, Y, headerBruteForceMetabolitebrute_force.pyexhaustive n-choose-kRandomForest + DecisionTree10-fold CV per combinationFeatureSelectionGAfeature_selection_GA.pyNEAT net = classifierargmax over 3/6 outputsfitness = train-set accuracyFeatureEngineeringfeature_engineering_GA.pyNEAT net -> 6 new features-> RandomForest classifierfitness = CV accuracyoutput/BF_metab_*.csvcombo + RF acc + DT accvisualize.pygraphviz net + fitness plotsoutput/*.checkpoint*NEAT population snapshotscore/utilities.py: auc(), BaseGA, CLI prompts (used by all three)module/configs/*.config: NEAT hyperparams (both GA paths)

architecture · drug-transporter-ml

Implementation notes

NEAT over gradient descent was a reasonable call for this scale: with 32 features and 51 to 137 samples, a hand-rolled backprop net would need real regularization to avoid memorizing the training set, and NEAT starts from minimal topology and only grows structure that improves fitness. The three algorithms are not evaluated the same way, though, and that matters if you are comparing their numbers. The feature-selection GA's docstring says outright that it "uses the entire thing to train and uses training accuracy as a measure of fitness," no held-out split. The feature-engineering GA does better, scoring its downstream Random Forest with leave-one-out or 10-fold CV depending on dataset size. Brute force is the most rigorous of the three, every combination gets 10-fold CV, no shortcuts. Winner-fitness numbers across the three methods are not directly comparable.

A few smaller things surfaced while running the code. nCr() uses floating-point division purely to size a progress bar. It printed 495.99999999999994 instead of 496 for C(32, 2), cosmetic only, since the actual search is driven by itertools.combinations directly. utilities.py hardcodes a Windows Graphviz path into PATH at import time. It will not crash elsewhere, but network-visualization rendering will silently fail to find the dot executable on any other machine. There are no automated tests, defensible for research code built to produce one paper's result and then left alone, but the tradeoff shows up later: the neat-python config incompatibility documented below would have been caught immediately by even a minimal smoke test, and instead sat undiscovered until this audit.

Results

Verified live during this audit, with pandas 2.3.3, numpy 1.26.4, scikit-learn 1.8.0, and neat-python 2.0.0. Data loading is correct: all three CSV loaders return exactly the shapes the code implies, 51x32 with 3-class labels (17 Both, 12 OAT1, 22 OAT3), 73x32 (17 Both, 21 OAT1, 35 OAT3), and 137x32 with 6-class labels split 73 kidney and 64 liver rows.

Brute force runs correctly end to end: 5 of the 496 possible 2-feature combinations from the small dataset were run through the real classifier pipeline, producing accuracies from 0.34 to 0.42 (Random Forest) and 0.40 to 0.44 (Decision Tree) against a majority-class baseline of 43.1%. That is a small, non-exhaustive slice, not the full sweep, so it says nothing about the paper's actual best-feature-subset result. It confirms the mechanism works and produces sane numbers on real data.

Both NEAT-based algorithms fail immediately on a stock checkout: running either one against any of the four bundled config files raises a RuntimeError for a missing required configuration item, no_fitness_termination, that the neat-python 2.0.0 config schema requires but none of the four files provide. The fix is real and small: adding one line under [NEAT] in a scratch copy, never the tracked file, let the feature-selection GA run correctly, population fitness climbing from 0.451 at generation 0 to 0.569 at generation 4 over a 5-generation test run. A second, separate issue then surfaced: NEAT's checkpoint writer targets ./output/, which does not exist in a fresh checkout, until the directory is created by hand.

Three figures built from a fresh scratch audit run against the live repo checkout this session: how the three algorithms' numbers do and don't compare, how the feature-selection GA's fitness actually climbed generation by generation, and how the brute-force search's accuracy spread across sampled feature pairs stacks up against just guessing the majority class.

Three algorithms, one question, three different scoring rules

Brute force, the feature-selection GA, and the feature-engineering GA all attack OAT1/OAT3 substrate classification on the 51-sample small dataset, but they are not evaluated the same way. Read the Protocol column before comparing any two rows' numbers directly.

Method comparison for drug-transporter-ml: brute force, feature-selection GA, and feature-engineering GA, each with its own evaluation protocol, result, and disclosed gap
MethodProtocol (what's actually measured)ResultGap / caveat
Brute forceTrue 10-fold CV per feature pair, real held-out evaluation
  • 245 of 496 possible 2-feature combinations sampled (49.4%), time-boxed to 150.2s wall-clock.
  • Random Forest accuracy: min 0.1200 / mean 0.3585 / max 0.5167.
  • Decision Tree accuracy: min 0.0367 / mean 0.3638 / max 0.5900.
  • Majority-class baseline: 22/51 = 43.14%.
  • Best pair found in this sample: (nof_Chirals, a_heavy), DT 0.5900, RF 0.5133.
Most rigorous of the three protocols. A partial, time-boxed sample, not the full 496-combination sweep.
Feature-selection GAFitness = training-set accuracy only, no held-out split (by the code's own docstring)
  • Winner fitness climbed from 0.5098 (generation 0) to 0.5882 over 25 generations, 11.1s wall time.
  • A second, independent run reached winner fitness 0.6275, winning network 4 hidden/output nodes and 13 enabled connections (32 inputs, 3 outputs).
Not comparable to the brute-force numbers above: no held-out split. No random seed is set anywhere in the repo, so re-running produces different numbers each time.
Feature-engineering GADownstream Random Forest scored under LeaveOneOut CV (most rigorous of the two NEAT paths)
  • One generation only (pop_size 100) measured at 325.5s wall-clock on this machine.
  • No evolved accuracy number: a multi-generation run is cost-prohibitive within a normal working session.
Disclosed gap, not an omission. The evaluation protocol is sound; the search was not run long enough to report a result.
Not a head-to-head comparison. Brute force uses true 10-fold CV, the feature-selection GA scores training-set accuracy only with no held-out split, and the feature-engineering GA's downstream Random Forest is LeaveOneOut-validated but was only run for one generation. Own scratch runs this session, Python 3.14.0, pandas 2.3.3, numpy 1.26.4, scikit-learn 1.8.0, neat-python 2.0.0, against a live checkout of the tracked repo, GA configs scratch-patched only (no_fitness_termination = True), tracked files untouched.

Feature-selection GA: best and mean fitness over 25 generations

One run, no fixed random seed, pop_size 100 (config default), fitness function feature_sel_err_metab_small (training-set accuracy, the repo's own definition, not a held-out score). Plotted un-smoothed: a long plateau from generation 1 through 20 at best fitness 0.5490, then two step jumps at generations 21 and 22, NEAT's speciation-driven exploration rather than gradient descent, is the real, bumpy shape of the search.

best fitness (winner genome)mean population fitness0.00.10.20.30.40.50.60.70510152024generationfitnessstep jumps: gen 21 to 0.5686, gen 22 to 0.5882
results · feature_sel_err_metab_small fitness · module/configs/metabolite_BI.config (scratch-patched: no_fitness_termination = True, tracked file untouched) · pop_size 100 · no fixed random seed anywhere in the repo, so a re-run will differ · a second, independent run reached winner fitness 0.6275 (not plotted here, see Figure 1)

Brute-force accuracy distribution across sampled feature pairs

Random Forest and Decision Tree accuracy across the same 245-of-496 sampled 2-feature combinations as Figure 1's brute-force row, 0.05-wide bins, zero-based count axis. The dashed line is the majority-class baseline, 43.14% (22 of 51 samples). Most sampled pairs score below that baseline in both distributions: a single hand-picked 2-feature pair usually does worse than just guessing the majority class, which is why the search sweeps many combinations instead of picking one by hand.

Random Forest (n=245)Decision Tree (n=245)020406080100count0%10%20%30%40%50%60%accuracy (10-fold CV)majority-class baseline 43.14% (22/51)
results · brute_force.py eval_classifier() / RandomForestClassifier / DecisionTreeClassifier / KFold(10), OAT1OAT3Small.csv · same 245-of-496 sampled run as Figure 1's brute-force row, raw per-combination results saved to scratch JSON this session, histogram computed directly from that file

results in figures · drug-transporter-ml

Tradeoffs and lessons

Brute force is only tractable because k stays small. C(32, 2) is 496 combinations, but C(32, 6) is over 900,000, each needing two classifiers fit under 10-fold CV. The exhaustive-search guarantee only holds for the small-k regime the paper actually used. The CLI is interactive-only, a while True loop of input() prompts, fine for one person running experiments by hand, but there is no way to script a full sweep across datasets, algorithms, and seeds without hacking around stdin.

The strongest lesson from this audit is about maintenance debt in research code: a dependency floor with no upper bound and no CI meant a config-schema change three major versions upstream silently broke two of the three documented entry points, and nobody would know until they actually tried to run them, since the README's instructions look complete and correct on paper. The brute-force path, which happens to be the one that does not touch NEAT config parsing at all, is the only one that still works untouched. That is not a flaw in the original 2020 work so much as what happens to any unmaintained research repo left alone for years against a fast-moving dependency. The fix is one line per config file, but finding it required actually running the code, not just reading it.

notes

  • The two NEAT-based algorithms (feature_selection_GA.py, feature_engineering_GA.py) do not run as tracked: all four bundled config files are missing a required neat-python 2.0.0 parameter. The one-line fix was confirmed in a scratch copy only; the tracked repo files were not modified.
  • No automated tests exist in the repository. The brute-force path is the only one of the three algorithms verified to run untouched.
← All projects