Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7a638f2
fix `ValueError: y_true and y_pred contain different number of classes`
valosekj Jan 21, 2025
0977831
use number instead of str
valosekj Jan 22, 2025
47ef561
Merge branch 'master' into jv/experimental-fix_different_num_of_class…
valosekj Jan 22, 2025
3f276b6
Merge branch 'jv/experimental-fix_different_num_of_classes_between_y_…
valosekj Jan 22, 2025
9f8cae4
Merge branch 'master' into jv/experimental-fix_different_num_of_class…
valosekj Jan 28, 2025
6921a64
Merge branch 'master' into jv/experimental-fix_different_num_of_class…
valosekj Feb 12, 2025
e2f501a
Set default LogisticRegression solver to saga to make it working for RFE
valosekj Feb 12, 2025
e46d306
Merge branch 'master' into jv/experimental-fix_different_num_of_class…
valosekj Feb 13, 2025
b5cc4d1
Add a script to inspect and plot output db
valosekj Feb 15, 2025
f681fad
Add `y_true_collector` and `y_pred_proba_collector` metrics
valosekj Feb 20, 2025
707fe6b
Move getting the best replicate into a function
valosekj Feb 20, 2025
c5c61a3
Remove empty lines
valosekj Feb 20, 2025
a2f5934
Return `best_models_dict` by `get_best_replicate`
valosekj Feb 21, 2025
d69a385
Plot ROC
valosekj Feb 21, 2025
adab105
Keep only the models we are interested in
valosekj Feb 21, 2025
e58a68f
Improve figure titles
valosekj Feb 21, 2025
c14c7aa
Plot the mean ± std ROC curve for each model across replicates.
valosekj Feb 21, 2025
c55c2f4
Unify titles across figures
valosekj Feb 21, 2025
0b3ad3d
include metric (e.g., 'balanced_accuracy (test)' or 'balanced_accurac…
valosekj Feb 24, 2025
917f4ab
Add max_iter parameter to log_reg configuration
valosekj Apr 2, 2025
928df4a
Add proper argparse
valosekj Apr 10, 2025
bedb126
Get unique targets dynamically and iterate over them
valosekj Apr 10, 2025
8027b66
Initial commit; added SHAP value calculation to the list of available…
SomeoneInParticular Sep 17, 2025
0c58fa0
Merge remote-tracking branch 'origin/jv/experimental-fix_different_nu…
SomeoneInParticular Sep 17, 2025
6c6d3e9
Swapped SHAP values from list to dict (bound by feature name)
SomeoneInParticular Oct 7, 2025
54cbe33
Fixed error when a model cannot natively be parsed by SHAP.
SomeoneInParticular Nov 23, 2025
9201bd4
Added new "VarianceDrop" data hook, allowing low-variance features to…
SomeoneInParticular Nov 26, 2025
704c50a
Updated iris testing dataset + config with new encoders.
SomeoneInParticular Nov 26, 2025
b336ddd
Added Jupyter Notebooks to the git ignore, as we occasionally use the…
SomeoneInParticular Nov 26, 2025
a2bf6c8
Added catch for homogeneity when running SHAP tests.
SomeoneInParticular Dec 4, 2025
01a4bc1
Removed "inspect_output_db", as it is too specific to Jan's analysis.
SomeoneInParticular Feb 26, 2026
c323da8
Pinned to pre-3.0 version of Pandas until `dtype` issues can be addre…
SomeoneInParticular Feb 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*__pycache__/
.vscode/
.DS_Store
/.ipynb_checkpoints/

# Environments
.env
Expand Down
2 changes: 1 addition & 1 deletion data/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def _decorator(cls: Type[DataHook]):
# TODO: Find a more elegant way to do this
from data.hooks.feature_selection import (
SampleNullityDrop, FeatureNullityDrop, ExplicitDrop, ExplicitKeep, PrincipalComponentAnalysis,
RecursiveFeatureElimination
RecursiveFeatureElimination, VarianceDrop
)
from data.hooks.imputation import SimpleImputation
from data.hooks.encoding import OneHotEncoding
Expand Down
80 changes: 77 additions & 3 deletions data/hooks/feature_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pandas as pd
from optuna import Trial
from sklearn.decomposition import PCA
from sklearn.feature_selection import RFE
from sklearn.feature_selection import RFE, VarianceThreshold
from sklearn.linear_model import LogisticRegression

from config.utils import default_as, is_float, is_list, parse_data_config_entry
Expand Down Expand Up @@ -135,6 +135,81 @@ def run(self, x: BaseDataManager, y: Optional[BaseDataManager] = None) -> BaseDa
return x.drop_features(drop_idx)


## Feature selection by homogeneity
@registered_data_hook("drop_low_variance")
class VarianceDrop(FittedDataHook):
"""
Thin wrapper for SciKit-Learn's VarianceThreshold class, for use as a data hook within MOOP.

Runs additional checks on top of the default implementation provided by SciKit-Learn:
* Ensures the resulting dataset always contains at least 1 feature.

Example usage:
{
"type": "drop_low_variance",
"threshold": 0.1
}
"""
def __init__(self, config, **kwargs):
# TODO: make this tunable
super().__init__(config, **kwargs)

# Get the variance threshold
threshold = parse_data_config_entry(
"threshold", config,
default_as(0.0, self.logger), is_float(self.logger)
)

# Build the wrapped VarianceThreshold object
self.threshold = threshold
self.selected_features: list[str] | None = None

@classmethod
def from_config(cls, config: dict, logger: Logger = Logger.root) -> Self:
return cls(config=config, logger=logger)

def run(self, x: BaseDataManager, y: Optional[BaseDataManager] = None) -> BaseDataManager:
# If x contains only one feature already, just return that feature, as RFE has a stroke otherwise
if x.n_features() == 1:
self.logger.warning("Only one feature in the dataset was found; "
"dropping any further would result in a null dataset."
"Original (unmodified) dataset returned instead.")
return x

# Fit the model to the dataset
vt = VarianceThreshold(threshold=self.threshold)
vt.fit(x.as_array(), np.ravel(y.as_array())) # Ravel prevents some warning spam

# Select only the features with variance less than the threshold
self.selected_features = vt.get_feature_names_out(x.features())

# Ensure that at least one feature was kept
if self.selected_features.shape[0] < 1:
# Find the
highest_var = np.max(vt.variances_)
highest_var_feature = list(x.features())[np.argmax(vt.variances_)]
self.selected_features = [highest_var_feature]
self.logger.warning(
f"Low-variance filter almost dropped all features; kept highest variance "
f"feature ({highest_var_feature}, variance {highest_var}) alone to prevent crash!"
)

# Return the copy of x containing only these features
x_out = x.get_features(self.selected_features)
return x_out

def run_fitted(self, x_train: BaseDataManager, x_test: Optional[BaseDataManager],
y_train: Optional[BaseDataManager] = None, y_test: Optional[BaseDataManager] = None) -> \
tuple[BaseDataManager, BaseDataManager]:
# Run the fitted analysis first
train_out = self.run(x_train, y_train)

# Use the same set of features to filter the x_test set
test_out = x_test.get_features(self.selected_features)

return train_out, test_out


### Principal Component Analysis ###
@registered_data_hook("principal_component_analysis")
class PrincipalComponentAnalysis(Tunable, FittedDataHook):
Expand Down Expand Up @@ -263,8 +338,7 @@ def from_config(cls, config: dict, logger: Logger = Logger.root) -> Self:
def tune(self, trial: Trial):
self.prop_tuner.tune(trial)
# Generate the new backing model based on this setup
# TODO: Generalize this to work with continuous targets as well
new_lor = LogisticRegression()
new_lor = LogisticRegression(solver='saga')
self.backing_rfe = RFE(estimator=new_lor, n_features_to_select=self.prop_tuner.value)

def tunable_params(self) -> list[TunableParam]:
Expand Down
3 changes: 2 additions & 1 deletion environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ dependencies:
- ca-certificates
- openssl
- scikit-learn
- pandas
- pandas<3
- pytest
- shap
5 changes: 4 additions & 1 deletion study/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
"sk_f1_weighted_avg": sk_f1_weighted_avg,
"sk_f1_perclass": sk_f1_perclass,
"importance_by_permutation": importance_by_permutation,
"shap_additive": shap_additive,
"correct_samples": correct_samples,
"incorrect_samples": incorrect_samples
"incorrect_samples": incorrect_samples,
"y_true_collector": y_true_collector,
"y_pred_proba_collector": y_pred_proba_collector
}
109 changes: 107 additions & 2 deletions study/metrics.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""
Metric-reporting closures for use in this framework.
"""
import sys

import numpy as np
import shap
from sklearn.inspection import permutation_importance
from sklearn.metrics import balanced_accuracy_score, log_loss, roc_auc_score, precision_score, recall_score, f1_score

Expand All @@ -18,7 +21,8 @@ def clean_val_for_db(val):
def sk_log_loss(manager: OptunaModelManager, x: BaseDataManager, y: BaseDataManager):
# Log Loss
py = manager.predict_proba(x.as_array())
return log_loss(y.as_array(), py)
y_labels = [i for i in range(py.shape[1])]
return log_loss(y.as_array(), py, labels=y_labels)

def sk_balanced_accuracy(manager: OptunaModelManager, x: BaseDataManager, y: BaseDataManager):
# Balanced Accuracy
Expand Down Expand Up @@ -101,6 +105,95 @@ def importance_by_permutation(manager: OptunaModelManager, x: BaseDataManager, y
importance_vals = clean_val_for_db(importance_vals)
return importance_vals

def shap_additive(manager: OptunaModelManager, x: BaseDataManager, _: BaseDataManager):
"""
To restore the (raw) values for a given run, run the following snippet:

