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 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)]
@@ -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
@@ -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
+ ]