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 11import skops.io as sio 12from sklearn import compose, ensemble, pipeline, preprocessing 13from sklearn.base import BaseEstimator, RegressorMixin 14 15from bikes.core import schemas 16 17# %% TYPES 18 19# Model params 20ParamKey = str 21ParamValue = T.Any 22Params = dict[ParamKey, ParamValue] 23 24# %% MODELS 25 26 27class _RegressorTagger(RegressorMixin, BaseEstimator): 28 """Source of default scikit-learn regressor tags (sklearn >= 1.6 tags API).""" 29 30 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 126 127 def save_internal_model(self, path: str) -> None: 128 """Save the fitted internal model to a file in a safe (non-pickle) format. 129 130 Args: 131 path (str): file path to write. 132 133 Raises: 134 NotImplementedError: method not implemented. 135 """ 136 raise NotImplementedError 137 138 def load_internal_model(self, path: str) -> T.Self: 139 """Load the internal model from a file written by `save_internal_model`. 140 141 Args: 142 path (str): file path to read. 143 144 Raises: 145 NotImplementedError: method not implemented. 146 147 Returns: 148 T.Self: instance of the model. 149 """ 150 raise NotImplementedError 151 152 153class BaselineSklearnModel(Model): 154 """Simple baseline model based on scikit-learn. 155 156 Parameters: 157 max_depth (int): maximum depth of the random forest. 158 n_estimators (int): number of estimators in the random forest. 159 random_state (int, optional): random state of the machine learning pipeline. 160 """ 161 162 KIND: T.Literal["BaselineSklearnModel"] = "BaselineSklearnModel" 163 164 # skops only reloads the types it knows are safe. Tree storage holds raw node indices 165 # that scikit-learn reads without bounds checks, so it is trusted explicitly here, 166 # for files this project writes, instead of trusting whatever a file declares. 167 TRUSTED_TYPES: T.ClassVar[list[str]] = ["sklearn.tree._tree.Tree"] 168 169 # params 170 max_depth: int = 20 171 n_estimators: int = 200 172 random_state: int | None = 42 173 # private 174 _pipeline: pipeline.Pipeline | None = None 175 _numericals: list[str] = [ 176 "yr", 177 "mnth", 178 "hr", 179 "holiday", 180 "weekday", 181 "workingday", 182 "temp", 183 "atemp", 184 "hum", 185 "windspeed", 186 # casual and registered are left out: they add up to the target (cnt = casual + registered) 187 ] 188 _categoricals: list[str] = [ 189 "season", 190 "weathersit", 191 ] 192 193 @T.override 194 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> BaselineSklearnModel: 195 if not inputs.index.equals(targets.index): 196 raise ValueError("Input and target sample IDs must match in the same order.") 197 # subcomponents 198 categoricals_transformer = preprocessing.OneHotEncoder(sparse_output=False, handle_unknown="ignore") 199 # components 200 transformer = compose.ColumnTransformer( 201 [ 202 ("categoricals", categoricals_transformer, self._categoricals), 203 ("numericals", "passthrough", self._numericals), 204 ], 205 remainder="drop", 206 ) 207 regressor = ensemble.RandomForestRegressor( 208 max_depth=self.max_depth, 209 n_estimators=self.n_estimators, 210 random_state=self.random_state, 211 ) 212 # pipeline 213 self._pipeline = pipeline.Pipeline( 214 steps=[ 215 ("transformer", transformer), 216 ("regressor", regressor), 217 ] 218 ) 219 self._pipeline.fit(X=inputs, y=targets[schemas.TargetsSchema.cnt]) 220 return self 221 222 @T.override 223 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 224 model = self.get_internal_model() 225 # round to the nearest count: the uint32 output schema would otherwise truncate 226 prediction = model.predict(inputs).round() 227 outputs_ = pd.DataFrame(data={schemas.OutputsSchema.prediction: prediction}, index=inputs.index) 228 return schemas.OutputsSchema.check(data=outputs_) 229 230 @T.override 231 def explain_model(self) -> schemas.FeatureImportances: 232 model = self.get_internal_model() 233 regressor = model.named_steps["regressor"] 234 transformer = model.named_steps["transformer"] 235 feature = transformer.get_feature_names_out() 236 feature_importances_ = pd.DataFrame( 237 data={ 238 "feature": feature, 239 "importance": regressor.feature_importances_, 240 } 241 ) 242 return schemas.FeatureImportancesSchema.check(data=feature_importances_) 243 244 @T.override 245 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 246 model = self.get_internal_model() 247 regressor = model.named_steps["regressor"] 248 transformer = model.named_steps["transformer"] 249 transformed = transformer.transform(X=inputs) 250 explainer = shap.TreeExplainer(model=regressor) 251 shap_values_ = pd.DataFrame( 252 data=explainer.shap_values(X=transformed), 253 columns=transformer.get_feature_names_out(), 254 index=inputs.index, 255 ) 256 return schemas.SHAPValuesSchema.check(data=shap_values_) 257 258 @T.override 259 def get_internal_model(self) -> pipeline.Pipeline: 260 model = self._pipeline 261 if model is None: 262 raise ValueError("Model is not fitted yet!") 263 return model 264 265 @T.override 266 def save_internal_model(self, path: str) -> None: 267 sio.dump(self.get_internal_model(), path) 268 269 @T.override 270 def load_internal_model(self, path: str) -> T.Self: 271 self._pipeline = sio.load(path, trusted=self.TRUSTED_TYPES) 272 return self 273 274 275ModelKind = BaselineSklearnModel
32class Model(abc.ABC, pdt.BaseModel, strict=True, frozen=False, extra="forbid"): 33 """Base class for a project model. 34 35 Use a model to adapt AI/ML frameworks. 36 e.g., to swap easily one model with another. 37 """ 38 39 KIND: str 40 41 def __sklearn_tags__(self) -> T.Any: 42 """Return scikit-learn estimator tags. 43 44 Meta-estimators (e.g. GridSearchCV) query this since scikit-learn 1.6. The 45 project Model is a Pydantic adapter rather than a scikit-learn BaseEstimator, 46 so it borrows the default regressor tags. 47 48 Returns: 49 T.Any: scikit-learn estimator tags for a regressor. 50 """ 51 return _RegressorTagger().__sklearn_tags__() 52 53 def get_params(self, deep: bool = True) -> Params: # noqa: ARG002 # sklearn get_params interface 54 """Get the model params. 55 56 Args: 57 deep (bool, optional): ignored. 58 59 Returns: 60 Params: internal model parameters. 61 """ 62 params: Params = {} 63 for key, value in self.model_dump().items(): 64 if not key.startswith("_") and not key.isupper(): 65 params[key] = value 66 return params 67 68 def set_params(self, **params: ParamValue) -> T.Self: 69 """Set the model params in place. 70 71 Returns: 72 T.Self: instance of the model. 73 """ 74 for key, value in params.items(): 75 setattr(self, key, value) 76 return self 77 78 @abc.abstractmethod 79 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> T.Self: 80 """Fit the model on the given inputs and targets. 81 82 Args: 83 inputs (schemas.Inputs): model training inputs. 84 targets (schemas.Targets): model training targets. 85 86 Returns: 87 T.Self: instance of the model. 88 """ 89 90 @abc.abstractmethod 91 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 92 """Generate outputs with the model for the given inputs. 93 94 Args: 95 inputs (schemas.Inputs): model prediction inputs. 96 97 Returns: 98 schemas.Outputs: model prediction outputs. 99 """ 100 101 def explain_model(self) -> schemas.FeatureImportances: 102 """Explain the internal model structure. 103 104 Returns: 105 schemas.FeatureImportances: feature importances. 106 """ 107 raise NotImplementedError 108 109 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 110 """Explain model outputs on input samples. 111 112 Returns: 113 schemas.SHAPValues: SHAP values. 114 """ 115 raise NotImplementedError 116 117 def get_internal_model(self) -> T.Any: 118 """Return the internal model in the object. 119 120 Raises: 121 NotImplementedError: method not implemented. 122 123 Returns: 124 T.Any: any internal model (either empty or fitted). 125 """ 126 raise NotImplementedError 127 128 def save_internal_model(self, path: str) -> None: 129 """Save the fitted internal model to a file in a safe (non-pickle) format. 130 131 Args: 132 path (str): file path to write. 133 134 Raises: 135 NotImplementedError: method not implemented. 136 """ 137 raise NotImplementedError 138 139 def load_internal_model(self, path: str) -> T.Self: 140 """Load the internal model from a file written by `save_internal_model`. 141 142 Args: 143 path (str): file path to read. 144 145 Raises: 146 NotImplementedError: method not implemented. 147 148 Returns: 149 T.Self: instance of the model. 150 """ 151 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.
53 def get_params(self, deep: bool = True) -> Params: # noqa: ARG002 # sklearn get_params interface 54 """Get the model params. 55 56 Args: 57 deep (bool, optional): ignored. 58 59 Returns: 60 Params: internal model parameters. 61 """ 62 params: Params = {} 63 for key, value in self.model_dump().items(): 64 if not key.startswith("_") and not key.isupper(): 65 params[key] = value 66 return params
Get the model params.
Arguments:
- deep (bool, optional): ignored.
Returns:
Params: internal model parameters.
68 def set_params(self, **params: ParamValue) -> T.Self: 69 """Set the model params in place. 70 71 Returns: 72 T.Self: instance of the model. 73 """ 74 for key, value in params.items(): 75 setattr(self, key, value) 76 return self
Set the model params in place.
Returns:
T.Self: instance of the model.
78 @abc.abstractmethod 79 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> T.Self: 80 """Fit the model on the given inputs and targets. 81 82 Args: 83 inputs (schemas.Inputs): model training inputs. 84 targets (schemas.Targets): model training targets. 85 86 Returns: 87 T.Self: instance of the model. 88 """
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.
90 @abc.abstractmethod 91 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 92 """Generate outputs with the model for the given inputs. 93 94 Args: 95 inputs (schemas.Inputs): model prediction inputs. 96 97 Returns: 98 schemas.Outputs: model prediction outputs. 99 """
Generate outputs with the model for the given inputs.
Arguments:
- inputs (schemas.Inputs): model prediction inputs.
Returns:
schemas.Outputs: model prediction outputs.
101 def explain_model(self) -> schemas.FeatureImportances: 102 """Explain the internal model structure. 103 104 Returns: 105 schemas.FeatureImportances: feature importances. 106 """ 107 raise NotImplementedError
Explain the internal model structure.
Returns:
schemas.FeatureImportances: feature importances.
109 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 110 """Explain model outputs on input samples. 111 112 Returns: 113 schemas.SHAPValues: SHAP values. 114 """ 115 raise NotImplementedError
Explain model outputs on input samples.
Returns:
schemas.SHAPValues: SHAP values.
117 def get_internal_model(self) -> T.Any: 118 """Return the internal model in the object. 119 120 Raises: 121 NotImplementedError: method not implemented. 122 123 Returns: 124 T.Any: any internal model (either empty or fitted). 125 """ 126 raise NotImplementedError
Return the internal model in the object.
Raises:
- NotImplementedError: method not implemented.
Returns:
T.Any: any internal model (either empty or fitted).
128 def save_internal_model(self, path: str) -> None: 129 """Save the fitted internal model to a file in a safe (non-pickle) format. 130 131 Args: 132 path (str): file path to write. 133 134 Raises: 135 NotImplementedError: method not implemented. 136 """ 137 raise NotImplementedError
Save the fitted internal model to a file in a safe (non-pickle) format.
Arguments:
- path (str): file path to write.
Raises:
- NotImplementedError: method not implemented.
139 def load_internal_model(self, path: str) -> T.Self: 140 """Load the internal model from a file written by `save_internal_model`. 141 142 Args: 143 path (str): file path to read. 144 145 Raises: 146 NotImplementedError: method not implemented. 147 148 Returns: 149 T.Self: instance of the model. 150 """ 151 raise NotImplementedError
Load the internal model from a file written by save_internal_model.
Arguments:
- path (str): file path to read.
Raises:
- NotImplementedError: method not implemented.
Returns:
T.Self: instance of the model.
154class BaselineSklearnModel(Model): 155 """Simple baseline model based on scikit-learn. 156 157 Parameters: 158 max_depth (int): maximum depth of the random forest. 159 n_estimators (int): number of estimators in the random forest. 160 random_state (int, optional): random state of the machine learning pipeline. 161 """ 162 163 KIND: T.Literal["BaselineSklearnModel"] = "BaselineSklearnModel" 164 165 # skops only reloads the types it knows are safe. Tree storage holds raw node indices 166 # that scikit-learn reads without bounds checks, so it is trusted explicitly here, 167 # for files this project writes, instead of trusting whatever a file declares. 168 TRUSTED_TYPES: T.ClassVar[list[str]] = ["sklearn.tree._tree.Tree"] 169 170 # params 171 max_depth: int = 20 172 n_estimators: int = 200 173 random_state: int | None = 42 174 # private 175 _pipeline: pipeline.Pipeline | None = None 176 _numericals: list[str] = [ 177 "yr", 178 "mnth", 179 "hr", 180 "holiday", 181 "weekday", 182 "workingday", 183 "temp", 184 "atemp", 185 "hum", 186 "windspeed", 187 # casual and registered are left out: they add up to the target (cnt = casual + registered) 188 ] 189 _categoricals: list[str] = [ 190 "season", 191 "weathersit", 192 ] 193 194 @T.override 195 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> BaselineSklearnModel: 196 if not inputs.index.equals(targets.index): 197 raise ValueError("Input and target sample IDs must match in the same order.") 198 # subcomponents 199 categoricals_transformer = preprocessing.OneHotEncoder(sparse_output=False, handle_unknown="ignore") 200 # components 201 transformer = compose.ColumnTransformer( 202 [ 203 ("categoricals", categoricals_transformer, self._categoricals), 204 ("numericals", "passthrough", self._numericals), 205 ], 206 remainder="drop", 207 ) 208 regressor = ensemble.RandomForestRegressor( 209 max_depth=self.max_depth, 210 n_estimators=self.n_estimators, 211 random_state=self.random_state, 212 ) 213 # pipeline 214 self._pipeline = pipeline.Pipeline( 215 steps=[ 216 ("transformer", transformer), 217 ("regressor", regressor), 218 ] 219 ) 220 self._pipeline.fit(X=inputs, y=targets[schemas.TargetsSchema.cnt]) 221 return self 222 223 @T.override 224 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 225 model = self.get_internal_model() 226 # round to the nearest count: the uint32 output schema would otherwise truncate 227 prediction = model.predict(inputs).round() 228 outputs_ = pd.DataFrame(data={schemas.OutputsSchema.prediction: prediction}, index=inputs.index) 229 return schemas.OutputsSchema.check(data=outputs_) 230 231 @T.override 232 def explain_model(self) -> schemas.FeatureImportances: 233 model = self.get_internal_model() 234 regressor = model.named_steps["regressor"] 235 transformer = model.named_steps["transformer"] 236 feature = transformer.get_feature_names_out() 237 feature_importances_ = pd.DataFrame( 238 data={ 239 "feature": feature, 240 "importance": regressor.feature_importances_, 241 } 242 ) 243 return schemas.FeatureImportancesSchema.check(data=feature_importances_) 244 245 @T.override 246 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 247 model = self.get_internal_model() 248 regressor = model.named_steps["regressor"] 249 transformer = model.named_steps["transformer"] 250 transformed = transformer.transform(X=inputs) 251 explainer = shap.TreeExplainer(model=regressor) 252 shap_values_ = pd.DataFrame( 253 data=explainer.shap_values(X=transformed), 254 columns=transformer.get_feature_names_out(), 255 index=inputs.index, 256 ) 257 return schemas.SHAPValuesSchema.check(data=shap_values_) 258 259 @T.override 260 def get_internal_model(self) -> pipeline.Pipeline: 261 model = self._pipeline 262 if model is None: 263 raise ValueError("Model is not fitted yet!") 264 return model 265 266 @T.override 267 def save_internal_model(self, path: str) -> None: 268 sio.dump(self.get_internal_model(), path) 269 270 @T.override 271 def load_internal_model(self, path: str) -> T.Self: 272 self._pipeline = sio.load(path, trusted=self.TRUSTED_TYPES) 273 return self
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.
194 @T.override 195 def fit(self, inputs: schemas.Inputs, targets: schemas.Targets) -> BaselineSklearnModel: 196 if not inputs.index.equals(targets.index): 197 raise ValueError("Input and target sample IDs must match in the same order.") 198 # subcomponents 199 categoricals_transformer = preprocessing.OneHotEncoder(sparse_output=False, handle_unknown="ignore") 200 # components 201 transformer = compose.ColumnTransformer( 202 [ 203 ("categoricals", categoricals_transformer, self._categoricals), 204 ("numericals", "passthrough", self._numericals), 205 ], 206 remainder="drop", 207 ) 208 regressor = ensemble.RandomForestRegressor( 209 max_depth=self.max_depth, 210 n_estimators=self.n_estimators, 211 random_state=self.random_state, 212 ) 213 # pipeline 214 self._pipeline = pipeline.Pipeline( 215 steps=[ 216 ("transformer", transformer), 217 ("regressor", regressor), 218 ] 219 ) 220 self._pipeline.fit(X=inputs, y=targets[schemas.TargetsSchema.cnt]) 221 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.
223 @T.override 224 def predict(self, inputs: schemas.Inputs) -> schemas.Outputs: 225 model = self.get_internal_model() 226 # round to the nearest count: the uint32 output schema would otherwise truncate 227 prediction = model.predict(inputs).round() 228 outputs_ = pd.DataFrame(data={schemas.OutputsSchema.prediction: prediction}, index=inputs.index) 229 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.
231 @T.override 232 def explain_model(self) -> schemas.FeatureImportances: 233 model = self.get_internal_model() 234 regressor = model.named_steps["regressor"] 235 transformer = model.named_steps["transformer"] 236 feature = transformer.get_feature_names_out() 237 feature_importances_ = pd.DataFrame( 238 data={ 239 "feature": feature, 240 "importance": regressor.feature_importances_, 241 } 242 ) 243 return schemas.FeatureImportancesSchema.check(data=feature_importances_)
Explain the internal model structure.
Returns:
schemas.FeatureImportances: feature importances.
245 @T.override 246 def explain_samples(self, inputs: schemas.Inputs) -> schemas.SHAPValues: 247 model = self.get_internal_model() 248 regressor = model.named_steps["regressor"] 249 transformer = model.named_steps["transformer"] 250 transformed = transformer.transform(X=inputs) 251 explainer = shap.TreeExplainer(model=regressor) 252 shap_values_ = pd.DataFrame( 253 data=explainer.shap_values(X=transformed), 254 columns=transformer.get_feature_names_out(), 255 index=inputs.index, 256 ) 257 return schemas.SHAPValuesSchema.check(data=shap_values_)
Explain model outputs on input samples.
Returns:
schemas.SHAPValues: SHAP values.
259 @T.override 260 def get_internal_model(self) -> pipeline.Pipeline: 261 model = self._pipeline 262 if model is None: 263 raise ValueError("Model is not fitted yet!") 264 return model
Return the internal model in the object.
Raises:
- NotImplementedError: method not implemented.
Returns:
T.Any: any internal model (either empty or fitted).
266 @T.override 267 def save_internal_model(self, path: str) -> None: 268 sio.dump(self.get_internal_model(), path)
Save the fitted internal model to a file in a safe (non-pickle) format.
Arguments:
- path (str): file path to write.
Raises:
- NotImplementedError: method not implemented.
270 @T.override 271 def load_internal_model(self, path: str) -> T.Self: 272 self._pipeline = sio.load(path, trusted=self.TRUSTED_TYPES) 273 return self
Load the internal model from a file written by save_internal_model.
Arguments:
- path (str): file path to read.
Raises:
- NotImplementedError: method not implemented.
Returns:
T.Self: instance of the model.