mpml_lib 1.0.0__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.
- combination/bagging.py +102 -0
- combination/blending.py +239 -0
- combination/boosting.py +151 -0
- combination/stacking.py +212 -0
- combination/voting.py +289 -0
- mpml_lib-1.0.0.dist-info/METADATA +7 -0
- mpml_lib-1.0.0.dist-info/RECORD +9 -0
- mpml_lib-1.0.0.dist-info/WHEEL +5 -0
- mpml_lib-1.0.0.dist-info/top_level.txt +1 -0
combination/bagging.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from sklearn.ensemble import BaggingClassifier
|
|
4
|
+
from .voting import majority_vote
|
|
5
|
+
|
|
6
|
+
def train_bagging(
|
|
7
|
+
perspectives,
|
|
8
|
+
X_train,
|
|
9
|
+
y_train,
|
|
10
|
+
n_estimators=5,
|
|
11
|
+
random_state=42
|
|
12
|
+
):
|
|
13
|
+
"""
|
|
14
|
+
Train a bagging ensemble for each MPML perspective.
|
|
15
|
+
|
|
16
|
+
Each perspective uses its assigned feature subset.
|
|
17
|
+
BaggingClassifier automatically performs bootstrap
|
|
18
|
+
sampling and trains multiple copies of the model.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
trained_models: Dictionary of trained bagging ensembles.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# Dictionary to store the trained bagging models.
|
|
25
|
+
trained_models = {}
|
|
26
|
+
|
|
27
|
+
# Loop through each perspective.
|
|
28
|
+
for model_name in perspectives.get_dict():
|
|
29
|
+
|
|
30
|
+
# Get the model assigned to this perspective.
|
|
31
|
+
model = perspectives.get_model(model_name)
|
|
32
|
+
|
|
33
|
+
# Get the features assigned to this perspective.
|
|
34
|
+
feature_list = perspectives.get_features(model_name)
|
|
35
|
+
|
|
36
|
+
# Select only the features for this perspective.
|
|
37
|
+
X_perspective = X_train[feature_list]
|
|
38
|
+
|
|
39
|
+
# Create a bagging ensemble for this model.
|
|
40
|
+
bagging_model = BaggingClassifier(
|
|
41
|
+
estimator=model,
|
|
42
|
+
n_estimators=n_estimators,
|
|
43
|
+
max_samples=1.0,
|
|
44
|
+
bootstrap=True,
|
|
45
|
+
n_jobs=-1,
|
|
46
|
+
random_state=random_state
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Train the ensemble.
|
|
50
|
+
bagging_model.fit(X_perspective, y_train)
|
|
51
|
+
|
|
52
|
+
# Store the trained ensemble.
|
|
53
|
+
trained_models[model_name] = bagging_model
|
|
54
|
+
|
|
55
|
+
return trained_models
|
|
56
|
+
|
|
57
|
+
def bagging_predict(perspectives, trained_models, X_instance):
|
|
58
|
+
"""
|
|
59
|
+
Predict one instance using the trained bagging ensembles.
|
|
60
|
+
|
|
61
|
+
Each perspective contains a trained BaggingClassifier.
|
|
62
|
+
Each BaggingClassifier produces one final prediction.
|
|
63
|
+
|
|
64
|
+
These perspective predictions are then combined
|
|
65
|
+
using the existing majority_vote() function.
|
|
66
|
+
|
|
67
|
+
Parameters:
|
|
68
|
+
perspectives: Perspectives object containing feature lists.
|
|
69
|
+
trained_models: Dictionary of trained BaggingClassifier objects.
|
|
70
|
+
X_instance: DataFrame containing exactly one row.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Final predicted class.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# Check that an instance was provided correctly.
|
|
77
|
+
if not isinstance(X_instance, pd.DataFrame):
|
|
78
|
+
raise TypeError("X_instance must be a DataFrame.")
|
|
79
|
+
|
|
80
|
+
if len(X_instance) != 1:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"X_instance must contain exactly one row."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Make sure the bagging models have been trained.
|
|
86
|
+
if not trained_models:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"No trained bagging models were supplied."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Reuse the existing majority voting function.
|
|
92
|
+
#
|
|
93
|
+
# Each BaggingClassifier supports predict()
|
|
94
|
+
# and predict_proba(), which are used by
|
|
95
|
+
# majority_vote() to combine model decisions.
|
|
96
|
+
prediction = majority_vote(
|
|
97
|
+
perspectives,
|
|
98
|
+
trained_models,
|
|
99
|
+
X_instance
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return prediction
|
combination/blending.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
from sklearn.base import clone
|
|
5
|
+
from sklearn.model_selection import train_test_split
|
|
6
|
+
from sklearn.linear_model import LogisticRegression
|
|
7
|
+
|
|
8
|
+
def train_blending(
|
|
9
|
+
perspectives,
|
|
10
|
+
X_train,
|
|
11
|
+
y_train,
|
|
12
|
+
meta_model=None,
|
|
13
|
+
meta_train_size=0.20,
|
|
14
|
+
random_state=42
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Train a blending ensemble independently of the MPML class.
|
|
18
|
+
|
|
19
|
+
Parameters:
|
|
20
|
+
perspectives: Perspectives object containing the models
|
|
21
|
+
and their assigned feature lists.
|
|
22
|
+
X_train: Original training DataFrame.
|
|
23
|
+
y_train: Original target labels.
|
|
24
|
+
meta_model: Optional scikit-learn classifier.
|
|
25
|
+
meta_train_size: Fraction used for meta-model training.
|
|
26
|
+
random_state: Random seed for reproducibility.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
trained_models:
|
|
30
|
+
Dictionary containing the trained base models.
|
|
31
|
+
|
|
32
|
+
trained_meta_model:
|
|
33
|
+
Trained blending meta-model.
|
|
34
|
+
|
|
35
|
+
meta_feature_names:
|
|
36
|
+
Ordered column names used by the meta-model.
|
|
37
|
+
|
|
38
|
+
X_base_train:
|
|
39
|
+
Features used to train the base models.
|
|
40
|
+
|
|
41
|
+
y_base_train:
|
|
42
|
+
Target labels used to train the base models.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# 1. Validate the supplied training dataset.
|
|
46
|
+
if not isinstance(X_train, pd.DataFrame):
|
|
47
|
+
raise TypeError("X_train must be a pandas DataFrame.")
|
|
48
|
+
|
|
49
|
+
if len(X_train) != len(y_train):
|
|
50
|
+
raise ValueError("X_train and y_train must have equal lengths.")
|
|
51
|
+
|
|
52
|
+
if len(perspectives.get_dict()) == 0:
|
|
53
|
+
raise ValueError("No perspectives have been added.")
|
|
54
|
+
|
|
55
|
+
# 2. Split the original training dataset.
|
|
56
|
+
# The base models will not see the meta-training samples
|
|
57
|
+
# during their own training.
|
|
58
|
+
X_base_train, X_meta_train, y_base_train, y_meta_train = (
|
|
59
|
+
train_test_split(
|
|
60
|
+
X_train,
|
|
61
|
+
y_train,
|
|
62
|
+
test_size=meta_train_size,
|
|
63
|
+
random_state=random_state,
|
|
64
|
+
stratify=y_train
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 3. Train each base model on its assigned features.
|
|
69
|
+
trained_models = {}
|
|
70
|
+
|
|
71
|
+
for model_name in perspectives.get_dict():
|
|
72
|
+
|
|
73
|
+
model = perspectives.get_model(model_name)
|
|
74
|
+
feature_list = perspectives.get_features(model_name)
|
|
75
|
+
|
|
76
|
+
# Extract the features used by this perspective.
|
|
77
|
+
X_perspective = X_base_train[feature_list]
|
|
78
|
+
|
|
79
|
+
# Train a separate copy of the model.
|
|
80
|
+
trained_model = clone(model)
|
|
81
|
+
trained_model.fit(X_perspective, y_base_train)
|
|
82
|
+
|
|
83
|
+
# Store the trained model.
|
|
84
|
+
trained_models[model_name] = trained_model
|
|
85
|
+
|
|
86
|
+
# 4. Get the classes learned by the first model.
|
|
87
|
+
# All base models must recognize the same classes.
|
|
88
|
+
first_model = list(trained_models.values())[0]
|
|
89
|
+
classes = list(first_model.classes_)
|
|
90
|
+
|
|
91
|
+
# 5. Prepare a DataFrame for meta-model training.
|
|
92
|
+
X_meta_features = pd.DataFrame(index=X_meta_train.index)
|
|
93
|
+
|
|
94
|
+
# 6. Generate probability predictions from each base model.
|
|
95
|
+
for model_name, trained_model in trained_models.items():
|
|
96
|
+
|
|
97
|
+
feature_list = perspectives.get_features(model_name)
|
|
98
|
+
|
|
99
|
+
# Select features from the held-out meta-training data.
|
|
100
|
+
X_perspective = X_meta_train[feature_list]
|
|
101
|
+
|
|
102
|
+
# Check that the model can predict probabilities.
|
|
103
|
+
if not callable(getattr(trained_model, "predict_proba", None)):
|
|
104
|
+
raise TypeError(
|
|
105
|
+
f"{model_name} does not support predict_proba()."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Ensure all models were trained on the same classes.
|
|
109
|
+
if set(trained_model.classes_) != set(classes):
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"{model_name} has different class labels."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Obtain probabilities for all meta-training instances.
|
|
115
|
+
probabilities = trained_model.predict_proba(X_perspective)
|
|
116
|
+
|
|
117
|
+
# Create one meta-feature column per class.
|
|
118
|
+
for i in range(len(classes)):
|
|
119
|
+
|
|
120
|
+
class_label = classes[i]
|
|
121
|
+
|
|
122
|
+
# Find the position of this class in this model.
|
|
123
|
+
class_index = list(trained_model.classes_).index(
|
|
124
|
+
class_label
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Create a unique column name.
|
|
128
|
+
column_name = f"{model_name}_class_{i}"
|
|
129
|
+
|
|
130
|
+
# Store the probabilities for all instances.
|
|
131
|
+
# [:, class_index] means all rows, one column.
|
|
132
|
+
X_meta_features[column_name] = probabilities[
|
|
133
|
+
:, class_index
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
# 7. Select the meta-model.
|
|
137
|
+
if meta_model is None:
|
|
138
|
+
meta_model = LogisticRegression(max_iter=1000)
|
|
139
|
+
|
|
140
|
+
# Clone the meta-model to avoid modifying the original.
|
|
141
|
+
trained_meta_model = clone(meta_model)
|
|
142
|
+
|
|
143
|
+
# 8. Train the meta-model on the base-model probabilities.
|
|
144
|
+
trained_meta_model.fit(X_meta_features, y_meta_train)
|
|
145
|
+
|
|
146
|
+
# 9. Save the meta-feature column order.
|
|
147
|
+
meta_feature_names = X_meta_features.columns.tolist()
|
|
148
|
+
|
|
149
|
+
# 10. Return the trained models and the original
|
|
150
|
+
# base-training subset used to train them.
|
|
151
|
+
return (
|
|
152
|
+
trained_models,
|
|
153
|
+
trained_meta_model,
|
|
154
|
+
meta_feature_names,
|
|
155
|
+
X_base_train.copy(),
|
|
156
|
+
y_base_train.copy()
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def blending_predict(
|
|
160
|
+
perspectives,
|
|
161
|
+
trained_models,
|
|
162
|
+
trained_meta_model,
|
|
163
|
+
meta_feature_names,
|
|
164
|
+
X_instance
|
|
165
|
+
):
|
|
166
|
+
"""
|
|
167
|
+
Predict one instance using the trained blending ensemble.
|
|
168
|
+
|
|
169
|
+
X_instance must be a DataFrame containing exactly one row.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
# Check the input.
|
|
173
|
+
if not isinstance(X_instance, pd.DataFrame):
|
|
174
|
+
raise TypeError("X_instance must be a DataFrame.")
|
|
175
|
+
|
|
176
|
+
if len(X_instance) != 1:
|
|
177
|
+
raise ValueError("X_instance must contain exactly one row.")
|
|
178
|
+
|
|
179
|
+
if not trained_models:
|
|
180
|
+
raise ValueError("No trained base models were supplied.")
|
|
181
|
+
|
|
182
|
+
# Create a DataFrame containing the meta-model inputs.
|
|
183
|
+
X_meta_features = pd.DataFrame(index=X_instance.index)
|
|
184
|
+
|
|
185
|
+
# Get the expected class labels.
|
|
186
|
+
first_model = list(trained_models.values())[0]
|
|
187
|
+
classes = list(first_model.classes_)
|
|
188
|
+
|
|
189
|
+
# Generate class probabilities from every base model.
|
|
190
|
+
for model_name, trained_model in trained_models.items():
|
|
191
|
+
|
|
192
|
+
# Get the feature subset assigned to this perspective.
|
|
193
|
+
feature_list = perspectives.get_features(model_name)
|
|
194
|
+
|
|
195
|
+
# Select the appropriate input features.
|
|
196
|
+
X_perspective = X_instance[feature_list]
|
|
197
|
+
|
|
198
|
+
# Generate the class probabilities.
|
|
199
|
+
probabilities = trained_model.predict_proba(
|
|
200
|
+
X_perspective
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Get this model's class ordering.
|
|
204
|
+
model_classes = list(trained_model.classes_)
|
|
205
|
+
|
|
206
|
+
# Ensure class labels match.
|
|
207
|
+
if set(model_classes) != set(classes):
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"{model_name} has different class labels."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Create the probability columns used by the meta-model.
|
|
213
|
+
for i in range(len(classes)):
|
|
214
|
+
|
|
215
|
+
class_label = classes[i]
|
|
216
|
+
|
|
217
|
+
# Find the corresponding probability column.
|
|
218
|
+
class_index = model_classes.index(class_label)
|
|
219
|
+
|
|
220
|
+
# Use the same column naming convention as training.
|
|
221
|
+
column_name = f"{model_name}_class_{i}"
|
|
222
|
+
|
|
223
|
+
# [:, class_index] selects every row and the
|
|
224
|
+
# probability column for the current class.
|
|
225
|
+
X_meta_features[column_name] = probabilities[
|
|
226
|
+
:, class_index
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
# Arrange the columns in their original training order.
|
|
230
|
+
X_meta_features = X_meta_features[meta_feature_names]
|
|
231
|
+
|
|
232
|
+
# Generate the final prediction using the meta-model.
|
|
233
|
+
prediction = trained_meta_model.predict(X_meta_features)[0]
|
|
234
|
+
|
|
235
|
+
# Convert a NumPy scalar into a regular Python value.
|
|
236
|
+
if hasattr(prediction, "item"):
|
|
237
|
+
prediction = prediction.item()
|
|
238
|
+
|
|
239
|
+
return prediction
|
combination/boosting.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
|
|
2
|
+
import inspect
|
|
3
|
+
from collections import Counter
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import clone
|
|
7
|
+
from sklearn.ensemble import AdaBoostClassifier
|
|
8
|
+
|
|
9
|
+
from .voting import majority_vote
|
|
10
|
+
|
|
11
|
+
def train_boosting(
|
|
12
|
+
perspectives,
|
|
13
|
+
X_train,
|
|
14
|
+
y_train,
|
|
15
|
+
n_estimators=50,
|
|
16
|
+
learning_rate=1.0,
|
|
17
|
+
random_state=42
|
|
18
|
+
):
|
|
19
|
+
"""
|
|
20
|
+
Train an AdaBoost ensemble for each MPML perspective.
|
|
21
|
+
|
|
22
|
+
Each perspective keeps its assigned feature subset.
|
|
23
|
+
AdaBoost trains multiple copies of the perspective's
|
|
24
|
+
estimator sequentially, focusing on difficult samples.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
trained_models: Dictionary containing fitted
|
|
28
|
+
AdaBoost ensembles.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# 1. Validate the training data.
|
|
32
|
+
if not isinstance(X_train, pd.DataFrame):
|
|
33
|
+
raise TypeError("X_train must be a DataFrame.")
|
|
34
|
+
|
|
35
|
+
if len(X_train) == 0 or len(X_train) != len(y_train):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"X_train must be nonempty and match y_train in length."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if type(n_estimators) is not int or n_estimators < 1:
|
|
41
|
+
raise ValueError("n_estimators must be a positive integer.")
|
|
42
|
+
|
|
43
|
+
if not isinstance(learning_rate, (int, float)) or not (
|
|
44
|
+
0 < learning_rate < float("inf")
|
|
45
|
+
):
|
|
46
|
+
raise ValueError("learning_rate must be positive and finite.")
|
|
47
|
+
|
|
48
|
+
# 2. Get the registered perspectives.
|
|
49
|
+
perspective_dict = perspectives.get_dict()
|
|
50
|
+
|
|
51
|
+
if not perspective_dict:
|
|
52
|
+
raise ValueError("No perspectives have been added.")
|
|
53
|
+
|
|
54
|
+
# Dictionary for trained boosting ensembles.
|
|
55
|
+
trained_models = {}
|
|
56
|
+
|
|
57
|
+
# 3. Train an independent boosting ensemble
|
|
58
|
+
# for each perspective.
|
|
59
|
+
for model_name in perspective_dict:
|
|
60
|
+
|
|
61
|
+
# Get the configured estimator and feature list.
|
|
62
|
+
model = perspectives.get_model(model_name)
|
|
63
|
+
feature_list = perspectives.get_features(model_name)
|
|
64
|
+
|
|
65
|
+
# Check that the required features exist.
|
|
66
|
+
for feature in feature_list:
|
|
67
|
+
if feature not in X_train.columns:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"{model_name}: Feature '{feature}' is missing."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# AdaBoost requires a base estimator that accepts
|
|
73
|
+
# sample_weight in its fit() method.
|
|
74
|
+
#
|
|
75
|
+
# inspect.signature() lets us inspect the parameters
|
|
76
|
+
# accepted by another function or method.
|
|
77
|
+
try:
|
|
78
|
+
parameters = inspect.signature(model.fit).parameters
|
|
79
|
+
supports_weights = "sample_weight" in parameters
|
|
80
|
+
except (AttributeError, TypeError, ValueError):
|
|
81
|
+
supports_weights = False
|
|
82
|
+
|
|
83
|
+
if not supports_weights:
|
|
84
|
+
raise TypeError(
|
|
85
|
+
f"{model_name}: The selected estimator must "
|
|
86
|
+
"support sample_weight in fit()."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Select only this perspective's features.
|
|
90
|
+
X_perspective = X_train[feature_list]
|
|
91
|
+
|
|
92
|
+
# 4. Create an AdaBoost classifier.
|
|
93
|
+
#
|
|
94
|
+
# estimator: The base model to boost.
|
|
95
|
+
# n_estimators: Maximum number of sequential models.
|
|
96
|
+
# learning_rate: Controls the contribution of each stage.
|
|
97
|
+
# random_state: Makes training reproducible.
|
|
98
|
+
boosting_model = AdaBoostClassifier(
|
|
99
|
+
estimator=clone(model),
|
|
100
|
+
n_estimators=n_estimators,
|
|
101
|
+
learning_rate=learning_rate,
|
|
102
|
+
random_state=random_state
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# 5. Train the boosting ensemble.
|
|
106
|
+
# AdaBoost manages the sequential training internally.
|
|
107
|
+
boosting_model.fit(X_perspective, y_train)
|
|
108
|
+
|
|
109
|
+
# Save the trained ensemble under its perspective name.
|
|
110
|
+
trained_models[model_name] = boosting_model
|
|
111
|
+
|
|
112
|
+
return trained_models
|
|
113
|
+
|
|
114
|
+
def boosting_predict(perspectives, trained_models, X_instance):
|
|
115
|
+
"""
|
|
116
|
+
Predict a single instance using trained boosting ensembles.
|
|
117
|
+
|
|
118
|
+
Each perspective has its own trained AdaBoostClassifier.
|
|
119
|
+
|
|
120
|
+
The AdaBoostClassifier combines its internal models,
|
|
121
|
+
producing one prediction for that perspective.
|
|
122
|
+
|
|
123
|
+
The final prediction across perspectives is determined
|
|
124
|
+
using the existing majority_vote() function.
|
|
125
|
+
|
|
126
|
+
Parameters:
|
|
127
|
+
perspectives: Perspectives object.
|
|
128
|
+
trained_models: Dictionary of trained AdaBoost models.
|
|
129
|
+
X_instance: DataFrame containing exactly one row.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Final predicted class.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
# Ensure that trained boosting models were supplied.
|
|
136
|
+
if not trained_models:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
"No trained boosting models were supplied."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Reuse the majority_vote() function.
|
|
142
|
+
# It selects each perspective's assigned features,
|
|
143
|
+
# generates predictions and resolves voting ties.
|
|
144
|
+
prediction = majority_vote(
|
|
145
|
+
perspectives,
|
|
146
|
+
trained_models,
|
|
147
|
+
X_instance
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return prediction
|
|
151
|
+
|
combination/stacking.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from sklearn.base import clone
|
|
4
|
+
from sklearn.linear_model import LogisticRegression
|
|
5
|
+
from sklearn.model_selection import StratifiedKFold, cross_val_predict
|
|
6
|
+
|
|
7
|
+
def train_stacking(
|
|
8
|
+
perspectives,
|
|
9
|
+
X_train,
|
|
10
|
+
y_train,
|
|
11
|
+
meta_model=None,
|
|
12
|
+
cv=5,
|
|
13
|
+
random_state=42
|
|
14
|
+
):
|
|
15
|
+
"""
|
|
16
|
+
Train an MPML stacking ensemble using out-of-fold probabilities.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
trained_models: Dictionary containing base models fitted on all training data.
|
|
20
|
+
trained_meta_model: Meta-model fitted on out-of-fold probability features.
|
|
21
|
+
meta_feature_names: Ordered column names for meta-model prediction inputs.
|
|
22
|
+
|
|
23
|
+
Each perspective is independently fitted on its own feature columns.
|
|
24
|
+
The original estimators inside `perspectives` are not modified.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# 1. Validate input data and the requested number of folds.
|
|
28
|
+
if not isinstance(X_train, pd.DataFrame):
|
|
29
|
+
raise TypeError("X_train must be a pandas DataFrame.")
|
|
30
|
+
|
|
31
|
+
if X_train.empty:
|
|
32
|
+
raise ValueError("X_train must contain training samples and features.")
|
|
33
|
+
|
|
34
|
+
if len(X_train) != len(y_train):
|
|
35
|
+
raise ValueError("X_train and y_train must have equal lengths.")
|
|
36
|
+
|
|
37
|
+
if isinstance(cv, bool) or not isinstance(cv, int) or cv < 2:
|
|
38
|
+
raise ValueError("cv must be an integer of at least 2.")
|
|
39
|
+
|
|
40
|
+
definitions = perspectives.get_dict()
|
|
41
|
+
if not definitions:
|
|
42
|
+
raise ValueError("No perspectives have been added.")
|
|
43
|
+
|
|
44
|
+
# Reset the target index to make it positional, like X_train's rows.
|
|
45
|
+
# A Series from a previous train_test_split may have a different index.
|
|
46
|
+
y = pd.Series(y_train).reset_index(drop=True)
|
|
47
|
+
if y.isna().any():
|
|
48
|
+
raise ValueError("y_train must not contain missing labels.")
|
|
49
|
+
|
|
50
|
+
# np.unique() returns the class labels in sorted order,
|
|
51
|
+
# matching scikit-learn's standard classifier class order.
|
|
52
|
+
classes = list(np.unique(y))
|
|
53
|
+
if len(classes) < 2:
|
|
54
|
+
raise ValueError("Classification requires at least two classes.")
|
|
55
|
+
|
|
56
|
+
# Stratified CV needs enough examples of every class in every fold.
|
|
57
|
+
counts = y.value_counts()
|
|
58
|
+
if counts.min() < cv:
|
|
59
|
+
raise ValueError("Every class must have at least cv training samples.")
|
|
60
|
+
|
|
61
|
+
# One fold arrangement is shared by all perspectives.
|
|
62
|
+
splitter = StratifiedKFold(
|
|
63
|
+
n_splits=cv,
|
|
64
|
+
shuffle=True,
|
|
65
|
+
random_state=random_state
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 2. Assemble meta-model features from out-of-fold probabilities.
|
|
69
|
+
X_meta_features = pd.DataFrame(index=range(len(X_train)))
|
|
70
|
+
trained_models = {}
|
|
71
|
+
|
|
72
|
+
for model_name in definitions:
|
|
73
|
+
model = perspectives.get_model(model_name)
|
|
74
|
+
feature_list = perspectives.get_features(model_name)
|
|
75
|
+
|
|
76
|
+
# Selecting with a list keeps this as a DataFrame.
|
|
77
|
+
X_perspective = X_train[feature_list]
|
|
78
|
+
|
|
79
|
+
# A new clone is trained separately on each fold, then predicts
|
|
80
|
+
# only the fold that clone was NOT trained on.
|
|
81
|
+
# cross_val_predict returns predictions in the original row order.
|
|
82
|
+
try:
|
|
83
|
+
probabilities = cross_val_predict(
|
|
84
|
+
clone(model),
|
|
85
|
+
X_perspective,
|
|
86
|
+
y,
|
|
87
|
+
cv=splitter,
|
|
88
|
+
method="predict_proba"
|
|
89
|
+
)
|
|
90
|
+
except AttributeError as error:
|
|
91
|
+
raise TypeError(
|
|
92
|
+
f"{model_name} must support predict_proba() for stacking."
|
|
93
|
+
) from error
|
|
94
|
+
|
|
95
|
+
# Fit the final model on all training rows for later inference.
|
|
96
|
+
trained_model = clone(model)
|
|
97
|
+
trained_model.fit(X_perspective, y)
|
|
98
|
+
trained_models[model_name] = trained_model
|
|
99
|
+
|
|
100
|
+
# cross_val_predict aligns probability columns with the class
|
|
101
|
+
# order of the full y passed to it; sklearn uses sorted classes.
|
|
102
|
+
# Align explicitly with classes used by the final fitted model.
|
|
103
|
+
probability_classes = list(trained_model.classes_)
|
|
104
|
+
if set(probability_classes) != set(classes):
|
|
105
|
+
raise ValueError(f"{model_name} learned different classes.")
|
|
106
|
+
|
|
107
|
+
for i in range(len(classes)):
|
|
108
|
+
class_label = classes[i]
|
|
109
|
+
class_index = probability_classes.index(class_label)
|
|
110
|
+
column_name = f"{model_name}_class_{i}"
|
|
111
|
+
|
|
112
|
+
# [:, class_index] means every row, one probability column.
|
|
113
|
+
X_meta_features[column_name] = probabilities[:, class_index]
|
|
114
|
+
|
|
115
|
+
# 3. Train a separate meta-model on the out-of-fold features.
|
|
116
|
+
if meta_model is None:
|
|
117
|
+
meta_model = LogisticRegression(max_iter=1000)
|
|
118
|
+
|
|
119
|
+
trained_meta_model = clone(meta_model)
|
|
120
|
+
trained_meta_model.fit(X_meta_features, y)
|
|
121
|
+
|
|
122
|
+
# Preserve the exact meta-feature order for predictions on new data.
|
|
123
|
+
meta_feature_names = X_meta_features.columns.tolist()
|
|
124
|
+
return trained_models, trained_meta_model, meta_feature_names
|
|
125
|
+
|
|
126
|
+
def stacking_predict(
|
|
127
|
+
perspectives,
|
|
128
|
+
trained_models,
|
|
129
|
+
trained_meta_model,
|
|
130
|
+
meta_feature_names,
|
|
131
|
+
X_instance
|
|
132
|
+
):
|
|
133
|
+
"""
|
|
134
|
+
Predict one instance using a trained stacking ensemble.
|
|
135
|
+
|
|
136
|
+
Parameters:
|
|
137
|
+
perspectives: Perspectives object.
|
|
138
|
+
trained_models: Dictionary of trained base models.
|
|
139
|
+
trained_meta_model: Trained stacking meta-model.
|
|
140
|
+
meta_feature_names: Columns used to train the meta-model.
|
|
141
|
+
X_instance: DataFrame containing exactly one row.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Final predicted class.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
# 1. Validate the input instance.
|
|
148
|
+
if not isinstance(X_instance, pd.DataFrame):
|
|
149
|
+
raise TypeError("X_instance must be a DataFrame.")
|
|
150
|
+
|
|
151
|
+
if len(X_instance) != 1:
|
|
152
|
+
raise ValueError("X_instance must contain exactly one row.")
|
|
153
|
+
|
|
154
|
+
# 2. Create a DataFrame for meta-model input.
|
|
155
|
+
X_meta_features = pd.DataFrame(index=X_instance.index)
|
|
156
|
+
|
|
157
|
+
# 3. Get the expected class order.
|
|
158
|
+
# All base models must recognize the same classes.
|
|
159
|
+
first_model = list(trained_models.values())[0]
|
|
160
|
+
classes = list(first_model.classes_)
|
|
161
|
+
|
|
162
|
+
# 4. Generate probabilities from each base model.
|
|
163
|
+
for model_name, trained_model in trained_models.items():
|
|
164
|
+
|
|
165
|
+
# Get the assigned features.
|
|
166
|
+
feature_list = perspectives.get_features(model_name)
|
|
167
|
+
|
|
168
|
+
# Select only the required features.
|
|
169
|
+
X_perspective = X_instance[feature_list]
|
|
170
|
+
|
|
171
|
+
# Generate class probabilities.
|
|
172
|
+
probabilities = trained_model.predict_proba(
|
|
173
|
+
X_perspective
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Get this model's class ordering.
|
|
177
|
+
model_classes = list(trained_model.classes_)
|
|
178
|
+
|
|
179
|
+
# Ensure every model has the same class labels.
|
|
180
|
+
if set(model_classes) != set(classes):
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"{model_name} has different class labels."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Create the same probability columns
|
|
186
|
+
# used during meta-model training.
|
|
187
|
+
for i in range(len(classes)):
|
|
188
|
+
|
|
189
|
+
class_label = classes[i]
|
|
190
|
+
|
|
191
|
+
# Find the correct probability column.
|
|
192
|
+
class_index = model_classes.index(class_label)
|
|
193
|
+
|
|
194
|
+
column_name = f"{model_name}_class_{i}"
|
|
195
|
+
|
|
196
|
+
# Store the class probability.
|
|
197
|
+
X_meta_features[column_name] = probabilities[
|
|
198
|
+
:, class_index
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
# 5. Arrange the columns in the same order
|
|
202
|
+
# used when the meta-model was trained.
|
|
203
|
+
X_meta_features = X_meta_features[meta_feature_names]
|
|
204
|
+
|
|
205
|
+
# 6. Predict using the trained meta-model.
|
|
206
|
+
prediction = trained_meta_model.predict(X_meta_features)[0]
|
|
207
|
+
|
|
208
|
+
# Convert NumPy values into regular Python values.
|
|
209
|
+
if hasattr(prediction, "item"):
|
|
210
|
+
prediction = prediction.item()
|
|
211
|
+
|
|
212
|
+
return prediction
|
combination/voting.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from collections import Counter
|
|
3
|
+
|
|
4
|
+
def get_final_prediction(predictions, classes=None):
|
|
5
|
+
"""
|
|
6
|
+
Determine the final prediction using majority voting.
|
|
7
|
+
|
|
8
|
+
If there is a tie, select the class with the highest
|
|
9
|
+
combined probability across all models.
|
|
10
|
+
|
|
11
|
+
Parameters:
|
|
12
|
+
predictions: List of (prediction, probabilities) tuples.
|
|
13
|
+
classes: Class labels in the same order as probabilities.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
The final predicted class.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
if not predictions:
|
|
20
|
+
raise ValueError("Predictions list cannot be empty.")
|
|
21
|
+
|
|
22
|
+
if classes is None:
|
|
23
|
+
classes = list(range(len(predictions[0][1])))
|
|
24
|
+
|
|
25
|
+
# Count how many models predicted each class.
|
|
26
|
+
counts = Counter(pred for pred, _ in predictions)
|
|
27
|
+
|
|
28
|
+
# Find the highest number of votes.
|
|
29
|
+
max_count = max(counts.values())
|
|
30
|
+
|
|
31
|
+
# Find classes tied for the most votes.
|
|
32
|
+
tied_classes = [
|
|
33
|
+
cls for cls, count in counts.items()
|
|
34
|
+
if count == max_count
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# If there is no tie, return the winning class.
|
|
38
|
+
if len(tied_classes) == 1:
|
|
39
|
+
return tied_classes[0]
|
|
40
|
+
|
|
41
|
+
# If there is a tie, sum the probabilities
|
|
42
|
+
# assigned to each tied class by all models.
|
|
43
|
+
confidence_sums = {
|
|
44
|
+
cls: sum(
|
|
45
|
+
probs[classes.index(cls)]
|
|
46
|
+
for _, probs in predictions
|
|
47
|
+
)
|
|
48
|
+
for cls in tied_classes
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Return the tied class with the highest confidence.
|
|
52
|
+
return max(
|
|
53
|
+
tied_classes,
|
|
54
|
+
key=lambda cls: confidence_sums[cls]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def majority_vote(perspectives, trained_models, X_instance):
|
|
58
|
+
"""
|
|
59
|
+
Predict a single instance using majority voting.
|
|
60
|
+
|
|
61
|
+
Each trained perspective contributes one vote.
|
|
62
|
+
The final decision is made by get_final_prediction().
|
|
63
|
+
|
|
64
|
+
Parameters:
|
|
65
|
+
perspectives: Perspectives object.
|
|
66
|
+
trained_models: Dictionary containing fitted models.
|
|
67
|
+
X_instance: DataFrame containing exactly one row.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The final predicted class.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# Check that the input is a DataFrame with one row.
|
|
74
|
+
if not isinstance(X_instance, pd.DataFrame):
|
|
75
|
+
raise TypeError("X_instance must be a DataFrame.")
|
|
76
|
+
|
|
77
|
+
if len(X_instance) != 1:
|
|
78
|
+
raise ValueError("X_instance must contain exactly one row.")
|
|
79
|
+
|
|
80
|
+
if not trained_models:
|
|
81
|
+
raise ValueError("No trained models were supplied.")
|
|
82
|
+
|
|
83
|
+
# Store each model's prediction and probabilities.
|
|
84
|
+
mv_predictions = []
|
|
85
|
+
|
|
86
|
+
# Use a consistent class order for all models.
|
|
87
|
+
classes = None
|
|
88
|
+
|
|
89
|
+
# Loop through each trained perspective.
|
|
90
|
+
for model_name, trained_model in trained_models.items():
|
|
91
|
+
|
|
92
|
+
# Get the features assigned to this perspective.
|
|
93
|
+
feature_list = perspectives.get_features(model_name)
|
|
94
|
+
|
|
95
|
+
# Select the appropriate columns.
|
|
96
|
+
X_perspective = X_instance[feature_list]
|
|
97
|
+
|
|
98
|
+
# Predict the class of the instance.
|
|
99
|
+
prediction = trained_model.predict(X_perspective)[0]
|
|
100
|
+
|
|
101
|
+
# Convert NumPy values into regular Python values.
|
|
102
|
+
if hasattr(prediction, "item"):
|
|
103
|
+
prediction = prediction.item()
|
|
104
|
+
|
|
105
|
+
# Get the class probabilities.
|
|
106
|
+
probabilities = trained_model.predict_proba(
|
|
107
|
+
X_perspective
|
|
108
|
+
)[0]
|
|
109
|
+
|
|
110
|
+
# Get the classes recognized by this model.
|
|
111
|
+
model_classes = list(trained_model.classes_)
|
|
112
|
+
|
|
113
|
+
# Establish the class order from the first model.
|
|
114
|
+
if classes is None:
|
|
115
|
+
classes = model_classes
|
|
116
|
+
|
|
117
|
+
# Ensure every model recognizes the same classes.
|
|
118
|
+
if set(model_classes) != set(classes):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"{model_name} has different class labels."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Arrange probabilities in the same class order.
|
|
124
|
+
ordered_probabilities = []
|
|
125
|
+
|
|
126
|
+
for class_label in classes:
|
|
127
|
+
|
|
128
|
+
# Find the position of this class.
|
|
129
|
+
class_index = model_classes.index(class_label)
|
|
130
|
+
|
|
131
|
+
# Get the probability associated with the class.
|
|
132
|
+
probability = float(probabilities[class_index])
|
|
133
|
+
|
|
134
|
+
ordered_probabilities.append(probability)
|
|
135
|
+
|
|
136
|
+
# Store the prediction and probabilities as a tuple.
|
|
137
|
+
results = (prediction, ordered_probabilities)
|
|
138
|
+
|
|
139
|
+
mv_predictions.append(results)
|
|
140
|
+
|
|
141
|
+
# Call the separate function in the same file.
|
|
142
|
+
return get_final_prediction(mv_predictions, classes)
|
|
143
|
+
|
|
144
|
+
def weighted_sum(
|
|
145
|
+
perspectives,
|
|
146
|
+
trained_models,
|
|
147
|
+
X_instance,
|
|
148
|
+
voting_type="soft"
|
|
149
|
+
):
|
|
150
|
+
"""
|
|
151
|
+
Predict a single instance using weighted voting.
|
|
152
|
+
|
|
153
|
+
Parameters:
|
|
154
|
+
perspectives: Perspectives object containing model weights
|
|
155
|
+
and feature lists.
|
|
156
|
+
|
|
157
|
+
trained_models: Dictionary of trained base models.
|
|
158
|
+
|
|
159
|
+
X_instance: DataFrame containing exactly one row.
|
|
160
|
+
|
|
161
|
+
voting_type:
|
|
162
|
+
"soft" (default) - Multiply each model's weight by
|
|
163
|
+
its probability for each class.
|
|
164
|
+
|
|
165
|
+
"hard" - Assign the model's entire weight to
|
|
166
|
+
its predicted class.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The final predicted class.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
# 1. Validate the voting type.
|
|
173
|
+
if voting_type not in ("soft", "hard"):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"voting_type must be either 'soft' or 'hard'."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# 2. Validate the input instance.
|
|
179
|
+
if not isinstance(X_instance, pd.DataFrame):
|
|
180
|
+
raise TypeError("X_instance must be a DataFrame.")
|
|
181
|
+
|
|
182
|
+
if len(X_instance) != 1:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
"X_instance must contain exactly one row."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if not trained_models:
|
|
188
|
+
raise ValueError("No trained models were supplied.")
|
|
189
|
+
|
|
190
|
+
# Dictionary to store accumulated class scores.
|
|
191
|
+
class_weights = {}
|
|
192
|
+
|
|
193
|
+
# Keep track of the total assigned weight.
|
|
194
|
+
total_weight = 0
|
|
195
|
+
|
|
196
|
+
# 3. Process each trained perspective.
|
|
197
|
+
for model_name, trained_model in trained_models.items():
|
|
198
|
+
|
|
199
|
+
# Get the features assigned to the perspective.
|
|
200
|
+
feature_list = perspectives.get_features(model_name)
|
|
201
|
+
|
|
202
|
+
# Get the weight assigned to this perspective.
|
|
203
|
+
weight = perspectives.get_weight(model_name)
|
|
204
|
+
|
|
205
|
+
# Ensure the assigned weight is valid.
|
|
206
|
+
if (
|
|
207
|
+
not isinstance(weight, (int, float))
|
|
208
|
+
or isinstance(weight, bool)
|
|
209
|
+
or not 0 <= weight < float("inf")
|
|
210
|
+
):
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"{model_name} must have a finite, "
|
|
213
|
+
"nonnegative numerical weight."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
total_weight += weight
|
|
217
|
+
|
|
218
|
+
# Select the appropriate features.
|
|
219
|
+
X_perspective = X_instance[feature_list]
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------
|
|
222
|
+
# HARD WEIGHTED VOTING
|
|
223
|
+
# ------------------------------------------
|
|
224
|
+
if voting_type == "hard":
|
|
225
|
+
|
|
226
|
+
# Predict the class for this instance.
|
|
227
|
+
prediction = trained_model.predict(X_perspective)[0]
|
|
228
|
+
|
|
229
|
+
# Convert NumPy values to Python values.
|
|
230
|
+
if hasattr(prediction, "item"):
|
|
231
|
+
prediction = prediction.item()
|
|
232
|
+
|
|
233
|
+
# Initialize the score for this class.
|
|
234
|
+
if prediction not in class_weights:
|
|
235
|
+
class_weights[prediction] = 0.0
|
|
236
|
+
|
|
237
|
+
# Assign the model's full weight to
|
|
238
|
+
# the class it predicted.
|
|
239
|
+
class_weights[prediction] += weight
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------
|
|
242
|
+
# SOFT WEIGHTED VOTING
|
|
243
|
+
# ------------------------------------------
|
|
244
|
+
else:
|
|
245
|
+
|
|
246
|
+
# Get the predicted probability of each class.
|
|
247
|
+
probabilities = trained_model.predict_proba(
|
|
248
|
+
X_perspective
|
|
249
|
+
)[0]
|
|
250
|
+
|
|
251
|
+
# Get the class labels corresponding to
|
|
252
|
+
# the probability values.
|
|
253
|
+
classes = trained_model.classes_
|
|
254
|
+
|
|
255
|
+
# Calculate the weighted contribution
|
|
256
|
+
# for each class.
|
|
257
|
+
for i in range(len(classes)):
|
|
258
|
+
|
|
259
|
+
class_label = classes[i]
|
|
260
|
+
|
|
261
|
+
if hasattr(class_label, "item"):
|
|
262
|
+
class_label = class_label.item()
|
|
263
|
+
|
|
264
|
+
probability = float(probabilities[i])
|
|
265
|
+
|
|
266
|
+
# Multiply the probability by the weight.
|
|
267
|
+
weighted_probability = weight * probability
|
|
268
|
+
|
|
269
|
+
# Initialize this class's score.
|
|
270
|
+
if class_label not in class_weights:
|
|
271
|
+
class_weights[class_label] = 0.0
|
|
272
|
+
|
|
273
|
+
# Add the weighted probability.
|
|
274
|
+
class_weights[class_label] += weighted_probability
|
|
275
|
+
|
|
276
|
+
# 4. Ensure there is at least one positive weight.
|
|
277
|
+
if total_weight == 0:
|
|
278
|
+
raise ValueError(
|
|
279
|
+
"At least one perspective must have a positive weight."
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# 5. Select the class with the highest total score.
|
|
283
|
+
final_prediction = max(
|
|
284
|
+
class_weights,
|
|
285
|
+
key=class_weights.get
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return final_prediction
|
|
289
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
combination/bagging.py,sha256=OIPWmPnO9H8-sDv0mOQvVGBvfROAf-eYWU7NcAxVt_Q,2914
|
|
2
|
+
combination/blending.py,sha256=ZHZfxQFSc8H4SLaKrAMsNl2xwIZTamXc-GUuXi_wLeo,7653
|
|
3
|
+
combination/boosting.py,sha256=7ZrGd__l5TW5TlUJ4AK2J44mX4BcV6WXdwCze2V96Vw,4724
|
|
4
|
+
combination/stacking.py,sha256=FFIZ-ZEqkYI0bAYCBzSYdW5TTDVJGaZ268dEX82o4vA,7502
|
|
5
|
+
combination/voting.py,sha256=-Rvj0sWPddVeccL0NJccDXAxOjomqGXPhr97aNW7HbU,8731
|
|
6
|
+
mpml_lib-1.0.0.dist-info/METADATA,sha256=6IYWOU_3KzuU8SdOdmUasjuh1BtrJxuYm_LDr6GOYnU,168
|
|
7
|
+
mpml_lib-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
mpml_lib-1.0.0.dist-info/top_level.txt,sha256=giyna2YQfB5l932_M983n0Yf_M0Z_up1_9wb91FLFsE,12
|
|
9
|
+
mpml_lib-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
combination
|