Interpretable Machine Learning as a Key to Understanding BBB Permeability

May 24, 2023·
Yassir Boulaamane
Yassir Boulaamane
· 6 min read

A model that says “crosses” or “does not cross” is unfinished until it also says which property of the molecule pushed the decision. On a blood-brain barrier table that question is answerable, because every row already has a label and a block of chemical descriptors.

The table is B3DB, the curated set of Meng, Xi, Huang, and Ayers [1]. The 2021 paper reports 7807 compounds with a categorical BBB+ or BBB− label, and a smaller subset of 1058 compounds with a numeric logBB. This note uses the categorical extended file, which carries 1613 Mordred descriptors. The code pins one commit of that file so the columns do not drift under you. Later releases of the repository have added compounds. The paper’s count and the file you download are allowed to differ. Say which one you used.

Prediction and explanation on the same descriptorsMordred descriptors from each molecule go into a random forest, which votes BBB+ or BBB-. SHAP then attributes that vote to individual descriptors such as topological polar surface area.The vote and the reason come apartSame descriptors. Two different questions.MoleculeMordreddescriptorsRandom forestTrained on 70%Tested on 30%BBB+ or BBB−SHAP on a descriptor
Figure 1. The forest's class is a vote. SHAP is a second calculation that spreads that vote back across the descriptors. Impurity importance, printed later from the forest itself, is a third and coarser ranking. They are not required to agree.

The shell line uses a notebook shortcut. In a terminal, drop the exclamation mark. The commit hash is the pin.

wget https://github.com/theochem/B3DB/raw/87240af2b4e585d56f9681a6426af6b7f2940e96/B3DB/B3DB_classification_extended.tsv.gz
import gzip
import shutil

import pandas as pd

with gzip.open("B3DB_classification_extended.tsv.gz", "rb") as src:
    with open("B3DB_classification_extended.tsv", "wb") as dst:
        shutil.copyfileobj(src, dst)

df = pd.read_csv("B3DB_classification_extended.tsv", sep="\t")
df.head()
First rows of the extended B3DB classification table
Figure 2. The extended classification table as loaded. Identifiers and the BBB label sit in front of the Mordred block.

The label, then the descriptor block

The label column is BBB+/BBB-. Map the two strings and drop anything else, so a blank cell cannot become a positive example by falling through an else.

The Mordred columns are the 1613 fields that begin at ABC and run to the end of the table (mZagreb2 in this file). Select them by that name. A positional slice that starts at column 6 starts on the label itself, takes in identifiers and comments, and stops near VE1_Dzpe, hundreds of columns before TopoPSA. A dependence plot for TopoPSA is only meaningful if TopoPSA was one of the columns the forest saw. In this file it is column 1568, inside the Mordred block and outside that positional slice.

dropna(axis=1) then removes any descriptor that is missing for even one molecule. That is a blunt filter. One failed calculation deletes the column for the whole set. It is applied after the descriptor block has been isolated, so it cannot delete the label or shift the remaining columns into the wrong place.

label_map = {"BBB+": 1, "BBB-": 0}
df = df[df["BBB+/BBB-"].isin(label_map)].copy()
df["labels"] = df["BBB+/BBB-"].map(label_map)

descriptors = df.loc[:, "ABC":].apply(pd.to_numeric, errors="coerce")
descriptors = descriptors.dropna(axis=1)
descriptors.head()
Table after columns with missing values were removed
Figure 3. The notebook's view after columns with missing values were dropped. The rewritten code drops those columns only inside the Mordred block.

Fit on 70 percent

The split is stratified so the BBB+ and BBB− fractions match in the train and test sets. Thirty percent is held out. random_state is fixed on both the split and the forest, so a second run of this notebook hits the same rows and the same trees. The original forest call left the trees unseeded.

from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score
from sklearn.model_selection import train_test_split

X = descriptors.reset_index(drop=True)
y = df["labels"].reset_index(drop=True)

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42, shuffle=True, stratify=y
)

rf = RandomForestClassifier(random_state=42)
rf.fit(X_train, y_train)

y_pred = rf.predict(X_test)
print(classification_report(y_test, y_pred))

roc_auc = roc_auc_score(y_test, rf.predict_proba(X_test)[:, 1])
print("ROC AUC:", roc_auc)

classification_report wants the true labels first and the predictions second. The report in Figure 4 was printed with those arguments reversed, so the precision and recall it shows are exchanged relative to scikit-learn’s definition. A rerun of the code above will not match that image cell for cell.

The ROC AUC is computed from the predicted probability of the positive class. An AUC computed from the hard 0/1 predictions is a different statistic. This page does not quote a number for either, because the value belongs to the fit you just ran. Read the printout.

Classification report printed by the original notebook
Figure 4. Classification report as printed in the original notebook. Treat it as a picture of that run. The call above passes the arguments in scikit-learn's order.

Which descriptors the forest used

Impurity importance is the forest’s own score: how much each descriptor reduced impurity across the trees. It favors descriptors that can split in many places. It is a reason to look, not a mechanism.

ranking = (
    pd.DataFrame({
        "features": X.columns,
        "Imp_values": rf.feature_importances_,
    })
    .sort_values("Imp_values", ascending=False)
)
ranking.head(15)
Descriptors ranked by random-forest impurity importance
Figure 5. Impurity importances from the notebook, largest first. TopoPSA is the descriptor the rest of this note follows into the SHAP plots.

SHAP

SHAP attributes a prediction to the descriptors. The calls below match the interface in which a tree explainer returns one array per class, and index 0 is the first class. If your installed SHAP returns a single three-dimensional array instead of that list, index the class axis explicitly. Do not assume [0] still means what it meant when these figures were drawn.

pip install shap
import shap

explain = shap.Explainer(rf)
shap_values = explain.shap_values(X_test)

shap.summary_plot(shap_values, X_test)
SHAP summary plot for the test set
Figure 6. Global SHAP summary for the test set from this notebook.
shap.summary_plot(shap_values[0], X_test)
SHAP summary plot for the first class
Figure 7. The same summary restricted to the first class array, shap_values[0], which this SHAP interface treats as class 0. In the labelling above, class 0 is BBB−.
shap.dependence_plot("TopoPSA", shap_values[0], X_test)
SHAP dependence plot for TopoPSA
Figure 8. SHAP dependence plot for topological polar surface area, TopoPSA, on the class-0 values.

Bottom line

TopoPSA came out at the top of the importance table in the run these figures record, and the dependence plot is there so that ranking can be looked at rather than quoted. Polar surface area is already a familiar constraint on CNS exposure, which makes the result plausible. It does not make it automatic. A different split, a different forest, or a descriptor block that accidentally excluded TopoPSA would have told a different story.

The useful pair is the held-out score and the attribution. The score says whether the forest separated BBB+ from BBB− on molecules it did not train on. SHAP says which descriptors carried that separation. Impurity importance is only the shortlist.

Reference

[1] Meng, F., Xi, Y., Huang, J., and Ayers, P. W. A curated diverse molecular database of blood-brain barrier permeability with chemical descriptors. Scientific Data, 2021, 8, 289. https://doi.org/10.1038/s41597-021-01069-5