bikes.core.models
Define trainable machine learning models.
1"""Define trainable machine learning models.""" 2 3# %% IMPORTS 4 5import abc 6import typing as T 7 8import pandas as pd 9import pydantic as pdt 10import shap 11from sklearn import compose, ensemble, pipeline, preprocessing 12from sklearn.base import BaseEstimator, RegressorMixin 13 14from bikes.core import schemas 15 16# %% TYPES 17 18# Model params 19ParamKey = str 20ParamValue = T.Any 21Params = dict[ParamKey, ParamValue] 22 23# %% MODELS 24 25 26class _RegressorTagger(RegressorMixin, BaseEstimator): 27 """Source of default scikit-learn regressor tags (sklearn >= 1.6 tags API).""" 28 29 30class Model(abc.ABC, pdt.BaseModel, strict=True, frozen=False, extra="forbid"): 31 """Base class for a project model. 32 33 Use a model to adapt AI/ML frameworks. 34 e.g., to swap easily one model with another. 35 """ 36 37 KIND: str 38 39 def __sklearn_tags__(self) -> T.Any: 40 """Return scikit-learn estimator tags. 41 42 Meta-estimators (e.g. GridSearchCV) query this since scikit-learn 1.6. The 43 project Model is a Pydantic adapter rather than a scikit-learn BaseEstimator, 44 so it borrows the default regressor tags. 45 46 Returns: 47 T.Any: scikit-learn estimator tags for a regressor. 48 """ 49 return _RegressorTagger().__sklearn_tags__() 50 51 def get_params(self, deep: bool = True) -> Params: # noqa: ARG002 # sklearn get_params interface 52 """Get the model params. 53 54 Args: 55 deep (bool, optional): ignored. 56 57 Returns: 58 Params: internal model parameters. 59 """ 60 params: Params = {} 61 for key, value in self.model_dump().items(): 62 if not key.startswith("_") and not key.isupper(): 63 params[key] = value 64 return params 65 66 def set_params(self, **params: ParamValue) -> T.Self: 67 """Set the model params in place. 68 69 Returns: 70 T.Self: instance of the model. 71 """ 72 for key, value in params.items(): 73 setattr(self, key, value) 74 return self 75 76 @abc.abstractmethod 77 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> T.Self: 78 """Fit the model on the given inputs and targets. 79 80 Args: 81 inputs (schemas.Inputs): model training inputs. 82 targets (schemas.Targets): model training targets. 83 84 Returns: 85 T.Self: instance of the model. 86 """ 87 88 @abc.abstractmethod 89 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 90 """Generate outputs with the model for the given inputs. 91 92 Args: 93 inputs (schemas.Inputs): model prediction inputs. 94 95 Returns: 96 schemas.Outputs: model prediction outputs. 97 """ 98 99 def explain_model(self) -> schemas.FeatureImportances: 100 """Explain the internal model structure. 101 102 Returns: 103 schemas.FeatureImportances: feature importances. 104 """ 105 raise NotImplementedError 106 107 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 108 """Explain model outputs on input samples. 109 110 Returns: 111 schemas.SHAPValues: SHAP values. 112 """ 113 raise NotImplementedError 114 115 def get_internal_model(self) -> T.Any: 116 """Return the internal model in the object. 117 118 Raises: 119 NotImplementedError: method not implemented. 120 121 Returns: 122 T.Any: any internal model (either empty or fitted). 123 """ 124 raise NotImplementedError 125 126 127class BaselineSklearnModel(Model): 128 """Simple baseline model based on scikit-learn. 129 130 Parameters: 131 max_depth (int): maximum depth of the random forest. 132 n_estimators (int): number of estimators in the random forest. 133 random_state (int, optional): random state of the machine learning pipeline. 134 """ 135 136 KIND: T.Literal["BaselineSklearnModel"] = "BaselineSklearnModel" 137 138 # params 139 max_depth: int = 20 140 n_estimators: int = 200 141 random_state: int | None = 42 142 # private 143 _pipeline: pipeline.Pipeline | None = None 144 _numericals: list[str] = [ 145 "yr", 146 "mnth", 147 "hr", 148 "holiday", 149 "weekday", 150 "workingday", 151 "temp", 152 "atemp", 153 "hum", 154 "windspeed", 155 "casual", 156 "registered", # too correlated with target 157 ] 158 _categoricals: list[str] = [ 159 "season", 160 "weathersit", 161 ] 162 163 @T.override 164 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> BaselineSklearnModel: 165 # subcomponents 166 categoricals_transformer = preprocessing.OneHotEncoder(sparse_output=False, handle_unknown="ignore") 167 # components 168 transformer = compose.ColumnTransformer( 169 [ 170 ("categoricals", categoricals_transformer, self._categoricals), 171 ("numericals", "passthrough", self._numericals), 172 ], 173 remainder="drop", 174 ) 175 regressor = ensemble.RandomForestRegressor( 176 max_depth=self.max_depth, 177 n_estimators=self.n_estimators, 178 random_state=self.random_state, 179 ) 180 # pipeline 181 self._pipeline = pipeline.Pipeline( 182 steps=[ 183 ("transformer", transformer), 184 ("regressor", regressor), 185 ] 186 ) 187 self._pipeline.fit(X=inputs, y=targets[schemas.TargetsSchema.cnt]) 188 return self 189 190 @T.override 191 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 192 model = self.get_internal_model() 193 prediction = model.predict(inputs) 194 outputs_ = pd.DataFrame(data={schemas.OutputsSchema.prediction: prediction}, index=inputs.index) 195 return schemas.OutputsSchema.check(data=outputs_) 196 197 @T.override 198 def explain_model(self) -> schemas.FeatureImportances: 199 model = self.get_internal_model() 200 regressor = model.named_steps["regressor"] 201 transformer = model.named_steps["transformer"] 202 feature = transformer.get_feature_names_out() 203 feature_importances_ = pd.DataFrame( 204 data={ 205 "feature": feature, 206 "importance": regressor.feature_importances_, 207 } 208 ) 209 return schemas.FeatureImportancesSchema.check(data=feature_importances_) 210 211 @T.override 212 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 213 model = self.get_internal_model() 214 regressor = model.named_steps["regressor"] 215 transformer = model.named_steps["transformer"] 216 transformed = transformer.transform(X=inputs) 217 explainer = shap.TreeExplainer(model=regressor) 218 shap_values_ = pd.DataFrame( 219 data=explainer.shap_values(X=transformed), 220 columns=transformer.get_feature_names_out(), 221 ) 222 return schemas.SHAPValuesSchema.check(data=shap_values_) 223 224 @T.override 225 def get_internal_model(self) -> pipeline.Pipeline: 226 model = self._pipeline 227 if model is None: 228 raise ValueError("Model is not fitted yet!") 229 return model 230 231 232ModelKind = BaselineSklearnModel
31class Model(abc.ABC, pdt.BaseModel, strict=True, frozen=False, extra="forbid"): 32 """Base class for a project model. 33 34 Use a model to adapt AI/ML frameworks. 35 e.g., to swap easily one model with another. 36 """ 37 38 KIND: str 39 40 def __sklearn_tags__(self) -> T.Any: 41 """Return scikit-learn estimator tags. 42 43 Meta-estimators (e.g. GridSearchCV) query this since scikit-learn 1.6. The 44 project Model is a Pydantic adapter rather than a scikit-learn BaseEstimator, 45 so it borrows the default regressor tags. 46 47 Returns: 48 T.Any: scikit-learn estimator tags for a regressor. 49 """ 50 return _RegressorTagger().__sklearn_tags__() 51 52 def get_params(self, deep: bool = True) -> Params: # noqa: ARG002 # sklearn get_params interface 53 """Get the model params. 54 55 Args: 56 deep (bool, optional): ignored. 57 58 Returns: 59 Params: internal model parameters. 60 """ 61 params: Params = {} 62 for key, value in self.model_dump().items(): 63 if not key.startswith("_") and not key.isupper(): 64 params[key] = value 65 return params 66 67 def set_params(self, **params: ParamValue) -> T.Self: 68 """Set the model params in place. 69 70 Returns: 71 T.Self: instance of the model. 72 """ 73 for key, value in params.items(): 74 setattr(self, key, value) 75 return self 76 77 @abc.abstractmethod 78 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> T.Self: 79 """Fit the model on the given inputs and targets. 80 81 Args: 82 inputs (schemas.Inputs): model training inputs. 83 targets (schemas.Targets): model training targets. 84 85 Returns: 86 T.Self: instance of the model. 87 """ 88 89 @abc.abstractmethod 90 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 91 """Generate outputs with the model for the given inputs. 92 93 Args: 94 inputs (schemas.Inputs): model prediction inputs. 95 96 Returns: 97 schemas.Outputs: model prediction outputs. 98 """ 99 100 def explain_model(self) -> schemas.FeatureImportances: 101 """Explain the internal model structure. 102 103 Returns: 104 schemas.FeatureImportances: feature importances. 105 """ 106 raise NotImplementedError 107 108 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 109 """Explain model outputs on input samples. 110 111 Returns: 112 schemas.SHAPValues: SHAP values. 113 """ 114 raise NotImplementedError 115 116 def get_internal_model(self) -> T.Any: 117 """Return the internal model in the object. 118 119 Raises: 120 NotImplementedError: method not implemented. 121 122 Returns: 123 T.Any: any internal model (either empty or fitted). 124 """ 125 raise NotImplementedError
Base class for a project model.
Use a model to adapt AI/ML frameworks. e.g., to swap easily one model with another.
52 def get_params(self, deep: bool = True) -> Params: # noqa: ARG002 # sklearn get_params interface 53 """Get the model params. 54 55 Args: 56 deep (bool, optional): ignored. 57 58 Returns: 59 Params: internal model parameters. 60 """ 61 params: Params = {} 62 for key, value in self.model_dump().items(): 63 if not key.startswith("_") and not key.isupper(): 64 params[key] = value 65 return params
Get the model params.
Arguments:
- deep (bool, optional): ignored.
Returns:
Params: internal model parameters.
67 def set_params(self, **params: ParamValue) -> T.Self: 68 """Set the model params in place. 69 70 Returns: 71 T.Self: instance of the model. 72 """ 73 for key, value in params.items(): 74 setattr(self, key, value) 75 return self
Set the model params in place.
Returns:
T.Self: instance of the model.
77 @abc.abstractmethod 78 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> T.Self: 79 """Fit the model on the given inputs and targets. 80 81 Args: 82 inputs (schemas.Inputs): model training inputs. 83 targets (schemas.Targets): model training targets. 84 85 Returns: 86 T.Self: instance of the model. 87 """
Fit the model on the given inputs and targets.
Arguments:
- inputs (schemas.Inputs): model training inputs.
- targets (schemas.Targets): model training targets.
Returns:
T.Self: instance of the model.
89 @abc.abstractmethod 90 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 91 """Generate outputs with the model for the given inputs. 92 93 Args: 94 inputs (schemas.Inputs): model prediction inputs. 95 96 Returns: 97 schemas.Outputs: model prediction outputs. 98 """
Generate outputs with the model for the given inputs.
Arguments:
- inputs (schemas.Inputs): model prediction inputs.
Returns:
schemas.Outputs: model prediction outputs.
100 def explain_model(self) -> schemas.FeatureImportances: 101 """Explain the internal model structure. 102 103 Returns: 104 schemas.FeatureImportances: feature importances. 105 """ 106 raise NotImplementedError
Explain the internal model structure.
Returns:
schemas.FeatureImportances: feature importances.
108 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 109 """Explain model outputs on input samples. 110 111 Returns: 112 schemas.SHAPValues: SHAP values. 113 """ 114 raise NotImplementedError
Explain model outputs on input samples.
Returns:
schemas.SHAPValues: SHAP values.
116 def get_internal_model(self) -> T.Any: 117 """Return the internal model in the object. 118 119 Raises: 120 NotImplementedError: method not implemented. 121 122 Returns: 123 T.Any: any internal model (either empty or fitted). 124 """ 125 raise NotImplementedError
Return the internal model in the object.
Raises:
- NotImplementedError: method not implemented.
Returns:
T.Any: any internal model (either empty or fitted).
128class BaselineSklearnModel(Model): 129 """Simple baseline model based on scikit-learn. 130 131 Parameters: 132 max_depth (int): maximum depth of the random forest. 133 n_estimators (int): number of estimators in the random forest. 134 random_state (int, optional): random state of the machine learning pipeline. 135 """ 136 137 KIND: T.Literal["BaselineSklearnModel"] = "BaselineSklearnModel" 138 139 # params 140 max_depth: int = 20 141 n_estimators: int = 200 142 random_state: int | None = 42 143 # private 144 _pipeline: pipeline.Pipeline | None = None 145 _numericals: list[str] = [ 146 "yr", 147 "mnth", 148 "hr", 149 "holiday", 150 "weekday", 151 "workingday", 152 "temp", 153 "atemp", 154 "hum", 155 "windspeed", 156 "casual", 157 "registered", # too correlated with target 158 ] 159 _categoricals: list[str] = [ 160 "season", 161 "weathersit", 162 ] 163 164 @T.override 165 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> BaselineSklearnModel: 166 # subcomponents 167 categoricals_transformer = preprocessing.OneHotEncoder(sparse_output=False, handle_unknown="ignore") 168 # components 169 transformer = compose.ColumnTransformer( 170 [ 171 ("categoricals", categoricals_transformer, self._categoricals), 172 ("numericals", "passthrough", self._numericals), 173 ], 174 remainder="drop", 175 ) 176 regressor = ensemble.RandomForestRegressor( 177 max_depth=self.max_depth, 178 n_estimators=self.n_estimators, 179 random_state=self.random_state, 180 ) 181 # pipeline 182 self._pipeline = pipeline.Pipeline( 183 steps=[ 184 ("transformer", transformer), 185 ("regressor", regressor), 186 ] 187 ) 188 self._pipeline.fit(X=inputs, y=targets[schemas.TargetsSchema.cnt]) 189 return self 190 191 @T.override 192 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 193 model = self.get_internal_model() 194 prediction = model.predict(inputs) 195 outputs_ = pd.DataFrame(data={schemas.OutputsSchema.prediction: prediction}, index=inputs.index) 196 return schemas.OutputsSchema.check(data=outputs_) 197 198 @T.override 199 def explain_model(self) -> schemas.FeatureImportances: 200 model = self.get_internal_model() 201 regressor = model.named_steps["regressor"] 202 transformer = model.named_steps["transformer"] 203 feature = transformer.get_feature_names_out() 204 feature_importances_ = pd.DataFrame( 205 data={ 206 "feature": feature, 207 "importance": regressor.feature_importances_, 208 } 209 ) 210 return schemas.FeatureImportancesSchema.check(data=feature_importances_) 211 212 @T.override 213 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 214 model = self.get_internal_model() 215 regressor = model.named_steps["regressor"] 216 transformer = model.named_steps["transformer"] 217 transformed = transformer.transform(X=inputs) 218 explainer = shap.TreeExplainer(model=regressor) 219 shap_values_ = pd.DataFrame( 220 data=explainer.shap_values(X=transformed), 221 columns=transformer.get_feature_names_out(), 222 ) 223 return schemas.SHAPValuesSchema.check(data=shap_values_) 224 225 @T.override 226 def get_internal_model(self) -> pipeline.Pipeline: 227 model = self._pipeline 228 if model is None: 229 raise ValueError("Model is not fitted yet!") 230 return model
Simple baseline model based on scikit-learn.
Arguments:
- max_depth (int): maximum depth of the random forest.
- n_estimators (int): number of estimators in the random forest.
- random_state (int, optional): random state of the machine learning pipeline.
164 @T.override 165 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> BaselineSklearnModel: 166 # subcomponents 167 categoricals_transformer = preprocessing.OneHotEncoder(sparse_output=False, handle_unknown="ignore") 168 # components 169 transformer = compose.ColumnTransformer( 170 [ 171 ("categoricals", categoricals_transformer, self._categoricals), 172 ("numericals", "passthrough", self._numericals), 173 ], 174 remainder="drop", 175 ) 176 regressor = ensemble.RandomForestRegressor( 177 max_depth=self.max_depth, 178 n_estimators=self.n_estimators, 179 random_state=self.random_state, 180 ) 181 # pipeline 182 self._pipeline = pipeline.Pipeline( 183 steps=[ 184 ("transformer", transformer), 185 ("regressor", regressor), 186 ] 187 ) 188 self._pipeline.fit(X=inputs, y=targets[schemas.TargetsSchema.cnt]) 189 return self
Fit the model on the given inputs and targets.
Arguments:
- inputs (schemas.Inputs): model training inputs.
- targets (schemas.Targets): model training targets.
Returns:
T.Self: instance of the model.
191 @T.override 192 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 193 model = self.get_internal_model() 194 prediction = model.predict(inputs) 195 outputs_ = pd.DataFrame(data={schemas.OutputsSchema.prediction: prediction}, index=inputs.index) 196 return schemas.OutputsSchema.check(data=outputs_)
Generate outputs with the model for the given inputs.
Arguments:
- inputs (schemas.Inputs): model prediction inputs.
Returns:
schemas.Outputs: model prediction outputs.
198 @T.override 199 def explain_model(self) -> schemas.FeatureImportances: 200 model = self.get_internal_model() 201 regressor = model.named_steps["regressor"] 202 transformer = model.named_steps["transformer"] 203 feature = transformer.get_feature_names_out() 204 feature_importances_ = pd.DataFrame( 205 data={ 206 "feature": feature, 207 "importance": regressor.feature_importances_, 208 } 209 ) 210 return schemas.FeatureImportancesSchema.check(data=feature_importances_)
Explain the internal model structure.
Returns:
schemas.FeatureImportances: feature importances.
212 @T.override 213 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 214 model = self.get_internal_model() 215 regressor = model.named_steps["regressor"] 216 transformer = model.named_steps["transformer"] 217 transformed = transformer.transform(X=inputs) 218 explainer = shap.TreeExplainer(model=regressor) 219 shap_values_ = pd.DataFrame( 220 data=explainer.shap_values(X=transformed), 221 columns=transformer.get_feature_names_out(), 222 ) 223 return schemas.SHAPValuesSchema.check(data=shap_values_)
Explain model outputs on input samples.
Returns:
schemas.SHAPValues: SHAP values.
225 @T.override 226 def get_internal_model(self) -> pipeline.Pipeline: 227 model = self._pipeline 228 if model is None: 229 raise ValueError("Model is not fitted yet!") 230 return model
Return the internal model in the object.
Raises:
- NotImplementedError: method not implemented.
Returns:
T.Any: any internal model (either empty or fitted).