```
from io import StringIO

# You can omit the Numpy import if you manually
# parse the inner string in the list comp
import numpy as np

# This is the SHAP value within the database you want to parse
val = ...

# Strip the brackets first
val = val.strip("{").strip("}")
# Split by commas
entry_strs = val.split(", ")
# Split the dataset by feature name
shap_map = dict()
for entry_str in entry_strs:
# Split the text along the colon to get the feature label back
feature_label, shap_value_str = entry_str.split(": ")
# Parse the shap value string back into numeric form
shap_vals = [list(np.fromstring(x, sep=" ")) for x in shap_value_str.split("\n")]
# Add it to the map
shap_map[feature_label] = shap_vals
```

Each entry in `shap_map` will be Numpy array of with the following dimensions:
* n is the number of samples in the input dataset (train, validate, or test), and
* c is the number of categorical classes used during training;
If this is binary classification, or a continuous target, c=1.

For categorical targets with more than 2 classes, each class is treated as
unique feature by SHAP for the purpose of calculating SHAP values.

TODO: Save the `shap_values` directly via pickle into a SQLite blob
"""
# Initialize the explainer, using the x data as both the mask and feature list
x_arr = x.as_array()
model = manager.get_model()

if np.unique(x_arr).shape[0] < 2:
# SHAP cannot run on a dataset which is entirely homogenous;
# return early to avoid an error
return "NULL"

try:
# Default to the "generic" explainer
explainer = shap.Explainer(
model, x_arr, feature_names=x.features()
)
# Calculate the Shapley values from this dataset
shap_values = explainer(x_arr)
except TypeError as err:
# If that failed, try to use the model's "predict" function instead
if hasattr(model, "predict"):
explainer = shap.Explainer(
model.predict, x_arr, feature_names=x.features()
)
# Calculate the Shapley values from this dataset
shap_values = explainer(x_arr)
else:
raise err

shap_list = list()
for i, v in enumerate(shap_values.feature_names):
# SHAP auto-reduces the shape of its features if it is targeting
# a binary classification OR a continuous metric
if len(shap_values.values.shape) < 3:
val_str = np.array2string(shap_values.values[:, i], max_line_width=sys.maxsize, threshold=sys.maxsize)
else:
val_str = np.array2string(shap_values.values[:, i, :], max_line_width=sys.maxsize, threshold=sys.maxsize)
# Remove the brackets; despite Numpy adding them, it cannot parse them after...
val_str = val_str.replace("[", "").replace("]", "")
val_str = f"{v}: {val_str}"
shap_list.append(val_str)

# This nonsense is required because Python maps
# "/n" to "//n" if you string convert a dict;
# why the hell does it do that?!?!?
full_str = "{"
full_str += ", ".join(shap_list)
full_str += "}"

# Return the result to be saved
return full_str


""" Sample Reporting """
def correct_samples(manager: OptunaModelManager, x: BaseDataManager, y: BaseDataManager):
Expand Down Expand Up @@ -133,4 +226,16 @@ def incorrect_samples(manager: OptunaModelManager, x: BaseDataManager, y: BaseDa
# Strip quotation marks from the result so the DB backend doesn't explode
bad_samples = clean_val_for_db(bad_samples)

return bad_samples
return bad_samples

""" ROC Curve """
def y_true_collector(_: OptunaModelManager, __: BaseDataManager, y: BaseDataManager):
""" Collects the true binary labels for ROC curve generation. """
return clean_val_for_db(list(y.as_array().flatten()))

def y_pred_proba_collector(manager: OptunaModelManager, x: BaseDataManager, _: BaseDataManager):
""" Collects predicted probabilities for the positive class. """
py = manager.predict_proba(x.as_array())
if py.shape[1] != 2:
raise ValueError(f"Expected binary classification with two probability columns; found {py.shape[1]}.")
return clean_val_for_db(list(py[:, 1])) # Probabilities for the positive class
20 changes: 19 additions & 1 deletion testing/iris_data/iris_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@
}
],
"post_split_hooks": [
{
"type": "imputation_simple",
"strategy": "most_frequent",
"features": ["color", "flower_category", "is_flower", "size"]
},
{
"type": "one_hot_encode",
"features": ["color", "flower_category"]
"features": ["color", "flower_category", "is_flower"]
},
{
"type": "ladder_encode",
Expand All @@ -33,6 +38,19 @@
"type": "standard_scaling",
"run_per_cross": true
},
{
"type": "drop_low_variance",
"threshold": 0.0
},
{
"type": "principal_component_analysis",
"proportion": {
"label": "pca_feature_proportion",
"type": "float",
"low": 0.1,
"high": 0.9
}
},
{
"type": "recursive_feature_elimination",
"proportion": {
Expand Down
Loading
Loading