mlmolprop 1.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mlmolprop/__init__.py +92 -0
- mlmolprop/basic.py +154 -0
- mlmolprop/correlation.py +80 -0
- mlmolprop/descriptors.py +279 -0
- mlmolprop/fingerprint.py +172 -0
- mlmolprop/image.py +97 -0
- mlmolprop/importance.py +151 -0
- mlmolprop/model.py +2430 -0
- mlmolprop/molprep.py +224 -0
- mlmolprop/plots.py +117 -0
- mlmolprop/processing.py +512 -0
- mlmolprop-1.9.1.dist-info/METADATA +215 -0
- mlmolprop-1.9.1.dist-info/RECORD +15 -0
- mlmolprop-1.9.1.dist-info/WHEEL +4 -0
- mlmolprop-1.9.1.dist-info/licenses/LICENSE +21 -0
mlmolprop/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""mlmolprop: cheminformatics and QSAR modeling toolkit built on RDKit and scikit-learn."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from .basic import (
|
|
6
|
+
RMSEP_CV_C,
|
|
7
|
+
F,
|
|
8
|
+
analyse,
|
|
9
|
+
makecolumn,
|
|
10
|
+
press,
|
|
11
|
+
press_m,
|
|
12
|
+
press_root,
|
|
13
|
+
q2r2,
|
|
14
|
+
r2test,
|
|
15
|
+
twodlist,
|
|
16
|
+
)
|
|
17
|
+
from .correlation import find_correlation
|
|
18
|
+
from .descriptors import CI, dataframe, desc, tanimoto
|
|
19
|
+
from .fingerprint import Fingerprint, explicit_bitvect_to_numpy_array, make_fingerprints
|
|
20
|
+
from .image import highlight, images_to_dataframe, svg_files_to_png
|
|
21
|
+
from .importance import lime_explain, partial
|
|
22
|
+
from .model import (
|
|
23
|
+
Model,
|
|
24
|
+
ModelC,
|
|
25
|
+
clus_uns,
|
|
26
|
+
metr,
|
|
27
|
+
plot_confusion_matrix,
|
|
28
|
+
plot_w,
|
|
29
|
+
rocc,
|
|
30
|
+
safe_divide,
|
|
31
|
+
)
|
|
32
|
+
from .molprep import RDimage, mol_enumerate, mol_prep, moltosvg
|
|
33
|
+
from .plots import plot_features
|
|
34
|
+
from .processing import (
|
|
35
|
+
SelectKBest_selector,
|
|
36
|
+
VarianceThreshold_selector,
|
|
37
|
+
average_bygroup,
|
|
38
|
+
data_prep,
|
|
39
|
+
file2list,
|
|
40
|
+
file2object,
|
|
41
|
+
list2file,
|
|
42
|
+
object2file,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__version__ = version("mlmolprop")
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"CI",
|
|
49
|
+
"RMSEP_CV_C",
|
|
50
|
+
"F",
|
|
51
|
+
"Fingerprint",
|
|
52
|
+
"Model",
|
|
53
|
+
"ModelC",
|
|
54
|
+
"RDimage",
|
|
55
|
+
"SelectKBest_selector",
|
|
56
|
+
"VarianceThreshold_selector",
|
|
57
|
+
"analyse",
|
|
58
|
+
"average_bygroup",
|
|
59
|
+
"clus_uns",
|
|
60
|
+
"data_prep",
|
|
61
|
+
"dataframe",
|
|
62
|
+
"desc",
|
|
63
|
+
"explicit_bitvect_to_numpy_array",
|
|
64
|
+
"file2list",
|
|
65
|
+
"file2object",
|
|
66
|
+
"find_correlation",
|
|
67
|
+
"highlight",
|
|
68
|
+
"images_to_dataframe",
|
|
69
|
+
"lime_explain",
|
|
70
|
+
"list2file",
|
|
71
|
+
"make_fingerprints",
|
|
72
|
+
"makecolumn",
|
|
73
|
+
"metr",
|
|
74
|
+
"mol_enumerate",
|
|
75
|
+
"mol_prep",
|
|
76
|
+
"moltosvg",
|
|
77
|
+
"object2file",
|
|
78
|
+
"partial",
|
|
79
|
+
"plot_confusion_matrix",
|
|
80
|
+
"plot_features",
|
|
81
|
+
"plot_w",
|
|
82
|
+
"press",
|
|
83
|
+
"press_m",
|
|
84
|
+
"press_root",
|
|
85
|
+
"q2r2",
|
|
86
|
+
"r2test",
|
|
87
|
+
"rocc",
|
|
88
|
+
"safe_divide",
|
|
89
|
+
"svg_files_to_png",
|
|
90
|
+
"tanimoto",
|
|
91
|
+
"twodlist",
|
|
92
|
+
]
|
mlmolprop/basic.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Core statistics helpers used for QSAR model evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _check_same_length(a: np.ndarray, b: np.ndarray) -> None:
|
|
9
|
+
if a.shape != b.shape:
|
|
10
|
+
raise ValueError(f"inputs must be the same length, got {len(a)} and {len(b)}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def press(observed, predicted) -> float:
|
|
14
|
+
"""Sum of squared residuals (a.k.a. PRESS/RSS) between two sequences."""
|
|
15
|
+
observed = np.asarray(observed, dtype=float)
|
|
16
|
+
predicted = np.asarray(predicted, dtype=float)
|
|
17
|
+
_check_same_length(observed, predicted)
|
|
18
|
+
return float(np.sum((observed - predicted) ** 2))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def press_root(observed, predicted) -> float:
|
|
22
|
+
"""Sum of absolute residuals between two sequences."""
|
|
23
|
+
observed = np.asarray(observed, dtype=float)
|
|
24
|
+
predicted = np.asarray(predicted, dtype=float)
|
|
25
|
+
_check_same_length(observed, predicted)
|
|
26
|
+
return float(np.sum(np.abs(observed - predicted)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def press_m(values, reference) -> float:
|
|
30
|
+
"""Sum of squared deviations of ``values`` from ``mean(reference)``.
|
|
31
|
+
|
|
32
|
+
``reference`` may be a precomputed scalar mean (``mean`` of a scalar is
|
|
33
|
+
itself, so this is a no-op) or an array-like whose mean is used as the
|
|
34
|
+
reference point. Both call styles are used elsewhere in this module.
|
|
35
|
+
"""
|
|
36
|
+
values = np.asarray(values, dtype=float)
|
|
37
|
+
reference_mean = np.mean(reference)
|
|
38
|
+
return float(np.sum((values - reference_mean) ** 2))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def q2r2(obs, pred) -> float:
|
|
42
|
+
"""Q2/R2 = 1 - PRESS / total sum of squares of ``obs``."""
|
|
43
|
+
obs = np.asarray(obs, dtype=float)
|
|
44
|
+
ss_tot = press_m(obs, np.mean(obs))
|
|
45
|
+
if ss_tot == 0:
|
|
46
|
+
return 0.0
|
|
47
|
+
return 1 - press(obs, pred) / ss_tot
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def r2test(obs, pred, train) -> float:
|
|
51
|
+
"""External R2 of a test set, using the training set's mean as reference."""
|
|
52
|
+
ss_tot = press_m(obs, train)
|
|
53
|
+
if ss_tot == 0:
|
|
54
|
+
return 0.0
|
|
55
|
+
return 1 - press(obs, pred) / ss_tot
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def RMSEP_CV_C(obs, pred, k: int = 0) -> float:
|
|
59
|
+
"""Root mean square error, optionally adjusted for degrees of freedom.
|
|
60
|
+
|
|
61
|
+
With ``k=0`` this is the plain RMSE (external test set or LOO-CV set).
|
|
62
|
+
With ``k>0`` the denominator is adjusted by the number of predictors/
|
|
63
|
+
components ``k`` (training-set calibration RMSE, a.k.a. SEE).
|
|
64
|
+
"""
|
|
65
|
+
ssr = press(obs, pred)
|
|
66
|
+
n = len(obs)
|
|
67
|
+
denom = n if k == 0 else n - k - 1
|
|
68
|
+
if denom <= 0:
|
|
69
|
+
raise ValueError(f"not enough observations ({n}) for k={k} predictors")
|
|
70
|
+
return float(np.sqrt(ssr / denom))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def F(obs, pred, k: int) -> float:
|
|
74
|
+
"""F-statistic (MSR / MSE) for a training-set regression with k predictors."""
|
|
75
|
+
obs = np.asarray(obs, dtype=float)
|
|
76
|
+
n = len(obs)
|
|
77
|
+
denom = n - k - 1
|
|
78
|
+
if denom <= 0:
|
|
79
|
+
raise ValueError(f"not enough observations ({n}) for k={k} predictors")
|
|
80
|
+
msr = press_m(pred, np.mean(obs)) / k
|
|
81
|
+
mse = press(obs, pred) / denom
|
|
82
|
+
return msr / mse
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def analyse(ytrain, y_pred_train, ytest, y_pred_test, ycv1, ycv2, k: int) -> dict:
|
|
86
|
+
"""Compute a standard set of QSAR model-quality metrics.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
ytrain, y_pred_train : array-like
|
|
91
|
+
Observed and predicted values on the training set.
|
|
92
|
+
ytest, y_pred_test : array-like
|
|
93
|
+
Observed and predicted values on the external test set.
|
|
94
|
+
ycv1, ycv2 : array-like
|
|
95
|
+
Observed and predicted values under cross-validation.
|
|
96
|
+
k : int
|
|
97
|
+
Number of predictors (or latent components). The cross-validation
|
|
98
|
+
formula used here assumes leave-one-out CV; it is not a valid
|
|
99
|
+
adjustment for k-fold CV.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
dict
|
|
104
|
+
R2, R2_Adj, R2_test, F, q2, RMSE/MAE for train, test, and CV.
|
|
105
|
+
"""
|
|
106
|
+
n_train = len(ytrain)
|
|
107
|
+
r2 = q2r2(ytrain, y_pred_train)
|
|
108
|
+
# Both R2_Adj and F encode classical OLS degrees-of-freedom (n > k+1, k
|
|
109
|
+
# literally-fit linear parameters) -- meaningful for M in {mlr, pls,
|
|
110
|
+
# lasso, ...}, but not a real constraint for RF/SVM/tree-style
|
|
111
|
+
# regressors, whose capacity isn't controlled by feature count the same
|
|
112
|
+
# way. Reporting NaN here instead of raising/dividing-by-zero mirrors
|
|
113
|
+
# ModelC(), which has no such constraint, and lets those model types run
|
|
114
|
+
# on any feature count.
|
|
115
|
+
if n_train - k - 1 > 0:
|
|
116
|
+
r2_adj = 1 - (((n_train - 1) / (n_train - k - 1)) * (1 - r2))
|
|
117
|
+
else:
|
|
118
|
+
r2_adj = float("nan")
|
|
119
|
+
q2 = q2r2(ycv1, ycv2)
|
|
120
|
+
r2_test = r2test(ytest, y_pred_test, ytrain)
|
|
121
|
+
try:
|
|
122
|
+
f = F(ytrain, y_pred_train, k)
|
|
123
|
+
except ValueError:
|
|
124
|
+
f = float("nan")
|
|
125
|
+
rmse_train = RMSEP_CV_C(ytrain, y_pred_train)
|
|
126
|
+
mae_train = press_root(ytrain, y_pred_train) / n_train
|
|
127
|
+
rmse_cv = RMSEP_CV_C(ycv1, ycv2)
|
|
128
|
+
mae_cv = press_root(ycv1, ycv2) / len(ycv2)
|
|
129
|
+
rmse_test = RMSEP_CV_C(ytest, y_pred_test)
|
|
130
|
+
mae_test = press_root(ytest, y_pred_test) / len(ytest)
|
|
131
|
+
return {
|
|
132
|
+
"R2": r2,
|
|
133
|
+
"R2_Adj": r2_adj,
|
|
134
|
+
"R2_test": r2_test,
|
|
135
|
+
"F": f,
|
|
136
|
+
"q2": q2,
|
|
137
|
+
"RMSE_train": rmse_train,
|
|
138
|
+
"MAE_train": mae_train,
|
|
139
|
+
"RMSE_test": rmse_test,
|
|
140
|
+
"MAE_test": mae_test,
|
|
141
|
+
"RMSE_CV": rmse_cv,
|
|
142
|
+
"MAE_CV": mae_cv,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def twodlist(m: int, n: int) -> list[list[str]]:
|
|
147
|
+
"""Build an m x n list of lists pre-filled with the placeholder "none"."""
|
|
148
|
+
return [["none"] * n for _ in range(m)]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def makecolumn(data, c: int) -> list[list]:
|
|
152
|
+
"""Return the first ``c`` columns of ``data`` as a list of Python lists."""
|
|
153
|
+
arr = np.asarray(data)
|
|
154
|
+
return [list(arr[:, j]) for j in range(c)]
|
mlmolprop/correlation.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Detect and select for removal highly correlated numeric features."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def find_correlation(data: pd.DataFrame, threshold: float = 0.9) -> list[str]:
|
|
10
|
+
"""Find features that are redundant due to high pairwise correlation.
|
|
11
|
+
|
|
12
|
+
For every pair of features whose absolute correlation exceeds
|
|
13
|
+
``threshold``, one feature from the pair is kept and the rest are
|
|
14
|
+
marked for removal, so that at most one representative of each
|
|
15
|
+
correlated cluster survives.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
data : pandas.DataFrame
|
|
20
|
+
Numeric feature matrix (columns are features).
|
|
21
|
+
threshold : float, default 0.9
|
|
22
|
+
Absolute correlation threshold in ``(0, 1]``; pairs whose absolute
|
|
23
|
+
correlation exceeds this value are considered redundant.
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
list[str]
|
|
28
|
+
Column names recommended for removal.
|
|
29
|
+
"""
|
|
30
|
+
if not 0 < threshold <= 1:
|
|
31
|
+
raise ValueError(f"threshold must be in (0, 1], got {threshold}")
|
|
32
|
+
|
|
33
|
+
data = pd.DataFrame(data)
|
|
34
|
+
columns = list(data.columns)
|
|
35
|
+
corr_arr = data.corr().abs().to_numpy()
|
|
36
|
+
|
|
37
|
+
# Union-find over pairs whose correlation exceeds threshold, so columns
|
|
38
|
+
# linked only transitively (A~B and B~C both exceed threshold, even if
|
|
39
|
+
# A~C doesn't) end up in the same cluster rather than being resolved as
|
|
40
|
+
# two independent pairs that can each keep a representative that turns
|
|
41
|
+
# out to be correlated with the other pair's representative.
|
|
42
|
+
parent = {c: c for c in columns}
|
|
43
|
+
|
|
44
|
+
def find(c: str) -> str:
|
|
45
|
+
while parent[c] != c:
|
|
46
|
+
parent[c] = parent[parent[c]]
|
|
47
|
+
c = parent[c]
|
|
48
|
+
return c
|
|
49
|
+
|
|
50
|
+
def union(a: str, b: str) -> None:
|
|
51
|
+
ra, rb = find(a), find(b)
|
|
52
|
+
if ra != rb:
|
|
53
|
+
parent[ra] = rb
|
|
54
|
+
|
|
55
|
+
# Scanning every pair via DataFrame.loc in a Python loop is O(n^2) with
|
|
56
|
+
# substantial per-lookup overhead -- minutes, not seconds, past a few
|
|
57
|
+
# hundred columns (e.g. ~2 min for 2048 ECFP4 bits). np.triu_indices
|
|
58
|
+
# walks the upper triangle in the same row-major (i, then j > i) order
|
|
59
|
+
# the original nested loop used, so the union() call sequence -- and
|
|
60
|
+
# therefore which survivor each cluster keeps -- is unchanged; only the
|
|
61
|
+
# "is this pair above threshold" scan itself is vectorized.
|
|
62
|
+
iu, ju = np.triu_indices(len(columns), k=1)
|
|
63
|
+
above = corr_arr[iu, ju] > threshold
|
|
64
|
+
for i, j in zip(iu[above], ju[above], strict=True):
|
|
65
|
+
union(columns[i], columns[j])
|
|
66
|
+
|
|
67
|
+
clusters: dict[str, list[str]] = {}
|
|
68
|
+
for c in columns:
|
|
69
|
+
clusters.setdefault(find(c), []).append(c)
|
|
70
|
+
|
|
71
|
+
to_remove: list[str] = []
|
|
72
|
+
for members in clusters.values():
|
|
73
|
+
if len(members) > 1:
|
|
74
|
+
# Keep the last column in original order, matching this
|
|
75
|
+
# function's existing convention for a simple two-column match
|
|
76
|
+
# (the earlier column is removed, the later one kept).
|
|
77
|
+
survivor = members[-1]
|
|
78
|
+
to_remove.extend(m for m in members if m != survivor)
|
|
79
|
+
|
|
80
|
+
return to_remove
|
mlmolprop/descriptors.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Compute RDKit molecular descriptors and build QSAR-ready datasets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from rdkit import DataStructs
|
|
8
|
+
from rdkit.Chem import (
|
|
9
|
+
AllChem,
|
|
10
|
+
Descriptors,
|
|
11
|
+
MACCSkeys,
|
|
12
|
+
PandasTools,
|
|
13
|
+
SDMolSupplier,
|
|
14
|
+
SmilesMolSupplier,
|
|
15
|
+
)
|
|
16
|
+
from rdkit.Chem.Fingerprints import FingerprintMols
|
|
17
|
+
from rdkit.ML.Descriptors import MoleculeDescriptors
|
|
18
|
+
|
|
19
|
+
from .basic import makecolumn, twodlist
|
|
20
|
+
|
|
21
|
+
_SOURCE_TYPES = {"sdf", "smi", "molecule"}
|
|
22
|
+
_DESCRIPTOR_SETS = {"all", "positive", "interpretable"}
|
|
23
|
+
|
|
24
|
+
positive_prefixes = [
|
|
25
|
+
"fr_", # fragment counts (~110 descriptors)
|
|
26
|
+
"Num", # counts: NumAtoms, NumRings, etc.
|
|
27
|
+
"MolWt",
|
|
28
|
+
"ExactMolWt",
|
|
29
|
+
"HeavyAtomMolWt",
|
|
30
|
+
"TPSA",
|
|
31
|
+
"LabuteASA",
|
|
32
|
+
"SPS",
|
|
33
|
+
"qed",
|
|
34
|
+
"PEOE_VSA",
|
|
35
|
+
"SMR_VSA",
|
|
36
|
+
"SlogP_VSA", # surface area families
|
|
37
|
+
]
|
|
38
|
+
# Filter RDKit's full descriptor list to only those starting with the above prefixes
|
|
39
|
+
positive_desc_names = [
|
|
40
|
+
name
|
|
41
|
+
for name, _ in Descriptors.descList
|
|
42
|
+
if any(name.startswith(p) for p in positive_prefixes)
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
easy_prefixes = ["fr_", "Num", "MolWt", "ExactMolWt", "HeavyAtomMolWt", "TPSA"]
|
|
46
|
+
easy_desc_names = [
|
|
47
|
+
name
|
|
48
|
+
for name, _ in Descriptors.descList
|
|
49
|
+
if any(name.startswith(p) for p in easy_prefixes)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def desc(
|
|
54
|
+
molecules, source: str = "molecule", delimiter: str = ",", selected_des: str = "all"
|
|
55
|
+
) -> list:
|
|
56
|
+
"""Compute RDKit descriptors for a set of molecules.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
molecules
|
|
61
|
+
Path to an SDF/SMILES file, or (if ``source="molecule"``) an
|
|
62
|
+
already-loaded list of RDKit Mol objects. Each molecule must have
|
|
63
|
+
a ``_Name`` property set.
|
|
64
|
+
source : {"sdf", "smi", "molecule"}, default "molecule"
|
|
65
|
+
delimiter : str, default ","
|
|
66
|
+
Field delimiter, used only when ``source="smi"``.
|
|
67
|
+
selected_des : {"all", "positive", "interpretable"}, default "all"
|
|
68
|
+
"positive" restricts to descriptors that are inherently
|
|
69
|
+
non-negative (counts, fragment counts, surface areas, ...);
|
|
70
|
+
"interpretable" restricts to a smaller, easy-to-explain subset.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
list
|
|
75
|
+
``[names, descriptor_names, descriptor_values]``: molecule names,
|
|
76
|
+
the computed descriptor names, and a list of per-molecule
|
|
77
|
+
descriptor value tuples.
|
|
78
|
+
"""
|
|
79
|
+
if source == "sdf":
|
|
80
|
+
molecules_list = SDMolSupplier(molecules)
|
|
81
|
+
elif source == "smi":
|
|
82
|
+
molecules_list = SmilesMolSupplier(
|
|
83
|
+
molecules, delimiter=delimiter, titleLine=True, smilesColumn=0, nameColumn=1
|
|
84
|
+
)
|
|
85
|
+
elif source == "molecule":
|
|
86
|
+
molecules_list = molecules
|
|
87
|
+
else:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"source must be one of {sorted(_SOURCE_TYPES)}, got {source!r}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if selected_des == "all":
|
|
93
|
+
descriptor_names = [x[0] for x in Descriptors.descList]
|
|
94
|
+
elif selected_des == "positive":
|
|
95
|
+
descriptor_names = positive_desc_names
|
|
96
|
+
elif selected_des == "interpretable":
|
|
97
|
+
descriptor_names = easy_desc_names
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"selected_des must be one of {sorted(_DESCRIPTOR_SETS)}, got {selected_des!r}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
names = len(molecules_list) * ["null"]
|
|
104
|
+
values = len(molecules_list) * ["null"]
|
|
105
|
+
calc = MoleculeDescriptors.MolecularDescriptorCalculator(descriptor_names)
|
|
106
|
+
for i in range(len(molecules_list)):
|
|
107
|
+
values[i] = calc.CalcDescriptors(molecules_list[i])
|
|
108
|
+
names[i] = molecules_list[i].GetProp("_Name")
|
|
109
|
+
|
|
110
|
+
return [names, descriptor_names, values]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def dataframe(
|
|
114
|
+
desc_result,
|
|
115
|
+
input_activities: str | None,
|
|
116
|
+
output: str,
|
|
117
|
+
target: str = "IC50",
|
|
118
|
+
sdf_file: str | None = None,
|
|
119
|
+
source: str = "des",
|
|
120
|
+
) -> pd.DataFrame:
|
|
121
|
+
"""Merge computed descriptors/fingerprints with an activity table into a QSAR dataset.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
desc_result
|
|
126
|
+
Output of :func:`desc` (``source="des"``), or raw fingerprint
|
|
127
|
+
data indexable by ``pandas.DataFrame`` (``source in
|
|
128
|
+
{"finger", "image"}``).
|
|
129
|
+
input_activities : str or None
|
|
130
|
+
Path to a CSV of activities with a "name" column matching the
|
|
131
|
+
molecule names in ``desc_result``. If ``None``, the descriptor/
|
|
132
|
+
fingerprint DataFrame is written out as-is -- useful for scoring
|
|
133
|
+
an external set with no known activity.
|
|
134
|
+
output : str
|
|
135
|
+
Path the merged (or standalone) DataFrame is written to as CSV.
|
|
136
|
+
target : str, default "IC50"
|
|
137
|
+
Activity column to pull from ``sdf_file`` when generating
|
|
138
|
+
``input_activities`` from an SDF.
|
|
139
|
+
sdf_file : str or None
|
|
140
|
+
If given, activities are extracted from this SDF's "ID" and
|
|
141
|
+
``target`` fields and written to ``input_activities`` before
|
|
142
|
+
merging.
|
|
143
|
+
source : {"des", "finger", "image"}, default "des"
|
|
144
|
+
Shape of ``desc_result``.
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
pandas.DataFrame
|
|
149
|
+
"""
|
|
150
|
+
if sdf_file is not None:
|
|
151
|
+
sdftable = PandasTools.LoadSDF(sdf_file)
|
|
152
|
+
activity_df = sdftable.loc[:, ["ID", target]].rename(columns={"ID": "name"})
|
|
153
|
+
activity_df.to_csv(input_activities, index=False)
|
|
154
|
+
|
|
155
|
+
if source in ("finger", "image"):
|
|
156
|
+
df1 = pd.DataFrame(desc_result)
|
|
157
|
+
elif source == "des":
|
|
158
|
+
df1 = pd.DataFrame(desc_result[2], index=desc_result[0], columns=desc_result[1])
|
|
159
|
+
else:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"source must be one of 'des', 'finger', 'image', got {source!r}"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if input_activities is not None:
|
|
165
|
+
reader = pd.read_csv(input_activities)
|
|
166
|
+
reader["name"] = reader["name"].astype(str)
|
|
167
|
+
merged = pd.merge(reader, df1, right_index=True, left_on="name")
|
|
168
|
+
else:
|
|
169
|
+
merged = df1
|
|
170
|
+
|
|
171
|
+
merged.to_csv(output, index=False)
|
|
172
|
+
return merged
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def tanimoto(molecules_list, names, output: str, kind: str = "fps") -> pd.DataFrame:
|
|
176
|
+
"""Compute a pairwise Tanimoto similarity matrix for a list of molecules.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
molecules_list : list of rdkit.Chem.Mol
|
|
181
|
+
names : list
|
|
182
|
+
Molecule names/labels, used as the DataFrame's index and columns.
|
|
183
|
+
output : str
|
|
184
|
+
Path the similarity matrix is written to as CSV.
|
|
185
|
+
kind : {"fps", "maccs", "ecfp", "fcfp"}, default "fps"
|
|
186
|
+
Fingerprint used for similarity: Daylight ("fps"), MACCS keys,
|
|
187
|
+
Morgan/ECFP, or feature-based Morgan/FCFP.
|
|
188
|
+
"""
|
|
189
|
+
if kind == "fps":
|
|
190
|
+
fps = [FingerprintMols.FingerprintMol(m) for m in molecules_list]
|
|
191
|
+
elif kind == "maccs":
|
|
192
|
+
fps = [MACCSkeys.GenMACCSKeys(m) for m in molecules_list]
|
|
193
|
+
elif kind == "ecfp":
|
|
194
|
+
fps = [AllChem.GetMorganFingerprint(m, 6) for m in molecules_list]
|
|
195
|
+
elif kind == "fcfp":
|
|
196
|
+
fps = [
|
|
197
|
+
AllChem.GetMorganFingerprint(m, 6, useFeatures=True) for m in molecules_list
|
|
198
|
+
]
|
|
199
|
+
else:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
f"kind must be one of 'fps', 'maccs', 'ecfp', 'fcfp', got {kind!r}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
n = len(fps)
|
|
205
|
+
table = twodlist(n, n)
|
|
206
|
+
for i, x in enumerate(fps):
|
|
207
|
+
for j, y in enumerate(fps):
|
|
208
|
+
table[i][j] = DataStructs.TanimotoSimilarity(x, y)
|
|
209
|
+
|
|
210
|
+
df = pd.DataFrame(table, index=names, columns=names)
|
|
211
|
+
df.to_csv(output)
|
|
212
|
+
return df
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def CI(
|
|
216
|
+
X_train, X_test, v_names, train_names, test_names, cutoff: float
|
|
217
|
+
) -> list[pd.DataFrame]:
|
|
218
|
+
"""Standardize train/test features against training-set statistics and flag outliers.
|
|
219
|
+
|
|
220
|
+
For each feature, z-scores (``|value - train_mean| / train_std``) are
|
|
221
|
+
computed for both train and test sets using the *training* set's mean
|
|
222
|
+
and standard deviation (features with zero training variance get a
|
|
223
|
+
z-score of 0). ``out_train``/``out_test`` keep only the z-scores that
|
|
224
|
+
exceed ``cutoff``, leaving the placeholder string ``"none"``
|
|
225
|
+
elsewhere -- matching the original R ``CI_Test`` this was ported from.
|
|
226
|
+
|
|
227
|
+
Returns
|
|
228
|
+
-------
|
|
229
|
+
list[pandas.DataFrame]
|
|
230
|
+
``[table_train, table_test, out_train, out_test]``, each a
|
|
231
|
+
z-score table indexed by ``train_names``/``test_names``.
|
|
232
|
+
"""
|
|
233
|
+
train = np.array(X_train)
|
|
234
|
+
test = np.array(X_test)
|
|
235
|
+
r, c = len(train), len(train[0])
|
|
236
|
+
r2 = len(test)
|
|
237
|
+
|
|
238
|
+
table_train = twodlist(r, c)
|
|
239
|
+
table_test = twodlist(r2, c)
|
|
240
|
+
out_train = twodlist(r, c)
|
|
241
|
+
out_test = twodlist(r2, c)
|
|
242
|
+
|
|
243
|
+
for i in range(r):
|
|
244
|
+
table_train[i] = train[i]
|
|
245
|
+
if i < r2:
|
|
246
|
+
table_test[i] = test[i]
|
|
247
|
+
|
|
248
|
+
column = makecolumn(table_train, c)
|
|
249
|
+
col_means = [np.mean(column[j]) for j in range(c)]
|
|
250
|
+
col_stds = [np.std(column[j]) for j in range(c)]
|
|
251
|
+
|
|
252
|
+
for i in range(r):
|
|
253
|
+
for j in range(c):
|
|
254
|
+
if col_stds[j] != 0:
|
|
255
|
+
table_train[i][j] = np.abs(
|
|
256
|
+
(table_train[i][j] - col_means[j]) / col_stds[j]
|
|
257
|
+
)
|
|
258
|
+
if i < r2:
|
|
259
|
+
table_test[i][j] = np.abs(
|
|
260
|
+
(table_test[i][j] - col_means[j]) / col_stds[j]
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
table_train[i][j] = 0
|
|
264
|
+
if i < r2:
|
|
265
|
+
table_test[i][j] = 0
|
|
266
|
+
|
|
267
|
+
for i in range(r):
|
|
268
|
+
for j in range(c):
|
|
269
|
+
if table_train[i][j] > cutoff:
|
|
270
|
+
out_train[i][j] = table_train[i][j]
|
|
271
|
+
if i < r2 and table_test[i][j] > cutoff:
|
|
272
|
+
out_test[i][j] = table_test[i][j]
|
|
273
|
+
|
|
274
|
+
return [
|
|
275
|
+
pd.DataFrame(table_train, columns=v_names, index=train_names),
|
|
276
|
+
pd.DataFrame(table_test, columns=v_names, index=test_names),
|
|
277
|
+
pd.DataFrame(out_train, columns=v_names, index=train_names),
|
|
278
|
+
pd.DataFrame(out_test, columns=v_names, index=test_names),
|
|
279
|
+
]
|