mlsauce
1try: 2 from .adaopt import AdaOpt 3except ImportError as e: 4 print(f"Could not import some modules: {e}") 5 6try: 7 from .booster import ( 8 LSBoostClassifier, 9 LSBoostRegressor, 10 GenericBoostingClassifier, 11 GenericBoostingRegressor, 12 ) 13except ImportError as e: 14 print(f"Could not import some modules: {e}") 15 16try: 17 from .lazybooster import ( 18 LazyBoostingClassifier, 19 LazyBoostingRegressor, 20 LazyBoostingMTS, 21 ) 22except ImportError as e: 23 print(f"Could not import some modules: {e}") 24 25try: 26 from .multitaskregressor import MultiTaskRegressor 27except ImportError as e: 28 print(f"Could not import some modules: {e}") 29 30try: 31 from .datasets import download 32except ImportError as e: 33 print(f"Could not import some modules: {e}") 34 35try: 36 from .elasticnet import ElasticNetRegressor 37except ImportError as e: 38 print(f"Could not import some modules: {e}") 39 40try: 41 from .kernelridge import KRLSRegressor 42except ImportError as e: 43 print(f"Could not import some modules: {e}") 44 45try: 46 from .lasso import LassoRegressor 47except ImportError as e: 48 print(f"Could not import some modules: {e}") 49 50try: 51 from .ridge import RidgeRegressor 52except ImportError as e: 53 print(f"Could not import some modules: {e}") 54 55try: 56 from .stump import StumpClassifier 57except ImportError as e: 58 print(f"Could not import some modules: {e}") 59 60try: 61 from .isotonicregression import IsotonicRegressor 62except ImportError as e: 63 print(f"Could not import some modules: {e}") 64 65try: 66 from .fpca import GenericFunctionalForecaster 67except ImportError as e: 68 print(f"Could not import some modules: {e}") 69 70try: 71 from .generators import ( 72 make_diverse_classification, 73 HealthcareTimeSeriesGenerator, 74 generate_synthetic_returns, 75 plot_synthetic_returns, 76 ) 77except ImportError as e: 78 print(f"Could not import generators: {e}") 79 80try: 81 from .catencoder import RankTargetEncoder 82except ImportError as e: 83 print(f"Could not import RankTargetEncoder: {e}") 84 85try: 86 from .rollingoriginregression import RollingOriginForecaster 87except ImportError as e: 88 print(f"Could not import RollingOriginForecaster: {e}") 89# from .encoders import corrtarget_encoder 90 91try: 92 from .penalizedcv import penalized_cross_val_score 93except ImportError as e: 94 print(f"Could not import penalized_cross_val_score: {e}") 95 96try: 97 from .conformalbayesian import ConformalBayesianRegressor 98except ImportError as e: 99 print(f"Could not import ConformalBayesianRegressor: {e}") 100 101try: 102 from .conformalbayesian import ConformalBayesianClassifier 103except ImportError as e: 104 print(f"Could not import ConformalBayesianClassifier: {e}") 105 106try: 107 from .contextawaretheta import ContextAwareThetaForecaster 108except ImportError as e: 109 print(f"Could not import ContextAwareThetaForecaster: {e}") 110 111try: 112 from .rvfljackknifeplus import RVFLJackknifePlus 113except ImportError as e: 114 print(f"Could not import RVFLJackknifePlus: {e}") 115 116__all__ = [ 117 "AdaOpt", 118 "ConformalBayesianRegressor", 119 "ConformalBayesianClassifier", 120 "ContextAwareThetaForecaster", 121 "LSBoostClassifier", 122 "GenericBoostingClassifier", 123 "GenericBoostingRegressor", 124 "StumpClassifier", 125 "ElasticNetRegressor", 126 "KRLSRegressor", 127 "LassoRegressor", 128 "LSBoostRegressor", 129 "LSTMRegressor", 130 "RidgeRegressor", 131 "LazyBoostingClassifier", 132 "LazyBoostingMTS", 133 "LazyBoostingRegressor", 134 "MultiTaskRegressor", 135 "IsotonicRegressor", 136 "GenericFunctionalForecaster", 137 "RankTargetEncoder", 138 "RollingOriginForecaster", 139 "RVFLJackknifePlus", 140 # Other imports 141 # "corrtarget_encoder", 142 "download", 143 # Non-modules: 144 "get_config", 145 "set_config", 146 "config_context", 147 "penalized_cross_val_score", 148 "make_diverse_classification", 149 "HealthcareTimeSeriesGenerator", 150 "generate_synthetic_returns", 151 "plot_synthetic_returns", 152]
19class ConformalBayesianRegressor(BaseEstimator, RegressorMixin): 20 def __init__( 21 self, 22 obj=Ridge, 23 level=95, 24 hyperparameter_bounds=None, 25 n_samples=20, 26 calibration_fraction=0.2, 27 scaling_method="standard", 28 random_state=None, 29 show_progress=False, 30 verbose=True, 31 n_jobs=-1, 32 ): 33 self.obj = obj 34 self.level = level 35 self.alpha_ = 1 - self.level / 100 36 self.hyperparameter_bounds = hyperparameter_bounds 37 self.n_samples = n_samples 38 self.calibration_fraction = calibration_fraction 39 self.scaling_method = scaling_method 40 self.random_state = random_state 41 self.verbose = verbose 42 self.show_progress = show_progress 43 self.n_jobs = n_jobs 44 45 self.is_fitted_ = False 46 47 def _sample_hyperparameters(self): 48 # Simple uniform sampling or use fixed bounds 49 configs = [] 50 for _ in range(self.n_samples): 51 if self.hyperparameter_bounds: 52 cfg = {} 53 for k, v in self.hyperparameter_bounds.items(): 54 if isinstance(v, list) and len(v) == 2: 55 # Always sample as float first 56 sampled_value = np.random.uniform(v[0], v[1]) 57 58 # If both bounds are integers, assume integer parameter 59 if isinstance(v[0], (int, np.integer)) and isinstance( 60 v[1], (int, np.integer) 61 ): 62 cfg[k] = int(sampled_value) 63 else: 64 cfg[k] = sampled_value 65 else: 66 # Assume fixed value 67 cfg[k] = v 68 else: 69 cfg = {} 70 configs.append(cfg) 71 return configs 72 73 def _train_models_parallel(self, X, y, configs): 74 def train_model(cfg): 75 self.obj.set_params(**cfg) 76 self.obj.fit(X, y) 77 return self.obj 78 79 if self.show_progress == False: 80 models = joblib.Parallel(n_jobs=self.n_jobs)( 81 joblib.delayed(train_model)(cfg) 82 for cfg in tqdm(configs, disable=not self.verbose) 83 ) 84 else: 85 models = joblib.Parallel(n_jobs=self.n_jobs)( 86 joblib.delayed(train_model)(cfg) for cfg in configs 87 ) 88 return models 89 90 def _predict_models(self, models, X): 91 if self.show_progress == False: 92 preds = [m.predict(X) for m in models] 93 else: 94 preds = [ 95 m.predict(X) for m in tqdm(models, disable=not self.verbose) 96 ] 97 return np.column_stack(preds) 98 99 def fit(self, X, y): 100 if hasattr(X, "values"): # keep DataFrame if possible 101 X = X.copy() 102 else: 103 X = np.asarray(X) 104 X = np.asarray(X) 105 y = np.asarray(y) 106 rng = check_random_state(self.random_state) 107 X, y = shuffle(X, y, random_state=rng) 108 # 1. Cluster with GMM (full covariance) 109 n_clusters = min(10, X.shape[0] // 30) 110 gmm = GaussianMixture( 111 n_components=n_clusters, 112 covariance_type="full", 113 random_state=self.random_state, 114 ) 115 clusters = gmm.fit_predict(X) 116 # 2. Stratified train/calibration split 117 X_train, X_calib, y_train, y_calib = train_test_split( 118 X, 119 y, 120 test_size=self.calibration_fraction, 121 random_state=self.random_state, 122 stratify=clusters, 123 ) 124 # 3. Scale features 125 if self.scaling_method == "standard": 126 scaler = StandardScaler().fit(X_train) 127 else: 128 raise ValueError("Scaling method must be 'standard'") 129 self.scaler_ = scaler 130 X_train_s = scaler.transform(X_train) 131 X_calib_s = scaler.transform(X_calib) 132 # 4. Train ensemble 133 configs = self._sample_hyperparameters() 134 self.models_ = self._train_models_parallel(X_train_s, y_train, configs) 135 # 5. Calibration residuals 136 preds_calib = self._predict_models(self.models_, X_calib_s) 137 self.calibration_residuals_ = y_calib - np.median(preds_calib, axis=1) 138 self.is_fitted_ = True 139 return self 140 141 def predict(self, X, return_pi=False): 142 """Obtain predictions and prediction intervals 143 144 Args: 145 146 X: array-like, shape = [n_samples, n_features]; 147 Testing set vectors, where n_samples is the number 148 of samples and n_features is the number of features. 149 150 return_pi: boolean 151 Whether the prediction interval is returned or not. 152 Default is False, for compatibility with other _estimators_. 153 If True, a tuple containing the predictions + lower and upper 154 bounds is returned. 155 156 """ 157 if not self.is_fitted_: 158 raise RuntimeError("Fit the model first") 159 X_s = self.scaler_.transform(X) 160 preds = self._predict_models(self.models_, X_s) 161 self.mean_ = np.median(preds, axis=1) 162 if return_pi == False: 163 return self.mean_ 164 DescribeResult = namedtuple( 165 "DescribeResult", ("mean", "lower", "upper") 166 ) 167 q = np.quantile(self.calibration_residuals_, q=self.alpha_ / 200) 168 return DescribeResult(self.mean_, self.mean_ + q, self.mean_ - q) 169 170 def get_coverage(self, y_true, lower, upper): 171 return np.mean((y_true >= lower) & (y_true <= upper))
Base class for all estimators in scikit-learn.
Inheriting from this class provides default implementations of:
- setting and getting parameters used by
GridSearchCVand friends; - textual and HTML representation displayed in terminals and IDEs;
- estimator serialization;
- parameters validation;
- data validation;
- feature names validation.
Read more in the :ref:User Guide <rolling_your_own_estimator>.
Notes
All estimators should specify all the parameters that can be set
at the class level in their __init__ as explicit keyword
arguments (no *args or **kwargs).
Examples
>>> import numpy as np
>>> from sklearn.base import BaseEstimator
>>> class MyEstimator(BaseEstimator):
... def __init__(self, *, param=1):
... self.param = param
... def fit(self, X, y=None):
... self.is_fitted_ = True
... return self
... def predict(self, X):
... return np.full(shape=X.shape[0], fill_value=self.param)
>>> estimator = MyEstimator(param=2)
>>> estimator.get_params()
{'param': 2}
>>> X = np.array([[1, 2], [2, 3], [3, 4]])
>>> y = np.array([1, 0, 1])
>>> estimator.fit(X, y).predict(X)
array([2, 2, 2])
>>> estimator.set_params(param=3).fit(X, y).predict(X)
array([3, 3, 3])
99 def fit(self, X, y): 100 if hasattr(X, "values"): # keep DataFrame if possible 101 X = X.copy() 102 else: 103 X = np.asarray(X) 104 X = np.asarray(X) 105 y = np.asarray(y) 106 rng = check_random_state(self.random_state) 107 X, y = shuffle(X, y, random_state=rng) 108 # 1. Cluster with GMM (full covariance) 109 n_clusters = min(10, X.shape[0] // 30) 110 gmm = GaussianMixture( 111 n_components=n_clusters, 112 covariance_type="full", 113 random_state=self.random_state, 114 ) 115 clusters = gmm.fit_predict(X) 116 # 2. Stratified train/calibration split 117 X_train, X_calib, y_train, y_calib = train_test_split( 118 X, 119 y, 120 test_size=self.calibration_fraction, 121 random_state=self.random_state, 122 stratify=clusters, 123 ) 124 # 3. Scale features 125 if self.scaling_method == "standard": 126 scaler = StandardScaler().fit(X_train) 127 else: 128 raise ValueError("Scaling method must be 'standard'") 129 self.scaler_ = scaler 130 X_train_s = scaler.transform(X_train) 131 X_calib_s = scaler.transform(X_calib) 132 # 4. Train ensemble 133 configs = self._sample_hyperparameters() 134 self.models_ = self._train_models_parallel(X_train_s, y_train, configs) 135 # 5. Calibration residuals 136 preds_calib = self._predict_models(self.models_, X_calib_s) 137 self.calibration_residuals_ = y_calib - np.median(preds_calib, axis=1) 138 self.is_fitted_ = True 139 return self
141 def predict(self, X, return_pi=False): 142 """Obtain predictions and prediction intervals 143 144 Args: 145 146 X: array-like, shape = [n_samples, n_features]; 147 Testing set vectors, where n_samples is the number 148 of samples and n_features is the number of features. 149 150 return_pi: boolean 151 Whether the prediction interval is returned or not. 152 Default is False, for compatibility with other _estimators_. 153 If True, a tuple containing the predictions + lower and upper 154 bounds is returned. 155 156 """ 157 if not self.is_fitted_: 158 raise RuntimeError("Fit the model first") 159 X_s = self.scaler_.transform(X) 160 preds = self._predict_models(self.models_, X_s) 161 self.mean_ = np.median(preds, axis=1) 162 if return_pi == False: 163 return self.mean_ 164 DescribeResult = namedtuple( 165 "DescribeResult", ("mean", "lower", "upper") 166 ) 167 q = np.quantile(self.calibration_residuals_, q=self.alpha_ / 200) 168 return DescribeResult(self.mean_, self.mean_ + q, self.mean_ - q)
Obtain predictions and prediction intervals
Args:
X: array-like, shape = [n_samples, n_features];
Testing set vectors, where n_samples is the number
of samples and n_features is the number of features.
return_pi: boolean
Whether the prediction interval is returned or not.
Default is False, for compatibility with other _estimators_.
If True, a tuple containing the predictions + lower and upper
bounds is returned.
21class ConformalBayesianClassifier(BaseEstimator, ClassifierMixin): 22 23 # construct the object ----- 24 _estimator_type = "classifier" 25 26 def __init__( 27 self, 28 obj=Ridge(), 29 hyperparameter_bounds=None, 30 n_samples=20, 31 calibration_fraction=0.2, 32 scaling_method="standard", 33 random_state=None, 34 verbose=True, 35 n_jobs=-1, 36 calibrated=False, 37 calibration_method="sigmoid", 38 calibration_cv=3, 39 use_classifier=True, 40 ): 41 """ 42 Conformal Bayesian Classifier with optional probability calibration. 43 44 Parameters 45 ---------- 46 obj : estimator object 47 Base estimator (default: Ridge() for regression-based, or classifier if use_classifier=True) 48 hyperparameter_bounds : dict, optional 49 Bounds for hyperparameter sampling 50 n_samples : int 51 Number of ensemble models to train 52 calibration_fraction : float 53 Fraction of data to use for conformal calibration 54 scaling_method : str 55 Feature scaling method (default: "standard") 56 random_state : int, optional 57 Random seed for reproducibility 58 verbose : bool 59 Whether to show progress bars 60 n_jobs : int 61 Number of parallel jobs (-1 for all cores) 62 calibrated : bool 63 Whether to apply probability calibration (default: False) 64 calibration_method : str 65 Method for calibration: "sigmoid" or "isotonic" (default: "sigmoid") 66 calibration_cv : int 67 Number of CV folds for calibration (default: 3) 68 use_classifier : bool 69 If True, use sklearn-like classifier with probability averaging. 70 If False, use regression-based approach with SimpleMultitaskClassifier (default: False) 71 """ 72 self.obj_base = obj 73 self.hyperparameter_bounds = hyperparameter_bounds 74 self.n_samples = n_samples 75 self.calibration_fraction = calibration_fraction 76 self.scaling_method = scaling_method 77 self.random_state = random_state 78 self.verbose = verbose 79 self.n_jobs = n_jobs 80 self.calibrated = calibrated 81 self.calibration_method = calibration_method 82 self.calibration_cv = calibration_cv 83 self.use_classifier = use_classifier 84 85 self.is_fitted_ = False 86 87 def _sample_hyperparameters(self): 88 """Sample hyperparameters for ensemble members""" 89 configs = [] 90 91 # Get parameter constraints from the model if available 92 param_constraints = {} 93 if hasattr(self.obj_base, "_parameter_constraints"): 94 param_constraints = self.obj_base._parameter_constraints 95 96 for _ in range(self.n_samples): 97 if self.hyperparameter_bounds: 98 cfg = {} 99 for k, v in self.hyperparameter_bounds.items(): 100 if isinstance(v, list) and len(v) == 2: 101 # If both bounds are integers, assume integer parameter 102 if isinstance(v[0], (int, np.integer)) and isinstance( 103 v[1], (int, np.integer) 104 ): 105 cfg[k] = np.random.randint(v[0], v[1] + 1) 106 else: 107 cfg[k] = np.random.uniform(v[0], v[1]) 108 else: 109 # Assume fixed value 110 cfg[k] = v 111 else: 112 cfg = {} 113 configs.append(cfg) 114 return configs 115 116 def _train_classifiers_parallel(self, X, y, configs): 117 """Train ensemble of classifiers in parallel""" 118 119 def train_classifier(cfg): 120 clf = clone(self.obj_base) 121 clf.set_params(**cfg) 122 clf.fit(X, y) 123 return clf 124 125 if self.verbose: 126 models = joblib.Parallel(n_jobs=self.n_jobs)( 127 joblib.delayed(train_classifier)(cfg) 128 for cfg in tqdm(configs, desc="Training classifiers") 129 ) 130 else: 131 models = joblib.Parallel(n_jobs=self.n_jobs)( 132 joblib.delayed(train_classifier)(cfg) for cfg in configs 133 ) 134 return models 135 136 def fit(self, X, y): 137 """ 138 Fit the conformal Bayesian classifier. 139 140 Parameters 141 ---------- 142 X : array-like of shape (n_samples, n_features) 143 Training data 144 y : array-like of shape (n_samples,) 145 Target values 146 147 Returns 148 ------- 149 self : object 150 Fitted classifier 151 """ 152 X = np.asarray(X) 153 y = np.asarray(y) 154 155 # Store classes 156 self.classes_ = np.unique(y) 157 self.n_classes_ = len(self.classes_) 158 159 if self.use_classifier: 160 # Simpler approach: use sklearn-like classifier with probability averaging 161 if not is_classifier(self.obj_base): 162 raise ValueError( 163 "use_classifier=True requires obj to be a classifier " 164 "(e.g., LogisticRegression, RandomForestClassifier)" 165 ) 166 167 rng = check_random_state(self.random_state) 168 X, y = shuffle(X, y, random_state=rng) 169 170 # Stratified train/calibration split 171 X_train, X_calib, y_train, y_calib = train_test_split( 172 X, 173 y, 174 test_size=self.calibration_fraction, 175 random_state=self.random_state, 176 stratify=y, 177 ) 178 179 # Scale features 180 if self.scaling_method == "standard": 181 self.scaler_ = StandardScaler().fit(X_train) 182 else: 183 raise ValueError("Scaling method must be 'standard'") 184 185 X_train_s = self.scaler_.transform(X_train) 186 X_calib_s = self.scaler_.transform(X_calib) 187 188 # Train ensemble of classifiers 189 configs = self._sample_hyperparameters() 190 self.classifiers_ = self._train_classifiers_parallel( 191 X_train_s, y_train, configs 192 ) 193 194 # Store calibration data for potential conformal prediction 195 self.X_calib_ = X_calib_s 196 self.y_calib_ = y_calib 197 198 if self.calibrated: 199 # Calibrate each classifier 200 if self.verbose: 201 print("Calibrating classifiers...") 202 203 self.calibrated_classifiers_ = [] 204 for clf in tqdm( 205 self.classifiers_, 206 disable=not self.verbose, 207 desc="Calibrating", 208 ): 209 cal_clf = CalibratedClassifierCV( 210 clf, 211 method=self.calibration_method, 212 cv="prefit", 213 ensemble=False, 214 ) 215 cal_clf.fit(X_calib_s, y_calib) 216 self.calibrated_classifiers_.append(cal_clf) 217 218 else: 219 # Original approach: use regression-based with SimpleMultitaskClassifier 220 base_regressor = ConformalBayesianRegressor( 221 self.obj_base, 222 hyperparameter_bounds=self.hyperparameter_bounds, 223 n_samples=self.n_samples, 224 calibration_fraction=self.calibration_fraction, 225 scaling_method=self.scaling_method, 226 random_state=self.random_state, 227 verbose=self.verbose, 228 n_jobs=self.n_jobs, 229 ) 230 231 # Wrap in multitask classifier 232 self.obj = ns.SimpleMultitaskClassifier(base_regressor) 233 234 if self.calibrated: 235 # Fit the base classifier first 236 self.obj.fit(X, y) 237 238 # Then wrap with calibration 239 self.calibrated_obj_ = CalibratedClassifierCV( 240 self.obj, 241 method=self.calibration_method, 242 cv="prefit", 243 ensemble=False, 244 ) 245 246 # Fit calibration on the same data 247 self.calibrated_obj_.fit(X, y) 248 else: 249 self.obj.fit(X, y) 250 251 self.is_fitted_ = True 252 return self 253 254 def predict_proba(self, X): 255 """ 256 Predict class probabilities. 257 258 Parameters 259 ---------- 260 X : array-like of shape (n_samples, n_features) 261 Test data 262 263 Returns 264 ------- 265 proba : array of shape (n_samples, n_classes) 266 Predicted probabilities for each class 267 """ 268 if not self.is_fitted_: 269 raise RuntimeError("Fit the model first") 270 271 X = np.asarray(X) 272 273 if self.use_classifier: 274 # Average probabilities from ensemble 275 X_s = self.scaler_.transform(X) 276 277 if self.calibrated: 278 # Use calibrated classifiers 279 probas = np.array( 280 [ 281 clf.predict_proba(X_s) 282 for clf in self.calibrated_classifiers_ 283 ] 284 ) 285 else: 286 # Use uncalibrated classifiers 287 probas = np.array( 288 [clf.predict_proba(X_s) for clf in self.classifiers_] 289 ) 290 291 # Average probabilities across ensemble 292 mean_proba = np.mean(probas, axis=0) 293 294 return mean_proba 295 else: 296 # Use regression-based approach 297 if self.calibrated: 298 return self.calibrated_obj_.predict_proba(X) 299 else: 300 return self.obj.predict_proba(X) 301 302 def predict(self, X): 303 """ 304 Predict class labels. 305 306 Parameters 307 ---------- 308 X : array-like of shape (n_samples, n_features) 309 Test data 310 311 Returns 312 ------- 313 y_pred : array of shape (n_samples,) 314 Predicted class labels 315 """ 316 proba = self.predict_proba(X) 317 return self.classes_[np.argmax(proba, axis=1)] 318 319 @property 320 def _estimator_type(self): 321 return "classifier"
Base class for all estimators in scikit-learn.
Inheriting from this class provides default implementations of:
- setting and getting parameters used by
GridSearchCVand friends; - textual and HTML representation displayed in terminals and IDEs;
- estimator serialization;
- parameters validation;
- data validation;
- feature names validation.
Read more in the :ref:User Guide <rolling_your_own_estimator>.
Notes
All estimators should specify all the parameters that can be set
at the class level in their __init__ as explicit keyword
arguments (no *args or **kwargs).
Examples
>>> import numpy as np
>>> from sklearn.base import BaseEstimator
>>> class MyEstimator(BaseEstimator):
... def __init__(self, *, param=1):
... self.param = param
... def fit(self, X, y=None):
... self.is_fitted_ = True
... return self
... def predict(self, X):
... return np.full(shape=X.shape[0], fill_value=self.param)
>>> estimator = MyEstimator(param=2)
>>> estimator.get_params()
{'param': 2}
>>> X = np.array([[1, 2], [2, 3], [3, 4]])
>>> y = np.array([1, 0, 1])
>>> estimator.fit(X, y).predict(X)
array([2, 2, 2])
>>> estimator.set_params(param=3).fit(X, y).predict(X)
array([3, 3, 3])
136 def fit(self, X, y): 137 """ 138 Fit the conformal Bayesian classifier. 139 140 Parameters 141 ---------- 142 X : array-like of shape (n_samples, n_features) 143 Training data 144 y : array-like of shape (n_samples,) 145 Target values 146 147 Returns 148 ------- 149 self : object 150 Fitted classifier 151 """ 152 X = np.asarray(X) 153 y = np.asarray(y) 154 155 # Store classes 156 self.classes_ = np.unique(y) 157 self.n_classes_ = len(self.classes_) 158 159 if self.use_classifier: 160 # Simpler approach: use sklearn-like classifier with probability averaging 161 if not is_classifier(self.obj_base): 162 raise ValueError( 163 "use_classifier=True requires obj to be a classifier " 164 "(e.g., LogisticRegression, RandomForestClassifier)" 165 ) 166 167 rng = check_random_state(self.random_state) 168 X, y = shuffle(X, y, random_state=rng) 169 170 # Stratified train/calibration split 171 X_train, X_calib, y_train, y_calib = train_test_split( 172 X, 173 y, 174 test_size=self.calibration_fraction, 175 random_state=self.random_state, 176 stratify=y, 177 ) 178 179 # Scale features 180 if self.scaling_method == "standard": 181 self.scaler_ = StandardScaler().fit(X_train) 182 else: 183 raise ValueError("Scaling method must be 'standard'") 184 185 X_train_s = self.scaler_.transform(X_train) 186 X_calib_s = self.scaler_.transform(X_calib) 187 188 # Train ensemble of classifiers 189 configs = self._sample_hyperparameters() 190 self.classifiers_ = self._train_classifiers_parallel( 191 X_train_s, y_train, configs 192 ) 193 194 # Store calibration data for potential conformal prediction 195 self.X_calib_ = X_calib_s 196 self.y_calib_ = y_calib 197 198 if self.calibrated: 199 # Calibrate each classifier 200 if self.verbose: 201 print("Calibrating classifiers...") 202 203 self.calibrated_classifiers_ = [] 204 for clf in tqdm( 205 self.classifiers_, 206 disable=not self.verbose, 207 desc="Calibrating", 208 ): 209 cal_clf = CalibratedClassifierCV( 210 clf, 211 method=self.calibration_method, 212 cv="prefit", 213 ensemble=False, 214 ) 215 cal_clf.fit(X_calib_s, y_calib) 216 self.calibrated_classifiers_.append(cal_clf) 217 218 else: 219 # Original approach: use regression-based with SimpleMultitaskClassifier 220 base_regressor = ConformalBayesianRegressor( 221 self.obj_base, 222 hyperparameter_bounds=self.hyperparameter_bounds, 223 n_samples=self.n_samples, 224 calibration_fraction=self.calibration_fraction, 225 scaling_method=self.scaling_method, 226 random_state=self.random_state, 227 verbose=self.verbose, 228 n_jobs=self.n_jobs, 229 ) 230 231 # Wrap in multitask classifier 232 self.obj = ns.SimpleMultitaskClassifier(base_regressor) 233 234 if self.calibrated: 235 # Fit the base classifier first 236 self.obj.fit(X, y) 237 238 # Then wrap with calibration 239 self.calibrated_obj_ = CalibratedClassifierCV( 240 self.obj, 241 method=self.calibration_method, 242 cv="prefit", 243 ensemble=False, 244 ) 245 246 # Fit calibration on the same data 247 self.calibrated_obj_.fit(X, y) 248 else: 249 self.obj.fit(X, y) 250 251 self.is_fitted_ = True 252 return self
Fit the conformal Bayesian classifier.
Parameters
X : array-like of shape (n_samples, n_features) Training data y : array-like of shape (n_samples,) Target values
Returns
self : object Fitted classifier
254 def predict_proba(self, X): 255 """ 256 Predict class probabilities. 257 258 Parameters 259 ---------- 260 X : array-like of shape (n_samples, n_features) 261 Test data 262 263 Returns 264 ------- 265 proba : array of shape (n_samples, n_classes) 266 Predicted probabilities for each class 267 """ 268 if not self.is_fitted_: 269 raise RuntimeError("Fit the model first") 270 271 X = np.asarray(X) 272 273 if self.use_classifier: 274 # Average probabilities from ensemble 275 X_s = self.scaler_.transform(X) 276 277 if self.calibrated: 278 # Use calibrated classifiers 279 probas = np.array( 280 [ 281 clf.predict_proba(X_s) 282 for clf in self.calibrated_classifiers_ 283 ] 284 ) 285 else: 286 # Use uncalibrated classifiers 287 probas = np.array( 288 [clf.predict_proba(X_s) for clf in self.classifiers_] 289 ) 290 291 # Average probabilities across ensemble 292 mean_proba = np.mean(probas, axis=0) 293 294 return mean_proba 295 else: 296 # Use regression-based approach 297 if self.calibrated: 298 return self.calibrated_obj_.predict_proba(X) 299 else: 300 return self.obj.predict_proba(X)
Predict class probabilities.
Parameters
X : array-like of shape (n_samples, n_features) Test data
Returns
proba : array of shape (n_samples, n_classes) Predicted probabilities for each class
302 def predict(self, X): 303 """ 304 Predict class labels. 305 306 Parameters 307 ---------- 308 X : array-like of shape (n_samples, n_features) 309 Test data 310 311 Returns 312 ------- 313 y_pred : array of shape (n_samples,) 314 Predicted class labels 315 """ 316 proba = self.predict_proba(X) 317 return self.classes_[np.argmax(proba, axis=1)]
Predict class labels.
Parameters
X : array-like of shape (n_samples, n_features) Test data
Returns
y_pred : array of shape (n_samples,) Predicted class labels
16class ContextAwareThetaForecaster: 17 """ 18 Unified Theta Method with multiple estimation modes. 19 20 Variants: 21 - 'classic': Standard Theta (SES + drift) 22 - 'cox': Context-aware with Cox partial likelihood 23 - 'ridge': Context-aware with Ridge regression 24 - 'ml': ML-enhanced with sklearn estimator 25 - 'rslope': R-style slopes via numerical differentiation (context-free) 26 27 Parameters 28 ---------- 29 mode : {'classic', 'cox', 'ridge', 'ml', 'rslope'} 30 Estimation mode 31 theta : float, default=0.5 32 Drift intensity (0=no drift, 0.5=classical, 1=full) 33 estimator : sklearn estimator, optional 34 For 'ml' and 'rslope' modes 35 tau : float, default=12 36 Temporal attention decay 37 sigma_val : float, optional 38 Value-based kernel bandwidth 39 kernel : {'temporal', 'value', 'hybrid'} 40 Attention kernel type 41 seasonal_period : int, optional 42 Seasonal period (auto-detected if None) 43 risk_set_size : int, default=15 44 Risk set size for Cox PL 45 stability_factor : float, default=0.8 46 Gamma clipping safety factor (0,1] 47 random_state : int, optional 48 Random seed for ML mode 49 """ 50 51 def __init__( 52 self, 53 mode: Literal["classic", "cox", "ridge", "ml", "rslope"] = "cox", 54 theta: float = 0.5, 55 estimator: Optional[Any] = None, 56 tau: float = 12.0, 57 sigma_val: Optional[float] = None, 58 kernel: Literal["temporal", "value", "hybrid"] = "temporal", 59 seasonal_period: Optional[int] = None, 60 risk_set_size: int = 15, 61 stability_factor: float = 0.8, 62 random_state: Optional[int] = None, 63 ): 64 self.mode = mode 65 self.theta = theta 66 self.estimator = estimator 67 self.tau = tau 68 self.sigma_val = sigma_val 69 self.kernel = kernel 70 self.seasonal_period = seasonal_period 71 self.risk_set_size = risk_set_size 72 self.stability_factor = stability_factor 73 self.random_state = random_state 74 75 # Fitted params 76 self.alpha_ = None 77 self.l_n_ = None 78 self.b0_ = None 79 self.gamma_ = None 80 self.mu_z_ = None 81 self.sigma_z_ = None 82 self.sigma2_ = None 83 self.seasonal_indices_ = None 84 self._y_train = None 85 self._fitted = False 86 87 # ============ SEASONAL DECOMPOSITION ============ 88 def _decompose(self, y: np.ndarray, period: int): 89 """Multiplicative seasonal decomposition""" 90 n = len(y) 91 if n < 2 * period: 92 return y, np.ones(n), y 93 94 # Centered MA trend 95 trend = np.full(n, np.nan) 96 half = period // 2 97 for i in range(half, n - half): 98 window = y[i - half : i + half + (period % 2)] 99 trend[i] = np.mean(window) 100 101 # Fill edges 102 valid = np.where(~np.isnan(trend))[0] 103 if len(valid) > 0: 104 trend[: valid[0]] = trend[valid[0]] 105 trend[valid[-1] + 1 :] = trend[valid[-1]] 106 107 # Seasonal indices 108 detrended = y / (trend + 1e-10) 109 seasonal = np.zeros(period) 110 for i in range(period): 111 seasonal[i] = np.nanmean(detrended[i::period]) 112 seasonal /= seasonal.mean() + 1e-10 113 114 seasonal_full = np.tile(seasonal, n // period + 1)[:n] 115 adjusted = y / (seasonal_full + 1e-10) 116 117 return adjusted, seasonal_full, trend 118 119 # ============ SES ============ 120 def _ses_level(self, y: np.ndarray, alpha: float) -> np.ndarray: 121 """Compute SES level array""" 122 level = np.zeros(len(y)) 123 level[0] = y[0] 124 for t in range(1, len(y)): 125 level[t] = alpha * y[t] + (1 - alpha) * level[t - 1] 126 return level 127 128 def _ses_nll(self, alpha: float, y: np.ndarray) -> float: 129 """SES negative log-likelihood""" 130 if alpha <= 0 or alpha >= 1: 131 return 1e10 132 level = self._ses_level(y, alpha) 133 resid = y[1:] - level[:-1] 134 sigma2 = np.var(resid) + 1e-10 135 return 0.5 * len(resid) * (np.log(2 * np.pi * sigma2) + 1) 136 137 def _fit_ses(self, y: np.ndarray): 138 """Estimate alpha via MLE""" 139 res = minimize( 140 lambda a: self._ses_nll(a[0], y), 141 [0.3], 142 bounds=[(0.01, 0.99)], 143 method="L-BFGS-B", 144 ) 145 alpha = res.x[0] 146 level_array = self._ses_level(y, alpha) 147 return alpha, level_array[-1], level_array 148 149 # ============ DRIFT ============ 150 def _estimate_drift(self, y: np.ndarray) -> float: 151 """Baseline drift: b0 = beta_OLS / 2""" 152 t = np.arange(len(y)) 153 beta = np.sum((t - t.mean()) * (y - y.mean())) / ( 154 np.sum((t - t.mean()) ** 2) + 1e-10 155 ) 156 return beta / 2.0 157 158 # ============ ATTENTION CONTEXT ============ 159 def _attention_kernel(self, Xj: float, Xt: float, j: int, t: int) -> float: 160 """Compute kernel weight""" 161 if self.kernel == "temporal": 162 return np.exp(-(t - j) / (self.tau + 1e-12)) 163 elif self.kernel == "value": 164 sigma = self.sigma_val if self.sigma_val else 1.0 165 return np.exp(-((Xj - Xt) ** 2) / (2 * sigma**2 + 1e-12)) 166 else: # hybrid 167 sigma = self.sigma_val if self.sigma_val else 1.0 168 return np.exp( 169 -(t - j) / (self.tau + 1e-12) 170 - ((Xj - Xt) ** 2) / (2 * sigma**2 + 1e-12) 171 ) 172 173 def _compute_context(self, y: np.ndarray) -> np.ndarray: 174 """Compute attention-weighted context z_t""" 175 n = len(y) 176 z = np.zeros(n) 177 for t in range(n): 178 weights = np.array( 179 [self._attention_kernel(y[j], y[t], j, t) for j in range(t + 1)] 180 ) 181 weights /= weights.sum() + 1e-12 182 z[t] = np.dot(weights, y[: t + 1]) 183 return z 184 185 # ============ GAMMA ESTIMATION ============ 186 def _partial_nll(self, gamma: float, z_star: np.ndarray) -> float: 187 """Cox partial negative log-likelihood (stable)""" 188 n = len(z_star) 189 k = min(self.risk_set_size, n // 2) 190 nll = 0.0 191 for t in range(k, n): 192 z_risk = z_star[max(0, t - k) : t + 1] 193 nll -= gamma * z_star[t] - logsumexp(gamma * z_risk) 194 return nll 195 196 def _estimate_gamma_cox(self, z_star: np.ndarray) -> float: 197 """Estimate gamma via Cox PL""" 198 res = minimize( 199 lambda g: self._partial_nll(g[0], z_star), [0.0], method="BFGS" 200 ) 201 return res.x[0] 202 203 def _estimate_gamma_ridge( 204 self, 205 y: np.ndarray, 206 z_star: np.ndarray, 207 level_array: np.ndarray, 208 alpha: float, 209 b0: float, 210 ) -> float: 211 """Estimate gamma via Ridge regression""" 212 try: 213 from sklearn.linear_model import Ridge 214 except ImportError: 215 warnings.warn("sklearn unavailable, using Cox method") 216 return self._estimate_gamma_cox(z_star) 217 218 n = len(y) 219 residuals = y - level_array 220 221 # Build design matrix 222 X_design = [] 223 y_resid = [] 224 for t in range(20, n): 225 D_h = self._D_n(1, alpha, t) 226 x_t = 0.5 * b0 * z_star[t - 1] * D_h 227 X_design.append(x_t) 228 y_resid.append(residuals[t]) 229 230 X_design = np.array(X_design).reshape(-1, 1) 231 y_resid = np.array(y_resid) 232 233 ridge = Ridge(alpha=10.0, fit_intercept=False) 234 ridge.fit(X_design, y_resid) 235 return ridge.coef_[0] 236 237 def _estimate_gamma_ml(self, y: np.ndarray, h: int) -> float: 238 """Estimate gamma via ML numerical differentiation""" 239 if self.estimator is None: 240 from sklearn.linear_model import LinearRegression 241 242 self.estimator = LinearRegression() 243 244 n = len(y) 245 if self.random_state is not None: 246 np.random.seed(self.random_state) 247 248 # Features 249 time_idx = np.arange(n + h) 250 time_norm = time_idx / n 251 n_random = 3 252 random_cov = np.random.randn(n + h, n_random) 253 X_all = np.column_stack([time_norm, random_cov]) 254 255 # Scale 256 from sklearn.preprocessing import StandardScaler 257 258 scaler = StandardScaler() 259 X_train = scaler.fit_transform(X_all[:n]) 260 X_all = scaler.transform(X_all) 261 262 # Fit 263 self.estimator.fit(X_train, y) 264 265 # Numerical differentiation 266 eps = 1e-4 ** (1 / 3) 267 h_eps = np.maximum(eps * np.abs(time_norm), eps / n) 268 269 t_plus = np.clip(time_norm + h_eps, 0, 2.0) 270 t_minus = np.clip(time_norm - h_eps, 0, 2.0) 271 272 X_plus = scaler.transform(np.column_stack([t_plus, random_cov])) 273 X_minus = scaler.transform(np.column_stack([t_minus, random_cov])) 274 275 fx_plus = self.estimator.predict(X_plus) 276 fx_minus = self.estimator.predict(X_minus) 277 278 slopes = (fx_plus - fx_minus) / (2 * h_eps) / n 279 280 # Approximate gamma from slopes 281 return self.theta * slopes.mean() / (0.5 * self.b0_ + 1e-12) 282 283 def _estimate_slopes_rslope(self, y: np.ndarray, h: int) -> np.ndarray: 284 """ 285 R-style slope estimation via numerical differentiation. 286 Returns slopes for forecast horizon (not gamma). 287 Similar to estimate_theta_slope() in R code. 288 """ 289 if self.estimator is None: 290 from sklearn.linear_model import LinearRegression 291 292 self.estimator = LinearRegression() 293 294 n = len(y) 295 if self.random_state is not None: 296 np.random.seed(self.random_state) 297 298 # Create features: time + random noise 299 time_idx = np.arange(n + h) 300 time_norm = time_idx / n 301 n_random = 3 302 random_cov = np.random.randn(n + h, n_random) 303 X_all = np.column_stack([time_norm, random_cov]) 304 305 # Scale features 306 from sklearn.preprocessing import StandardScaler 307 308 scaler = StandardScaler() 309 X_train = scaler.fit_transform(X_all[:n]) 310 X_all_scaled = scaler.transform(X_all) 311 312 # Fit model 313 self.estimator.fit(X_train, y) 314 315 # Numerical differentiation for ALL points (historical + forecast) 316 eps = 1e-4 ** (1 / 3) 317 h_eps = np.maximum(eps * np.abs(time_norm), eps / n) 318 319 t_plus = np.clip(time_norm + h_eps, 0, 2.0) 320 t_minus = np.clip(time_norm - h_eps, 0, 2.0) 321 322 X_plus = scaler.transform(np.column_stack([t_plus, random_cov])) 323 X_minus = scaler.transform(np.column_stack([t_minus, random_cov])) 324 325 fx_plus = self.estimator.predict(X_plus) 326 fx_minus = self.estimator.predict(X_minus) 327 328 # Slopes at each time point 329 slopes = (fx_plus - fx_minus) / (2 * h_eps) / n 330 331 # Return ONLY the forecast horizon slopes (last h values) 332 return slopes[-h:] * self.theta 333 334 # ============ DRIFT MULTIPLIER ============ 335 def _D_n(self, h: int, alpha: float, n: int) -> float: 336 """Drift multiplier D_n(h)""" 337 return (h - 1) + (1 - (1 - alpha) ** n) / (alpha + 1e-12) 338 339 # ============ FIT ============ 340 def fit(self, y: np.ndarray): 341 """Fit the model""" 342 y = np.asarray(y, dtype=float).ravel() 343 self._y_train = y.copy() 344 n = len(y) 345 346 # Detect seasonality 347 period = self.seasonal_period 348 if period is None: 349 period = 12 if n >= 24 else 1 350 351 # Decompose 352 if period > 1 and n >= 2 * period: 353 y_adj, seasonal_full, _ = self._decompose(y, period) 354 self.seasonal_indices_ = seasonal_full[-period:] 355 else: 356 y_adj = y.copy() 357 self.seasonal_indices_ = None 358 359 # SES 360 self.alpha_, self.l_n_, level_array = self._fit_ses(y_adj) 361 362 # Drift 363 self.b0_ = self._estimate_drift(y_adj) 364 365 # Context & Gamma 366 if self.mode == "classic": 367 self.gamma_ = 0.0 368 self.mu_z_ = 0.0 369 self.sigma_z_ = 1.0 370 elif self.mode == "rslope": 371 # R-style: no gamma, slopes computed during prediction 372 self.gamma_ = 0.0 373 self.mu_z_ = 0.0 374 self.sigma_z_ = 1.0 375 else: 376 # Compute context 377 z_raw = self._compute_context(y_adj) 378 self.mu_z_ = z_raw.mean() 379 self.sigma_z_ = z_raw.std() + 1e-12 380 z_star = (z_raw - self.mu_z_) / self.sigma_z_ 381 382 # Estimate gamma 383 if self.mode == "cox": 384 gamma_raw = self._estimate_gamma_cox(z_star) 385 elif self.mode == "ridge": 386 gamma_raw = self._estimate_gamma_ridge( 387 y_adj, z_star, level_array, self.alpha_, self.b0_ 388 ) 389 else: # ml 390 gamma_raw = self._estimate_gamma_ml(y_adj, 12) 391 392 # Stability constraint 393 D_max = self._D_n(36, self.alpha_, n) 394 stability_bound = 2.0 / (abs(self.b0_) * D_max + 1e-12) 395 self.gamma_ = np.clip( 396 gamma_raw, 397 -self.stability_factor * stability_bound, 398 self.stability_factor * stability_bound, 399 ) 400 401 # Innovation variance 402 residuals = y_adj[1:] - level_array[:-1] 403 self.sigma2_ = np.var(residuals, ddof=1) 404 405 self._fitted = True 406 return self 407 408 # ============ PREDICT ============ 409 def predict( 410 self, h: int, return_pi: bool = True, alpha_ci: float = 0.05 411 ) -> Dict[str, np.ndarray]: 412 """Generate forecasts""" 413 if not self._fitted: 414 raise RuntimeError("Call fit() first") 415 416 n = len(self._y_train) 417 418 # For rslope mode, compute slopes once 419 if self.mode == "rslope": 420 y_adj = ( 421 self._y_train 422 if self.seasonal_indices_ is None 423 else self._y_train 424 / np.tile( 425 self.seasonal_indices_, n // len(self.seasonal_indices_) + 1 426 )[:n] 427 ) 428 rslope_slopes = self._estimate_slopes_rslope(y_adj, h) 429 430 # Deseasonalized forecast (recursive for non-rslope, direct for rslope) 431 if self.mode == "rslope": 432 # R-style: direct application of slopes 433 fc = np.zeros(h) 434 for step in range(h): 435 D_h = self._D_n(step + 1, self.alpha_, n) 436 fc[step] = self.l_n_ + rslope_slopes[step] * D_h 437 else: 438 # Original recursive logic 439 fc = [] 440 history = list( 441 self._y_train 442 if self.seasonal_indices_ is None 443 else self._y_train 444 / np.tile( 445 self.seasonal_indices_, n // len(self.seasonal_indices_) + 1 446 )[:n] 447 ) 448 449 for step in range(1, h + 1): 450 # Recompute context 451 if self.mode != "classic": 452 t_now = len(history) - 1 453 weights = np.array( 454 [ 455 self._attention_kernel( 456 history[j], history[t_now], j, t_now 457 ) 458 for j in range(t_now + 1) 459 ] 460 ) 461 weights /= weights.sum() + 1e-12 462 z_h = np.dot(weights, history) 463 z_h_star = (z_h - self.mu_z_) / self.sigma_z_ 464 else: 465 z_h_star = 0.0 466 467 # Forecast 468 D_h = self._D_n(step, self.alpha_, n) 469 context_factor = 1.0 + self.gamma_ * z_h_star 470 fc_val = ( 471 self.l_n_ 472 + 0.5 * self.b0_ * self.theta * context_factor * D_h 473 ) 474 475 fc.append(fc_val) 476 history.append(fc_val) 477 478 fc = np.array(fc) 479 480 # Reseasonalize 481 if self.seasonal_indices_ is not None: 482 seasonal_fc = np.tile( 483 self.seasonal_indices_, (h // len(self.seasonal_indices_)) + 1 484 )[:h] 485 fc *= seasonal_fc 486 487 result = {"mean": fc} 488 489 # Prediction intervals 490 if return_pi: 491 z_score = norm.ppf(1 - alpha_ci / 2) 492 lower = [] 493 upper = [] 494 495 for step in range(1, h + 1): 496 D_h = self._D_n(step, self.alpha_, n) 497 var_ses = self.sigma2_ * ((step - 1) * self.alpha_**2 + 1) 498 var_ctx = ( 499 (0.5 * self.gamma_ * self.b0_ * self.sigma_z_ * D_h) ** 2 500 ) / n 501 se = np.sqrt(var_ses + var_ctx) 502 503 lower.append(fc[step - 1] - z_score * se) 504 upper.append(fc[step - 1] + z_score * se) 505 506 result["lower"] = np.array(lower) 507 result["upper"] = np.array(upper) 508 509 return result 510 511 # ============ UTILITIES ============ 512 def get_params(self) -> Dict[str, Any]: 513 """Get fitted parameters""" 514 return { 515 "mode": self.mode, 516 "alpha": self.alpha_, 517 "b0": self.b0_, 518 "gamma": self.gamma_, 519 "l_n": self.l_n_, 520 "theta": self.theta, 521 "sigma2": self.sigma2_, 522 "seasonal": self.seasonal_indices_ is not None, 523 } 524 525 def plot(self, forecast: Dict[str, np.ndarray], title: str = None): 526 """Rich visualization of forecasts""" 527 import matplotlib.pyplot as plt 528 529 n = len(self._y_train) 530 h = len(forecast["mean"]) 531 532 fig, axes = plt.subplots(2, 2, figsize=(14, 10)) 533 fig.suptitle( 534 title or f"Unified Theta: {self.mode.upper()} Mode", 535 fontsize=14, 536 fontweight="bold", 537 ) 538 539 # Plot 1: Forecasts with PI 540 ax1 = axes[0, 0] 541 train_idx = np.arange(n) 542 fc_idx = np.arange(n, n + h) 543 544 ax1.plot( 545 train_idx, 546 self._y_train, 547 "o-", 548 color="black", 549 label="Train", 550 linewidth=1.5, 551 markersize=3, 552 alpha=0.7, 553 ) 554 ax1.plot( 555 fc_idx, 556 forecast["mean"], 557 "s-", 558 color="steelblue", 559 label="Forecast", 560 linewidth=2.5, 561 markersize=5, 562 ) 563 if "lower" in forecast: 564 ax1.fill_between( 565 fc_idx, 566 forecast["lower"], 567 forecast["upper"], 568 color="lightblue", 569 alpha=0.3, 570 label="95% PI", 571 ) 572 ax1.axvline( 573 n - 0.5, color="red", linestyle="--", alpha=0.5, linewidth=2 574 ) 575 ax1.set_title("Forecasts with Prediction Intervals", fontweight="bold") 576 ax1.set_xlabel("Time") 577 ax1.set_ylabel("Value") 578 ax1.legend(loc="upper left") 579 ax1.grid(alpha=0.3) 580 581 # Plot 2: Context variable (if not classic/rslope) 582 ax2 = axes[0, 1] 583 if self.mode not in ["classic", "rslope"]: 584 y_adj = ( 585 self._y_train 586 if self.seasonal_indices_ is None 587 else self._y_train 588 / np.tile( 589 self.seasonal_indices_, n // len(self.seasonal_indices_) + 1 590 )[:n] 591 ) 592 z = self._compute_context(y_adj) 593 z_star = (z - self.mu_z_) / self.sigma_z_ 594 595 ax2.plot( 596 train_idx, 597 z_star, 598 color="purple", 599 linewidth=2, 600 label="z* (standardized)", 601 ) 602 ax2.axhline(0, color="black", linestyle="--", alpha=0.5) 603 ax2.fill_between( 604 train_idx, 605 0, 606 z_star, 607 where=(z_star > 0), 608 color="green", 609 alpha=0.2, 610 label="Above trend", 611 ) 612 ax2.fill_between( 613 train_idx, 614 0, 615 z_star, 616 where=(z_star < 0), 617 color="red", 618 alpha=0.2, 619 label="Below trend", 620 ) 621 ax2.set_title( 622 f"Context Signal (γ={self.gamma_:.4f})", fontweight="bold" 623 ) 624 ax2.set_xlabel("Time") 625 ax2.set_ylabel("z* (std dev)") 626 ax2.legend() 627 elif self.mode == "rslope": 628 ax2.text( 629 0.5, 630 0.5, 631 "R-Slope Mode\n(Direct ML Slopes)", 632 ha="center", 633 va="center", 634 transform=ax2.transAxes, 635 fontsize=14, 636 bbox=dict(boxstyle="round", facecolor="lightblue"), 637 ) 638 ax2.axis("off") 639 else: 640 ax2.text( 641 0.5, 642 0.5, 643 "Classic Mode\n(No Context)", 644 ha="center", 645 va="center", 646 transform=ax2.transAxes, 647 fontsize=14, 648 bbox=dict(boxstyle="round", facecolor="wheat"), 649 ) 650 ax2.axis("off") 651 ax2.grid(alpha=0.3) 652 653 # Plot 3: Residuals 654 ax3 = axes[1, 0] 655 y_adj = ( 656 self._y_train 657 if self.seasonal_indices_ is None 658 else self._y_train 659 / np.tile( 660 self.seasonal_indices_, n // len(self.seasonal_indices_) + 1 661 )[:n] 662 ) 663 level_array = self._ses_level(y_adj, self.alpha_) 664 residuals = y_adj[1:] - level_array[:-1] 665 666 ax3.scatter(train_idx[1:], residuals, alpha=0.6, s=20, color="coral") 667 ax3.axhline(0, color="black", linestyle="--", linewidth=1.5) 668 ax3.set_title(f"Residuals (σ²={self.sigma2_:.3f})", fontweight="bold") 669 ax3.set_xlabel("Time") 670 ax3.set_ylabel("Residual") 671 ax3.grid(alpha=0.3) 672 673 # Histogram 674 ax3_inset = ax3.inset_axes([0.65, 0.65, 0.3, 0.3]) 675 ax3_inset.hist( 676 residuals, bins=15, color="coral", alpha=0.7, edgecolor="black" 677 ) 678 ax3_inset.axvline(0, color="black", linestyle="--", linewidth=1) 679 ax3_inset.set_title("Distribution", fontsize=8) 680 ax3_inset.tick_params(labelsize=7) 681 682 # Plot 4: Summary table 683 ax4 = axes[1, 1] 684 ax4.axis("off") 685 686 params = self.get_params() 687 summary = f""" 688╔═══════════════════════════════════════╗ 689║ MODEL PARAMETERS ║ 690╠═══════════════════════════════════════╣ 691║ ║ 692║ Mode: {self.mode.upper():<20} ║ 693║ Theta: {self.theta:<20.4f} ║ 694║ Alpha (α): {params['alpha']:<20.4f} ║ 695║ Drift (b₀): {params['b0']:<20.4f} ║ 696║ Gamma (γ): {params['gamma']:<20.6f} ║ 697║ Level (ℓₙ): {params['l_n']:<20.2f} ║ 698║ σ²: {params['sigma2']:<20.4f} ║ 699║ Seasonal: {str(params['seasonal']):<20} ║ 700║ ║ 701╠═══════════════════════════════════════╣ 702║ FORECAST SUMMARY ║ 703╠═══════════════════════════════════════╣ 704║ ║ 705║ Horizon: {h:<20} steps ║ 706║ Final FC: {forecast['mean'][-1]:<20.2f} ║ 707""" 708 if "lower" in forecast: 709 summary += f"║ 95% PI: [{forecast['lower'][-1]:>6.2f}, {forecast['upper'][-1]:>6.2f}] ║\n" 710 711 summary += "║ ║\n" 712 summary += "╚═══════════════════════════════════════╝" 713 714 ax4.text( 715 0.05, 716 0.95, 717 summary, 718 fontsize=9, 719 family="monospace", 720 verticalalignment="top", 721 transform=ax4.transAxes, 722 bbox=dict( 723 boxstyle="round", 724 facecolor="lightyellow", 725 alpha=0.8, 726 edgecolor="black", 727 linewidth=1.5, 728 ), 729 ) 730 731 plt.tight_layout() 732 return fig
Unified Theta Method with multiple estimation modes.
Variants:
- 'classic': Standard Theta (SES + drift)
- 'cox': Context-aware with Cox partial likelihood
- 'ridge': Context-aware with Ridge regression
- 'ml': ML-enhanced with sklearn estimator
- 'rslope': R-style slopes via numerical differentiation (context-free)
Parameters
mode : {'classic', 'cox', 'ridge', 'ml', 'rslope'} Estimation mode theta : float, default=0.5 Drift intensity (0=no drift, 0.5=classical, 1=full) estimator : sklearn estimator, optional For 'ml' and 'rslope' modes tau : float, default=12 Temporal attention decay sigma_val : float, optional Value-based kernel bandwidth kernel : {'temporal', 'value', 'hybrid'} Attention kernel type seasonal_period : int, optional Seasonal period (auto-detected if None) risk_set_size : int, default=15 Risk set size for Cox PL stability_factor : float, default=0.8 Gamma clipping safety factor (0,1] random_state : int, optional Random seed for ML mode
340 def fit(self, y: np.ndarray): 341 """Fit the model""" 342 y = np.asarray(y, dtype=float).ravel() 343 self._y_train = y.copy() 344 n = len(y) 345 346 # Detect seasonality 347 period = self.seasonal_period 348 if period is None: 349 period = 12 if n >= 24 else 1 350 351 # Decompose 352 if period > 1 and n >= 2 * period: 353 y_adj, seasonal_full, _ = self._decompose(y, period) 354 self.seasonal_indices_ = seasonal_full[-period:] 355 else: 356 y_adj = y.copy() 357 self.seasonal_indices_ = None 358 359 # SES 360 self.alpha_, self.l_n_, level_array = self._fit_ses(y_adj) 361 362 # Drift 363 self.b0_ = self._estimate_drift(y_adj) 364 365 # Context & Gamma 366 if self.mode == "classic": 367 self.gamma_ = 0.0 368 self.mu_z_ = 0.0 369 self.sigma_z_ = 1.0 370 elif self.mode == "rslope": 371 # R-style: no gamma, slopes computed during prediction 372 self.gamma_ = 0.0 373 self.mu_z_ = 0.0 374 self.sigma_z_ = 1.0 375 else: 376 # Compute context 377 z_raw = self._compute_context(y_adj) 378 self.mu_z_ = z_raw.mean() 379 self.sigma_z_ = z_raw.std() + 1e-12 380 z_star = (z_raw - self.mu_z_) / self.sigma_z_ 381 382 # Estimate gamma 383 if self.mode == "cox": 384 gamma_raw = self._estimate_gamma_cox(z_star) 385 elif self.mode == "ridge": 386 gamma_raw = self._estimate_gamma_ridge( 387 y_adj, z_star, level_array, self.alpha_, self.b0_ 388 ) 389 else: # ml 390 gamma_raw = self._estimate_gamma_ml(y_adj, 12) 391 392 # Stability constraint 393 D_max = self._D_n(36, self.alpha_, n) 394 stability_bound = 2.0 / (abs(self.b0_) * D_max + 1e-12) 395 self.gamma_ = np.clip( 396 gamma_raw, 397 -self.stability_factor * stability_bound, 398 self.stability_factor * stability_bound, 399 ) 400 401 # Innovation variance 402 residuals = y_adj[1:] - level_array[:-1] 403 self.sigma2_ = np.var(residuals, ddof=1) 404 405 self._fitted = True 406 return self
Fit the model
409 def predict( 410 self, h: int, return_pi: bool = True, alpha_ci: float = 0.05 411 ) -> Dict[str, np.ndarray]: 412 """Generate forecasts""" 413 if not self._fitted: 414 raise RuntimeError("Call fit() first") 415 416 n = len(self._y_train) 417 418 # For rslope mode, compute slopes once 419 if self.mode == "rslope": 420 y_adj = ( 421 self._y_train 422 if self.seasonal_indices_ is None 423 else self._y_train 424 / np.tile( 425 self.seasonal_indices_, n // len(self.seasonal_indices_) + 1 426 )[:n] 427 ) 428 rslope_slopes = self._estimate_slopes_rslope(y_adj, h) 429 430 # Deseasonalized forecast (recursive for non-rslope, direct for rslope) 431 if self.mode == "rslope": 432 # R-style: direct application of slopes 433 fc = np.zeros(h) 434 for step in range(h): 435 D_h = self._D_n(step + 1, self.alpha_, n) 436 fc[step] = self.l_n_ + rslope_slopes[step] * D_h 437 else: 438 # Original recursive logic 439 fc = [] 440 history = list( 441 self._y_train 442 if self.seasonal_indices_ is None 443 else self._y_train 444 / np.tile( 445 self.seasonal_indices_, n // len(self.seasonal_indices_) + 1 446 )[:n] 447 ) 448 449 for step in range(1, h + 1): 450 # Recompute context 451 if self.mode != "classic": 452 t_now = len(history) - 1 453 weights = np.array( 454 [ 455 self._attention_kernel( 456 history[j], history[t_now], j, t_now 457 ) 458 for j in range(t_now + 1) 459 ] 460 ) 461 weights /= weights.sum() + 1e-12 462 z_h = np.dot(weights, history) 463 z_h_star = (z_h - self.mu_z_) / self.sigma_z_ 464 else: 465 z_h_star = 0.0 466 467 # Forecast 468 D_h = self._D_n(step, self.alpha_, n) 469 context_factor = 1.0 + self.gamma_ * z_h_star 470 fc_val = ( 471 self.l_n_ 472 + 0.5 * self.b0_ * self.theta * context_factor * D_h 473 ) 474 475 fc.append(fc_val) 476 history.append(fc_val) 477 478 fc = np.array(fc) 479 480 # Reseasonalize 481 if self.seasonal_indices_ is not None: 482 seasonal_fc = np.tile( 483 self.seasonal_indices_, (h // len(self.seasonal_indices_)) + 1 484 )[:h] 485 fc *= seasonal_fc 486 487 result = {"mean": fc} 488 489 # Prediction intervals 490 if return_pi: 491 z_score = norm.ppf(1 - alpha_ci / 2) 492 lower = [] 493 upper = [] 494 495 for step in range(1, h + 1): 496 D_h = self._D_n(step, self.alpha_, n) 497 var_ses = self.sigma2_ * ((step - 1) * self.alpha_**2 + 1) 498 var_ctx = ( 499 (0.5 * self.gamma_ * self.b0_ * self.sigma_z_ * D_h) ** 2 500 ) / n 501 se = np.sqrt(var_ses + var_ctx) 502 503 lower.append(fc[step - 1] - z_score * se) 504 upper.append(fc[step - 1] + z_score * se) 505 506 result["lower"] = np.array(lower) 507 result["upper"] = np.array(upper) 508 509 return result
Generate forecasts
19class ElasticNetRegressor(BaseEstimator, RegressorMixin): 20 """Elasticnet. 21 22 Attributes: 23 24 reg_lambda: float 25 regularization parameter. 26 27 alpha: float 28 compromise between L1 and L2 regularization (must be in [0, 1]), 29 for `solver` == 'enet'. 30 31 backend: str 32 type of backend; must be in ('cpu', 'gpu', 'tpu') 33 34 """ 35 36 def __init__(self, reg_lambda=0.1, alpha=0.5, backend="cpu"): 37 assert backend in ( 38 "cpu", 39 "gpu", 40 "tpu", 41 ), "`backend` must be in ('cpu', 'gpu', 'tpu')" 42 43 sys_platform = platform.system() 44 45 if (sys_platform == "Windows") and (backend in ("gpu", "tpu")): 46 warnings.warn( 47 "No GPU/TPU computing on Windows yet, backend set to 'cpu'" 48 ) 49 backend = "cpu" 50 51 self.reg_lambda = reg_lambda 52 self.alpha = alpha 53 self.backend = backend 54 if self.backend in ("gpu", "tpu"): 55 check_and_install("jax") 56 check_and_install("jaxlib") 57 58 def fit(self, X, y, **kwargs): 59 """Fit matrixops (classifier) to training data (X, y) 60 61 Args: 62 63 X: {array-like}, shape = [n_samples, n_features] 64 Training vectors, where n_samples is the number 65 of samples and n_features is the number of features. 66 67 y: array-like, shape = [n_samples] 68 Target values. 69 70 **kwargs: additional parameters to be passed to self.cook_training_set. 71 72 Returns: 73 74 self: object. 75 76 """ 77 fit_result = fit_elasticnet( 78 X, y, lam=self.reg_lambda, alpha=self.alpha, backend=self.backend 79 ) 80 self.coef_ = fit_result.coef_ 81 self.y_train_mean = fit_result.y_train_mean 82 self.scaler = fit_result.scaler 83 self.converged = fit_result.converged 84 return self 85 86 def predict(self, X, **kwargs): 87 """Predict test data X. 88 89 Args: 90 91 X: {array-like}, shape = [n_samples, n_features] 92 Training vectors, where n_samples is the number 93 of samples and n_features is the number of features. 94 95 **kwargs: additional parameters to be passed to `predict_proba` 96 97 Returns: 98 99 model predictions: {array-like} 100 101 """ 102 return predict_elasticnet(X, self, backend=self.backend)
Elasticnet.
Attributes:
reg_lambda: float
regularization parameter.
alpha: float
compromise between L1 and L2 regularization (must be in [0, 1]),
for `solver` == 'enet'.
backend: str
type of backend; must be in ('cpu', 'gpu', 'tpu')
58 def fit(self, X, y, **kwargs): 59 """Fit matrixops (classifier) to training data (X, y) 60 61 Args: 62 63 X: {array-like}, shape = [n_samples, n_features] 64 Training vectors, where n_samples is the number 65 of samples and n_features is the number of features. 66 67 y: array-like, shape = [n_samples] 68 Target values. 69 70 **kwargs: additional parameters to be passed to self.cook_training_set. 71 72 Returns: 73 74 self: object. 75 76 """ 77 fit_result = fit_elasticnet( 78 X, y, lam=self.reg_lambda, alpha=self.alpha, backend=self.backend 79 ) 80 self.coef_ = fit_result.coef_ 81 self.y_train_mean = fit_result.y_train_mean 82 self.scaler = fit_result.scaler 83 self.converged = fit_result.converged 84 return self
Fit matrixops (classifier) to training data (X, y)
Args:
X: {array-like}, shape = [n_samples, n_features]
Training vectors, where n_samples is the number
of samples and n_features is the number of features.
y: array-like, shape = [n_samples]
Target values.
**kwargs: additional parameters to be passed to self.cook_training_set.
Returns:
self: object.
86 def predict(self, X, **kwargs): 87 """Predict test data X. 88 89 Args: 90 91 X: {array-like}, shape = [n_samples, n_features] 92 Training vectors, where n_samples is the number 93 of samples and n_features is the number of features. 94 95 **kwargs: additional parameters to be passed to `predict_proba` 96 97 Returns: 98 99 model predictions: {array-like} 100 101 """ 102 return predict_elasticnet(X, self, backend=self.backend)
Predict test data X.
Args:
X: {array-like}, shape = [n_samples, n_features]
Training vectors, where n_samples is the number
of samples and n_features is the number of features.
**kwargs: additional parameters to be passed to `predict_proba`
Returns:
model predictions: {array-like}
36class KRLSRegressor(BaseEstimator, RegressorMixin): 37 38 def __init__(self, regularization=0.1, kernel=None, backend="cpu"): 39 40 if kernel is None: 41 if backend == "cpu": 42 43 def kernel(x, y): 44 return np.sqrt(np.sum(np.square(x - y))) 45 46 else: 47 48 def kernel(x, y): 49 device_put(x) 50 device_put(y) 51 return jnp.sqrt(jnp.sum(jnp.square(x - y))) 52 53 self.backend = backend 54 self.kernel = kernel 55 self.regularization = regularization 56 self.ym_ = None 57 self.scaler_ = StandardScaler() 58 self.X_ = None 59 self.coef_ = None 60 61 def fit(self, X, y): 62 self.ym_ = np.mean(y) 63 centered_y = y - self.ym_ 64 X_ = self.scaler_.fit_transform(X) 65 if self.backend == "cpu": 66 K = compute_kernel_matrix( 67 X_, self.kernel 68 ) + self.regularization * np.eye(X_.shape[0]) 69 self.coef_ = np.linalg.solve(K, centered_y) 70 else: 71 device_put(X_) 72 device_put(centered_y) 73 K = compute_kernel_matrix( 74 X_, self.kernel 75 ) + self.regularization * jnp.eye(X_.shape[0]) 76 self.coef_ = jnp.linalg.solve(K, centered_y) 77 self.X_ = X_ 78 return self 79 80 def predict(self, X): 81 X_ = self.scaler_.transform(X) 82 if self.backend != "cpu": 83 device_put(X_) 84 device_put(self.X_) 85 device_put(self.coef_) 86 device_put(self.ym_) 87 return ( 88 compute_kernel_matrix(self.X_, self.kernel, X_) @ self.coef_ 89 + self.ym_ 90 )
Base class for all estimators in scikit-learn.
Inheriting from this class provides default implementations of:
- setting and getting parameters used by
GridSearchCVand friends; - textual and HTML representation displayed in terminals and IDEs;
- estimator serialization;
- parameters validation;
- data validation;
- feature names validation.
Read more in the :ref:User Guide <rolling_your_own_estimator>.
Notes
All estimators should specify all the parameters that can be set
at the class level in their __init__ as explicit keyword
arguments (no *args or **kwargs).
Examples
>>> import numpy as np
>>> from sklearn.base import BaseEstimator
>>> class MyEstimator(BaseEstimator):
... def __init__(self, *, param=1):
... self.param = param
... def fit(self, X, y=None):
... self.is_fitted_ = True
... return self
... def predict(self, X):
... return np.full(shape=X.shape[0], fill_value=self.param)
>>> estimator = MyEstimator(param=2)
>>> estimator.get_params()
{'param': 2}
>>> X = np.array([[1, 2], [2, 3], [3, 4]])
>>> y = np.array([1, 0, 1])
>>> estimator.fit(X, y).predict(X)
array([2, 2, 2])
>>> estimator.set_params(param=3).fit(X, y).predict(X)
array([3, 3, 3])
61 def fit(self, X, y): 62 self.ym_ = np.mean(y) 63 centered_y = y - self.ym_ 64 X_ = self.scaler_.fit_transform(X) 65 if self.backend == "cpu": 66 K = compute_kernel_matrix( 67 X_, self.kernel 68 ) + self.regularization * np.eye(X_.shape[0]) 69 self.coef_ = np.linalg.solve(K, centered_y) 70 else: 71 device_put(X_) 72 device_put(centered_y) 73 K = compute_kernel_matrix( 74 X_, self.kernel 75 ) + self.regularization * jnp.eye(X_.shape[0]) 76 self.coef_ = jnp.linalg.solve(K, centered_y) 77 self.X_ = X_ 78 return self
9class MultiTaskRegressor(BaseEstimator, RegressorMixin): 10 """ 11 A class for multi-task regression 12 13 Parameters 14 ---------- 15 regr: object 16 A regressor object 17 18 Attributes 19 ---------- 20 objs: list 21 A list containing the fitted regressor objects 22 23 """ 24 25 def __init__(self, regr): 26 assert ( 27 is_multitask_estimator(regr) == False 28 ), "The regressor is already a multi-task regressor" 29 self.regr = regr 30 self.objs = [] 31 32 def fit(self, X, y): 33 """ 34 Fit the regressor 35 36 Parameters 37 ---------- 38 X: array-like 39 The input data 40 y: array-like 41 The target values 42 43 """ 44 n_tasks = y.shape[1] 45 assert n_tasks > 1, "The number of columns in y must be greater than 1" 46 self.n_outputs_ = n_tasks 47 try: 48 for i in range(n_tasks): 49 self.regr.fit(X, y.iloc[:, i].values) 50 self.objs.append(deepcopy(self.regr)) 51 except Exception: 52 for i in range(n_tasks): 53 self.regr.fit(X, y[:, i]) 54 self.objs.append(deepcopy(self.regr)) 55 return self 56 57 def predict(self, X): 58 """ 59 Predict the target values 60 61 Parameters 62 ---------- 63 X: array-like 64 The input data 65 66 Returns 67 ------- 68 y_pred: array-like 69 The predicted target values 70 71 """ 72 assert len(self.objs) > 0, "The regressor has not been fitted yet" 73 y_pred = np.zeros((X.shape[0], self.n_outputs_)) 74 for i in range(self.n_outputs_): 75 y_pred[:, i] = self.objs[i].predict(X) 76 return y_pred
A class for multi-task regression
Parameters
regr: object A regressor object
Attributes
objs: list A list containing the fitted regressor objects
32 def fit(self, X, y): 33 """ 34 Fit the regressor 35 36 Parameters 37 ---------- 38 X: array-like 39 The input data 40 y: array-like 41 The target values 42 43 """ 44 n_tasks = y.shape[1] 45 assert n_tasks > 1, "The number of columns in y must be greater than 1" 46 self.n_outputs_ = n_tasks 47 try: 48 for i in range(n_tasks): 49 self.regr.fit(X, y.iloc[:, i].values) 50 self.objs.append(deepcopy(self.regr)) 51 except Exception: 52 for i in range(n_tasks): 53 self.regr.fit(X, y[:, i]) 54 self.objs.append(deepcopy(self.regr)) 55 return self
Fit the regressor
Parameters
X: array-like The input data y: array-like The target values
57 def predict(self, X): 58 """ 59 Predict the target values 60 61 Parameters 62 ---------- 63 X: array-like 64 The input data 65 66 Returns 67 ------- 68 y_pred: array-like 69 The predicted target values 70 71 """ 72 assert len(self.objs) > 0, "The regressor has not been fitted yet" 73 y_pred = np.zeros((X.shape[0], self.n_outputs_)) 74 for i in range(self.n_outputs_): 75 y_pred[:, i] = self.objs[i].predict(X) 76 return y_pred
Predict the target values
Parameters
X: array-like The input data
Returns
y_pred: array-like The predicted target values
8class IsotonicRegressor(BaseEstimator, RegressorMixin): 9 """Isotonic Regressor with postprocessing. 10 11 This class takes a base regressor and applies isotonic regression as 12 postprocessing in the predict method. The isotonic regression ensures 13 that the predictions are monotonically increasing or decreasing. 14 15 Attributes: 16 regr: estimator 17 Base regressor to use for initial predictions. 18 19 increasing: bool, default=True 20 If True, the isotonic regression will be monotonically increasing. 21 If False, it will be monotonically decreasing. 22 23 out_of_bounds: str, default='nan' 24 The out_of_bounds parameter for IsotonicRegression. 25 Can be 'nan', 'clip', or 'raise'. 26 """ 27 28 def __init__(self, regr, increasing=True, out_of_bounds="nan"): 29 """Initialize the IsotonicRegressor. 30 31 Args: 32 regr: estimator 33 Base regressor to use for initial predictions. 34 35 increasing: bool, default=True 36 If True, the isotonic regression will be monotonically increasing. 37 If False, it will be monotonically decreasing. 38 39 out_of_bounds: str, default='nan' 40 The out_of_bounds parameter for IsotonicRegression. 41 Can be 'nan', 'clip', or 'raise'. 42 """ 43 self.regr = regr 44 self.increasing = increasing 45 self.out_of_bounds = out_of_bounds 46 47 def fit(self, X, y, **kwargs): 48 """Fit the model. 49 50 Args: 51 X: {array-like}, shape = [n_samples, n_features] 52 Training vectors, where n_samples is the number 53 of samples and n_features is the number of features. 54 55 y: array-like, shape = [n_samples] 56 Target values. 57 58 **kwargs: additional parameters to be passed to the base regressor. 59 60 Returns: 61 self: object. 62 """ 63 # Validate input 64 X, y = check_X_y(X, y) 65 # Fit the base regressor 66 self.regr.fit(X, y, **kwargs) 67 # Get predictions from base regressor for training data 68 y_pred_base = self.regr.predict(X) 69 # Fit isotonic regression on the base predictions vs actual targets 70 self.isotonic_regressor_ = IsotonicRegression( 71 increasing=self.increasing, out_of_bounds=self.out_of_bounds 72 ) 73 self.isotonic_regressor_.fit(y_pred_base, y) 74 return self 75 76 def predict(self, X, **kwargs): 77 """Predict using the model. 78 79 Args: 80 X: {array-like}, shape = [n_samples, n_features] 81 Samples. 82 83 **kwargs: additional parameters to be passed to the base regressor. 84 85 Returns: 86 y_pred: array-like, shape = [n_samples] 87 Predicted values. 88 """ 89 # Check if fitted 90 check_is_fitted(self, ["regr", "isotonic_regressor_"]) 91 # Validate input 92 X = check_array(X) 93 # Get predictions from base regressor 94 y_pred_base = self.regr.predict(X, **kwargs) 95 # Apply isotonic regression postprocessing 96 return self.isotonic_regressor_.predict(y_pred_base)
Isotonic Regressor with postprocessing.
This class takes a base regressor and applies isotonic regression as postprocessing in the predict method. The isotonic regression ensures that the predictions are monotonically increasing or decreasing.
Attributes: regr: estimator Base regressor to use for initial predictions.
increasing: bool, default=True
If True, the isotonic regression will be monotonically increasing.
If False, it will be monotonically decreasing.
out_of_bounds: str, default='nan'
The out_of_bounds parameter for IsotonicRegression.
Can be 'nan', 'clip', or 'raise'.
47 def fit(self, X, y, **kwargs): 48 """Fit the model. 49 50 Args: 51 X: {array-like}, shape = [n_samples, n_features] 52 Training vectors, where n_samples is the number 53 of samples and n_features is the number of features. 54 55 y: array-like, shape = [n_samples] 56 Target values. 57 58 **kwargs: additional parameters to be passed to the base regressor. 59 60 Returns: 61 self: object. 62 """ 63 # Validate input 64 X, y = check_X_y(X, y) 65 # Fit the base regressor 66 self.regr.fit(X, y, **kwargs) 67 # Get predictions from base regressor for training data 68 y_pred_base = self.regr.predict(X) 69 # Fit isotonic regression on the base predictions vs actual targets 70 self.isotonic_regressor_ = IsotonicRegression( 71 increasing=self.increasing, out_of_bounds=self.out_of_bounds 72 ) 73 self.isotonic_regressor_.fit(y_pred_base, y) 74 return self
Fit the model.
Args: X: {array-like}, shape = [n_samples, n_features] Training vectors, where n_samples is the number of samples and n_features is the number of features.
y: array-like, shape = [n_samples]
Target values.
**kwargs: additional parameters to be passed to the base regressor.
Returns: self: object.
76 def predict(self, X, **kwargs): 77 """Predict using the model. 78 79 Args: 80 X: {array-like}, shape = [n_samples, n_features] 81 Samples. 82 83 **kwargs: additional parameters to be passed to the base regressor. 84 85 Returns: 86 y_pred: array-like, shape = [n_samples] 87 Predicted values. 88 """ 89 # Check if fitted 90 check_is_fitted(self, ["regr", "isotonic_regressor_"]) 91 # Validate input 92 X = check_array(X) 93 # Get predictions from base regressor 94 y_pred_base = self.regr.predict(X, **kwargs) 95 # Apply isotonic regression postprocessing 96 return self.isotonic_regressor_.predict(y_pred_base)
Predict using the model.
Args: X: {array-like}, shape = [n_samples, n_features] Samples.
**kwargs: additional parameters to be passed to the base regressor.
Returns: y_pred: array-like, shape = [n_samples] Predicted values.
25class GenericFunctionalForecaster(BaseEstimator, RegressorMixin): 26 """ 27 Functional time series forecaster using dimensionality reduction and regression. 28 29 Following Hyndman-Ullah methodology: 30 1. Extract functional components using dimensionality reduction 31 2. Model relationships between components and functional data using regression 32 3. Forecast future functional curves 33 34 Parameters 35 ---------- 36 n_components : int, default=8 37 Number of components to extract. 38 reduction_method : str, default='pca' 39 Dimensionality reduction method. 40 reduction_params : dict, optional 41 Additional parameters for the reduction method. 42 rolling_window : int, optional 43 Window size for rolling regression. If None, uses full training set. 44 forecast_method : {'ar', 'last_value'}, default='ar' 45 Method for forecasting coefficients. 46 regressor : sklearn regressor, optional 47 Any sklearn regressor. If None, uses LinearRegression. 48 regressor_params : dict, optional 49 Additional parameters for the regressor. 50 """ 51 52 def __init__( 53 self, 54 n_components: int = 8, 55 reduction_method: str = "pca", 56 reduction_params: Optional[dict] = None, 57 rolling_window: Optional[int] = None, 58 forecast_method: Literal["ar", "last_value"] = "ar", 59 regressor: Optional[BaseEstimator] = None, 60 regressor_params: Optional[dict] = None, 61 ): 62 self.n_components = n_components 63 self.reduction_method = reduction_method 64 self.reduction_params = reduction_params or {} 65 self.rolling_window = rolling_window 66 self.forecast_method = forecast_method 67 self.regressor = ( 68 regressor if regressor is not None else LinearRegression() 69 ) 70 self.regressor_params = regressor_params or {} 71 72 # Available reduction methods 73 self._reduction_methods = { 74 "pca": PCA, 75 "kernel_pca": KernelPCA, 76 "truncated_svd": TruncatedSVD, 77 "factor_analysis": FactorAnalysis, 78 "fast_ica": FastICA, 79 "nmf": NMF, 80 "minibatch_sparse_pca": MiniBatchSparsePCA, 81 "mds": MDS, 82 "isomap": Isomap, 83 "lle": LocallyLinearEmbedding, 84 } 85 86 if reduction_method not in self._reduction_methods: 87 raise ValueError( 88 f"reduction_method must be one of {list(self._reduction_methods.keys())}" 89 ) 90 91 def _create_regressor(self): 92 """Create a fresh regressor instance with parameters.""" 93 if hasattr(self.regressor, "__class__"): 94 # Create new instance from class 95 regressor = self.regressor.__class__(**self.regressor_params) 96 else: 97 # Clone existing instance 98 from sklearn.base import clone 99 100 regressor = clone(self.regressor) 101 # Apply additional parameters 102 for param, value in self.regressor_params.items(): 103 setattr(regressor, param, value) 104 105 return regressor 106 107 def fit( 108 self, X: Union[np.ndarray, pd.DataFrame] 109 ) -> "GenericFunctionalForecaster": 110 """ 111 Fit the functional forecaster. 112 113 Parameters 114 ---------- 115 X : np.ndarray or pd.DataFrame, shape (n_samples, n_points) 116 Functional time series data. 117 118 Returns 119 ------- 120 self : object 121 Fitted forecaster. 122 """ 123 # Input validation and conversion 124 if isinstance(X, pd.DataFrame): 125 X = X.values 126 X = check_array(X) 127 128 self.X_ = X.copy() 129 self.n_samples_, self.n_points_ = X.shape 130 131 # 1. Standardize the functional data 132 self.scaler_ = StandardScaler() 133 X_scaled = self.scaler_.fit_transform(X) 134 135 # 2. Fit dimensionality reduction 136 self._fit_reduction_method(X_scaled) 137 138 # 3. Extract components (reduced features) 139 self.reduced_features_ = self.reducer_.transform(X_scaled) 140 141 # 4. Fit regression models 142 if self.rolling_window is not None: 143 self._fit_rolling_regression(X_scaled) 144 else: 145 self._fit_full_regression(X_scaled) 146 147 self.is_fitted_ = True 148 return self 149 150 def _fit_reduction_method(self, X_scaled): 151 """Fit the dimensionality reduction method.""" 152 reduction_class = self._reduction_methods[self.reduction_method] 153 154 # Handle method-specific parameters 155 if self.reduction_method == "kernel_pca": 156 if "kernel" not in self.reduction_params: 157 self.reduction_params["kernel"] = "rbf" 158 if "fit_inverse_transform" not in self.reduction_params: 159 self.reduction_params["fit_inverse_transform"] = True 160 elif self.reduction_method == "minibatch_sparse_pca": 161 if "alpha" not in self.reduction_params: 162 self.reduction_params["alpha"] = 1.0 163 if "batch_size" not in self.reduction_params: 164 self.reduction_params["batch_size"] = min(3, self.n_samples_) 165 166 # Initialize and fit the reducer 167 self.reducer_ = reduction_class( 168 n_components=self.n_components, **self.reduction_params 169 ) 170 self.reducer_.fit(X_scaled) 171 172 # Store components/basis functions for reconstruction 173 if hasattr(self.reducer_, "components_"): 174 self.components_ = ( 175 self.reducer_.components_ 176 ) # Shape: (n_components, n_points) 177 elif hasattr(self.reducer_, "inverse_transform"): 178 # For methods like KernelPCA, create identity mapping to get components 179 try: 180 identity_matrix = np.eye(self.n_components) 181 reconstructed = self.reducer_.inverse_transform(identity_matrix) 182 if reconstructed.shape == (self.n_components, self.n_points_): 183 self.components_ = reconstructed 184 else: 185 self.components_ = reconstructed.T 186 except Exception as e: 187 warnings.warn( 188 f"Could not extract components for {self.reduction_method}: {e}" 189 ) 190 self.components_ = None 191 else: 192 warnings.warn( 193 f"No reconstruction available for {self.reduction_method}" 194 ) 195 self.components_ = None 196 197 def _fit_rolling_regression(self, X_scaled): 198 """ 199 Fit rolling regression models. 200 201 For each window, fit: reduced_features[window] -> next_scaled_curve 202 This maintains scale consistency throughout. 203 """ 204 if self.n_samples_ <= self.rolling_window: 205 raise ValueError( 206 f"Need more than {self.rolling_window} samples for rolling window, " 207 f"got {self.n_samples_}" 208 ) 209 210 self.rolling_models_ = [] 211 self.rolling_coefs_ = [] 212 213 n_windows = self.n_samples_ - self.rolling_window 214 215 for i in range(n_windows): 216 # Input: window of reduced features 217 X_window = self.reduced_features_[ 218 i : i + self.rolling_window 219 ] # (window, n_components) 220 221 # Target: next scaled functional curve 222 y_next_scaled = X_scaled[i : i + self.rolling_window] # (n_points,) 223 224 # Create and fit regressor 225 regressor = self._create_regressor() 226 227 try: 228 # Fit regression: reduced_features_window -> scaled_functional_curve 229 regressor.fit(X_window, y_next_scaled) 230 231 # Store model and coefficients 232 self.rolling_models_.append(regressor) 233 234 # Extract coefficients - shape depends on regressor type 235 if hasattr(regressor, "coef_"): 236 coef = regressor.coef_ 237 # For multioutput: coef shape is (n_outputs, n_features) = (n_points, n_components) 238 # For single output with multiple features: (n_features,) = (n_components,) 239 # We expect multioutput here since y_next_scaled is (n_points,) 240 if coef.ndim == 1: 241 # This shouldn't happen with multioutput, but handle gracefully 242 warnings.warn( 243 f"Unexpected single output coefficients at window {i}" 244 ) 245 coef = coef.reshape(1, -1) # (1, n_components) 246 self.rolling_coefs_.append(coef) # (n_points, n_components) 247 else: 248 # Fallback: use least squares 249 warnings.warn( 250 f"Regressor has no coef_ attribute, using least squares at window {i}" 251 ) 252 coef = np.linalg.lstsq(X_window, y_next_scaled, rcond=None)[ 253 0 254 ].T 255 if coef.ndim == 1: 256 coef = coef.reshape(1, -1) 257 self.rolling_coefs_.append(coef) 258 259 except Exception as e: 260 warnings.warn( 261 f"Regression failed at window {i}: {e}. Using least squares fallback." 262 ) 263 # Least squares fallback 264 coef = np.linalg.lstsq(X_window, y_next_scaled, rcond=None)[ 265 0 266 ].T # (n_points, n_components) 267 if coef.ndim == 1: 268 coef = coef.reshape(1, -1) 269 self.rolling_coefs_.append(coef) 270 self.rolling_models_.append(None) 271 272 # Convert to array for easier manipulation 273 # Shape: (n_windows, n_points, n_components) 274 self.rolling_coefs_ = np.array(self.rolling_coefs_) 275 276 def _fit_full_regression(self, X_scaled): 277 """ 278 Fit regression using full training set. 279 280 Fit: reduced_features -> scaled_functional_data 281 """ 282 # Create regressor 283 regressor = self._create_regressor() 284 285 try: 286 # Fit: all reduced features -> all scaled functional curves 287 regressor.fit(self.reduced_features_, X_scaled) 288 self.full_model_ = regressor 289 290 # Store coefficients 291 if hasattr(regressor, "coef_"): 292 self.coefs_ = ( 293 regressor.coef_ 294 ) # (n_points, n_components) for multioutput 295 else: 296 # Fallback to least squares 297 warnings.warn( 298 "Regressor has no coef_ attribute, using least squares" 299 ) 300 self.coefs_ = np.linalg.lstsq( 301 self.reduced_features_, X_scaled, rcond=None 302 )[0].T 303 304 except Exception as e: 305 warnings.warn( 306 f"Full regression failed: {e}. Using least squares fallback." 307 ) 308 # Least squares fallback 309 self.coefs_ = np.linalg.lstsq( 310 self.reduced_features_, X_scaled, rcond=None 311 )[0].T 312 self.full_model_ = None 313 314 def forecast(self, steps: int = 5) -> np.ndarray: 315 """ 316 Forecast functional time series. 317 318 Parameters 319 ---------- 320 steps : int 321 Number of steps to forecast. 322 323 Returns 324 ------- 325 np.ndarray, shape (steps, n_points) 326 Forecasted functional curves. 327 """ 328 check_is_fitted(self, "is_fitted_") 329 330 if self.rolling_window is not None: 331 return self._forecast_rolling(steps) 332 else: 333 return self._forecast_full(steps) 334 335 def _forecast_rolling(self, steps: int) -> np.ndarray: 336 """Forecast using rolling regression approach.""" 337 # rolling_coefs_ shape: (n_windows, n_points, n_components) 338 n_windows, n_points, n_components = self.rolling_coefs_.shape 339 # Forecast coefficients for each point and component 340 forecasted_coefs = np.zeros((steps, n_points, n_components)) 341 342 for point_idx in range(n_points): 343 for comp_idx in range(n_components): 344 # Get time series of coefficients for this (point, component) 345 coef_series = self.rolling_coefs_[:, point_idx, comp_idx] 346 # Forecast this coefficient series 347 if self.forecast_method == "ar" and len(coef_series) > 1: 348 try: 349 # Fit AR model to coefficient series 350 ar_model = AutoReg( 351 coef_series, lags=min(2, len(coef_series) - 1) 352 ).fit() 353 forecasted_values = ar_model.predict( 354 start=len(coef_series), 355 end=len(coef_series) + steps - 1, 356 ) 357 forecasted_coefs[:, point_idx, comp_idx] = ( 358 forecasted_values 359 ) 360 except Exception as e: 361 warnings.warn( 362 f"AR forecasting failed for point {point_idx}, component {comp_idx}: {e}" 363 ) 364 # Use last value 365 forecasted_coefs[:, point_idx, comp_idx] = coef_series[ 366 -1 367 ] 368 else: 369 # Use last value 370 forecasted_coefs[:, point_idx, comp_idx] = coef_series[-1] 371 372 # Reconstruct functional forecasts from predicted coefficients 373 forecasts_scaled = np.zeros((steps, n_points)) 374 375 if self.components_ is not None: 376 # Use learned components for reconstruction 377 # For each forecast step and each point, sum over components 378 for step in range(steps): 379 for point_idx in range(n_points): 380 # forecasted_coefs[step, point_idx, :] has shape (n_components,) 381 # self.components_[:, point_idx] has shape (n_components,) 382 forecasts_scaled[step, point_idx] = np.dot( 383 forecasted_coefs[step, point_idx, :], 384 self.components_[:, point_idx], 385 ) 386 else: 387 # No reconstruction available - use direct prediction 388 warnings.warn( 389 f"No reconstruction available for {self.reduction_method}. Using last known values." 390 ) 391 last_scaled = self.scaler_.transform(self.X_[-1:]) 392 forecasts_scaled = np.tile(last_scaled, (steps, 1)) 393 # Transform back to original scale 394 return self.scaler_.inverse_transform(forecasts_scaled) 395 396 def _forecast_full(self, steps: int) -> np.ndarray: 397 """Forecast using full training set approach.""" 398 # First, forecast the reduced features themselves 399 forecasted_features = np.zeros((steps, self.n_components)) 400 401 for comp in range(self.n_components): 402 # Get time series of this component 403 feature_series = self.reduced_features_[:, comp] 404 405 if self.forecast_method == "ar" and len(feature_series) > 1: 406 try: 407 # Fit AR model to feature series 408 ar_model = AutoReg( 409 feature_series, lags=min(2, len(feature_series) - 1) 410 ).fit() 411 forecasted_values = ar_model.predict( 412 start=len(feature_series), 413 end=len(feature_series) + steps - 1, 414 ) 415 forecasted_features[:, comp] = forecasted_values 416 except Exception as e: 417 warnings.warn( 418 f"AR forecasting failed for component {comp}: {e}" 419 ) 420 # Use last value 421 forecasted_features[:, comp] = feature_series[-1] 422 else: 423 # Use last value 424 forecasted_features[:, comp] = feature_series[-1] 425 426 # Reconstruct functional data from forecasted features 427 if hasattr(self, "full_model_") and self.full_model_ is not None: 428 # Use the fitted model to predict 429 try: 430 forecasts_scaled = self.full_model_.predict(forecasted_features) 431 except: 432 # Fallback to coefficient multiplication 433 forecasts_scaled = forecasted_features @ self.coefs_.T 434 else: 435 # Use stored coefficients 436 forecasts_scaled = forecasted_features @ self.coefs_.T 437 438 # Transform back to original scale 439 forecasts = self.scaler_.inverse_transform(forecasts_scaled) 440 return forecasts 441 442 def plot_components(self, n_plot: int = 3) -> None: 443 """Plot functional components.""" 444 check_is_fitted(self, "is_fitted_") 445 446 if self.components_ is None: 447 print(f"Components not available for {self.reduction_method}") 448 return 449 450 plt.figure(figsize=(12, 6)) 451 for i in range(min(n_plot, self.n_components)): 452 plt.plot(self.components_[i], label=f"Component {i+1}", linewidth=2) 453 454 plt.title(f"{self.reduction_method.upper()} Components") 455 plt.xlabel("Domain Point") 456 plt.ylabel("Component Value") 457 plt.legend() 458 plt.grid(True, alpha=0.3) 459 plt.show() 460 461 def plot_reduced_features(self, n_plot: int = 4) -> None: 462 """Plot reduced features over time.""" 463 check_is_fitted(self, "is_fitted_") 464 465 plt.figure(figsize=(12, 8)) 466 n_subplot_cols = 2 467 n_subplot_rows = (min(n_plot, self.n_components) + 1) // 2 468 469 for i in range(min(n_plot, self.n_components)): 470 plt.subplot(n_subplot_rows, n_subplot_cols, i + 1) 471 plt.plot( 472 self.reduced_features_[:, i], "o-", linewidth=2, markersize=4 473 ) 474 plt.title(f"Reduced Feature {i+1}") 475 plt.xlabel("Time") 476 plt.ylabel("Value") 477 plt.grid(True, alpha=0.3) 478 479 plt.tight_layout() 480 plt.show() 481 482 def plot_forecast( 483 self, actual: Optional[np.ndarray] = None, steps: int = 5 484 ) -> None: 485 """Plot forecasted curves.""" 486 forecasts = self.forecast(steps=steps) 487 488 plt.figure(figsize=(12, 6)) 489 490 # Plot some historical curves 491 n_history = min(5, len(self.X_)) 492 for i in range(n_history): 493 idx = -(n_history - i) 494 plt.plot( 495 self.X_[idx], 496 "b-", 497 alpha=0.3, 498 linewidth=1, 499 label="Historical" if i == 0 else "", 500 ) 501 502 # Plot actual test data if provided 503 if actual is not None: 504 for i in range(min(3, len(actual))): 505 plt.plot( 506 actual[i], 507 "k-", 508 alpha=0.7, 509 linewidth=2, 510 label="Actual" if i == 0 else "", 511 ) 512 513 # Plot forecasts 514 for i in range(steps): 515 plt.plot( 516 forecasts[i], 517 "r--", 518 linewidth=2, 519 alpha=0.7, 520 label="Forecast" if i == 0 else "", 521 ) 522 523 plt.title("Functional Time Series Forecast") 524 plt.xlabel("Domain Point") 525 plt.ylabel("Value") 526 plt.legend() 527 plt.grid(True, alpha=0.3) 528 plt.show() 529 530 def get_model_info(self) -> dict: 531 """Get information about the fitted model.""" 532 info = { 533 "n_components": self.n_components, 534 "reduction_method": self.reduction_method, 535 "rolling_window": self.rolling_window, 536 "forecast_method": self.forecast_method, 537 "regressor": self.regressor.__class__.__name__, 538 "regressor_params": self.regressor_params, 539 "is_fitted": getattr(self, "is_fitted_", False), 540 } 541 542 if hasattr(self, "reduced_features_"): 543 info.update( 544 { 545 "n_samples": self.n_samples_, 546 "n_points": self.n_points_, 547 "explained_variance_ratio": getattr( 548 self.reducer_, "explained_variance_ratio_", None 549 ), 550 "has_components": self.components_ is not None, 551 "coefficient_shape": ( 552 getattr(self, "rolling_coefs_", np.array([])).shape 553 if hasattr(self, "rolling_coefs_") 554 else getattr(self, "coefs_", np.array([])).shape 555 ), 556 } 557 ) 558 559 return info
Functional time series forecaster using dimensionality reduction and regression.
Following Hyndman-Ullah methodology:
- Extract functional components using dimensionality reduction
- Model relationships between components and functional data using regression
- Forecast future functional curves
Parameters
n_components : int, default=8 Number of components to extract. reduction_method : str, default='pca' Dimensionality reduction method. reduction_params : dict, optional Additional parameters for the reduction method. rolling_window : int, optional Window size for rolling regression. If None, uses full training set. forecast_method : {'ar', 'last_value'}, default='ar' Method for forecasting coefficients. regressor : sklearn regressor, optional Any sklearn regressor. If None, uses LinearRegression. regressor_params : dict, optional Additional parameters for the regressor.
107 def fit( 108 self, X: Union[np.ndarray, pd.DataFrame] 109 ) -> "GenericFunctionalForecaster": 110 """ 111 Fit the functional forecaster. 112 113 Parameters 114 ---------- 115 X : np.ndarray or pd.DataFrame, shape (n_samples, n_points) 116 Functional time series data. 117 118 Returns 119 ------- 120 self : object 121 Fitted forecaster. 122 """ 123 # Input validation and conversion 124 if isinstance(X, pd.DataFrame): 125 X = X.values 126 X = check_array(X) 127 128 self.X_ = X.copy() 129 self.n_samples_, self.n_points_ = X.shape 130 131 # 1. Standardize the functional data 132 self.scaler_ = StandardScaler() 133 X_scaled = self.scaler_.fit_transform(X) 134 135 # 2. Fit dimensionality reduction 136 self._fit_reduction_method(X_scaled) 137 138 # 3. Extract components (reduced features) 139 self.reduced_features_ = self.reducer_.transform(X_scaled) 140 141 # 4. Fit regression models 142 if self.rolling_window is not None: 143 self._fit_rolling_regression(X_scaled) 144 else: 145 self._fit_full_regression(X_scaled) 146 147 self.is_fitted_ = True 148 return self
Fit the functional forecaster.
Parameters
X : np.ndarray or pd.DataFrame, shape (n_samples, n_points) Functional time series data.
Returns
self : object Fitted forecaster.
18class RankTargetEncoder(BaseEstimator, TransformerMixin): 19 """ 20 Rank-based target encoder using Spearman rho or Kendall tau via 21 Gaussian copula with proper cross-validation. 22 23 This encoder uses cross-validation and pseudo-targets generated via 24 Gaussian copula with specified rank correlation to create robust, 25 regularized encodings that prevent overfitting. 26 27 Parameters: 28 ----------- 29 correlation_type : str, default='spearman' 30 Type of rank correlation ('spearman' or 'kendall'). 31 correlation_strength : float, default=0.5 32 Desired strength of rank correlation (between 0 and 1). 33 shrinkage : float, default=10 34 Shrinkage parameter for regularization (Bayesian average). 35 n_folds : int, default=3 36 Number of CV folds for leakage-free encoding. 37 ensemble_size : int, default=5 38 Number of pseudo-targets to average over (reduces variance). 39 aggregate : str, default='mean' 40 Aggregation method for combining values within categories ('mean' or 'median'). 41 random_state : int, default=42 42 Random seed for reproducibility. 43 """ 44 45 def __init__( 46 self, 47 correlation_type="spearman", 48 correlation_strength=0.5, 49 shrinkage=10, 50 n_folds=3, 51 ensemble_size=5, 52 aggregate="mean", 53 random_state=42, 54 ): 55 self.correlation_type = correlation_type 56 self.correlation_strength = correlation_strength 57 self.shrinkage = shrinkage 58 self.n_folds = n_folds 59 self.ensemble_size = ensemble_size 60 self.aggregate = aggregate 61 self.random_state = random_state 62 self.cat_columns_ = [] 63 64 # Validate inputs 65 if correlation_type not in ["spearman", "kendall"]: 66 raise ValueError("correlation_type must be 'spearman' or 'kendall'") 67 if not (0 <= correlation_strength <= 1): 68 raise ValueError("correlation_strength must be in [0, 1]") 69 if shrinkage < 0: 70 raise ValueError("shrinkage must be non-negative") 71 if n_folds < 2: 72 raise ValueError("n_folds must be at least 2") 73 if ensemble_size < 1: 74 raise ValueError("ensemble_size must be at least 1") 75 if aggregate not in ["mean", "median"]: 76 raise ValueError("aggregate must be 'mean' or 'median'") 77 78 def _generate_pseudo_target(self, y, random_state): 79 """Generate pseudo-target with specified rank correlation to y.""" 80 y = np.asarray(y) 81 n = len(y) 82 if n <= 1: 83 return y.copy() 84 85 # Convert to uniform margins via ranks 86 ranks = rankdata(y, method="average") 87 u_y = ranks / (n + 1) 88 89 # Transform to Gaussian 90 g_y = norm.ppf(u_y) 91 92 # Convert rank correlation to Gaussian correlation 93 if self.correlation_type == "spearman": 94 rho_g = 2 * np.sin(np.pi * self.correlation_strength / 6) 95 else: # kendall 96 rho_g = np.sin(np.pi * self.correlation_strength / 2) 97 rho_g = np.clip(rho_g, -1.0, 1.0) 98 99 # Generate correlated Gaussian variable 100 rng = np.random.RandomState(random_state) 101 eta = rng.normal(size=n) 102 g_z = rho_g * g_y + np.sqrt(1 - rho_g**2) * eta 103 104 # Transform back to original scale via quantiles 105 u_z = norm.cdf(g_z) 106 y_sorted = np.sort(y) 107 z = np.quantile(y_sorted, u_z, method="linear") 108 109 return z 110 111 def _compute_category_statistics(self, categories, values): 112 """Compute category-wise statistics with proper handling.""" 113 if len(categories) == 0: 114 return {} 115 116 df = pd.DataFrame({"cat": categories, "val": values}) 117 118 if self.aggregate == "mean": 119 cat_stats = df.groupby("cat")["val"].agg(["mean", "count"]) 120 return dict( 121 zip(cat_stats.index, zip(cat_stats["mean"], cat_stats["count"])) 122 ) 123 else: # median 124 cat_stats = df.groupby("cat")["val"].agg(["median", "count"]) 125 return dict( 126 zip( 127 cat_stats.index, 128 zip(cat_stats["median"], cat_stats["count"]), 129 ) 130 ) 131 132 def _apply_shrinkage(self, category_stats, global_stat): 133 """Apply shrinkage regularization to category statistics.""" 134 regularized = {} 135 for cat, (stat, count) in category_stats.items(): 136 regularized[cat] = (count * stat + self.shrinkage * global_stat) / ( 137 count + self.shrinkage 138 ) 139 return regularized 140 141 def _identify_categorical_columns(self, X): 142 """Identify categorical columns in the DataFrame.""" 143 cat_cols = [] 144 for col in X.columns: 145 # Check if column is object type or has low cardinality 146 if ( 147 X[col].dtype == "object" 148 or X[col].dtype.name == "category" 149 or X[col].nunique() / len(X) < 0.05 150 ): # heuristic for categorical 151 cat_cols.append(col) 152 return cat_cols 153 154 def fit(self, X, y): 155 """Fit the encoder using cross-validation to prevent leakage.""" 156 if not isinstance(X, pd.DataFrame): 157 raise ValueError("X must be a pandas DataFrame") 158 159 X = X.reset_index(drop=True) # Ensure clean integer indices 160 y = np.asarray(y) 161 162 if len(X) != len(y): 163 raise ValueError("X and y must have the same number of samples") 164 165 if len(X) == 0: 166 raise ValueError("X cannot be empty") 167 168 self.feature_names_in_ = list(X.columns) 169 self.y_mean_ = np.mean(y) if len(y) > 0 else 0.0 170 self.category_mappings_ = {} 171 172 # Identify categorical columns 173 self.cat_columns_ = self._identify_categorical_columns(X) 174 self.non_cat_columns_ = [ 175 col for col in X.columns if col not in self.cat_columns_ 176 ] 177 178 # Set up cross-validation 179 kf = KFold( 180 n_splits=self.n_folds, shuffle=True, random_state=self.random_state 181 ) 182 183 for col in self.cat_columns_: 184 if X[col].nunique() <= 1: 185 # Handle constant columns 186 self.category_mappings_[col] = {X[col].iloc[0]: self.y_mean_} 187 continue 188 189 # Collect encodings for each category across all CV folds and ensemble members 190 category_encodings = defaultdict(list) 191 192 for ensemble_idx in range(self.ensemble_size): 193 ensemble_seed = self.random_state + ensemble_idx 194 fold_encodings = np.full(len(y), np.nan) 195 196 for fold_idx, (train_idx, val_idx) in enumerate(kf.split(X)): 197 # Training data for this fold 198 X_train_fold = X.iloc[train_idx] 199 y_train_fold = y[train_idx] 200 X_val_fold = X.iloc[val_idx] 201 202 if len(y_train_fold) == 0: 203 continue 204 205 # Generate pseudo-target for this fold and ensemble member 206 # Use deterministic seed based on ensemble_idx, fold_idx, and column 207 fold_seed = ( 208 ensemble_seed + fold_idx * 1000 + hash(col) % 10000 209 ) 210 z_train = self._generate_pseudo_target( 211 y_train_fold, fold_seed 212 ) 213 214 # Compute category statistics 215 cat_stats = self._compute_category_statistics( 216 X_train_fold[col].values, z_train 217 ) 218 219 if not cat_stats: 220 continue 221 222 # Apply shrinkage regularization 223 if self.aggregate == "mean": 224 global_stat = np.mean(z_train) 225 else: # median 226 global_stat = np.median(z_train) 227 228 regularized_stats = self._apply_shrinkage( 229 cat_stats, global_stat 230 ) 231 232 # Encode validation fold 233 for idx in val_idx: 234 category = X_val_fold.loc[idx, col] 235 if category in regularized_stats: 236 fold_encodings[idx] = regularized_stats[category] 237 else: 238 fold_encodings[idx] = global_stat 239 240 # Collect encodings by category for this ensemble member 241 for idx, encoding in enumerate(fold_encodings): 242 if not np.isnan(encoding): 243 category = X.iloc[idx][col] 244 category_encodings[category].append(encoding) 245 246 # Average encodings for each category across all ensemble members and folds 247 final_mappings = {} 248 for category, encodings in category_encodings.items(): 249 if encodings: 250 final_mappings[category] = np.mean(encodings) 251 else: 252 final_mappings[category] = self.y_mean_ 253 254 self.category_mappings_[col] = final_mappings 255 256 return self 257 258 def transform(self, X): 259 """Transform categorical columns using learned encodings.""" 260 if not hasattr(self, "category_mappings_"): 261 raise NotFittedError( 262 "This %s instance is not fitted yet." % self.__class__.__name__ 263 ) 264 265 if not isinstance(X, pd.DataFrame): 266 raise ValueError("X must be a pandas DataFrame") 267 268 # Check for missing columns 269 missing_cols = set(self.feature_names_in_) - set(X.columns) 270 if missing_cols: 271 raise ValueError(f"Missing columns from training: {missing_cols}") 272 273 X_encoded = X.copy() 274 275 for col in self.feature_names_in_: 276 if col not in X.columns: 277 # This shouldn't happen due to check above, but be safe 278 X_encoded[col] = self.y_mean_ 279 continue 280 281 # Only encode categorical columns, leave others unchanged 282 if col in self.cat_columns_: 283 mappings = self.category_mappings_[col] 284 X_encoded[col] = X[col].map(mappings).fillna(self.y_mean_) 285 # Non-categorical columns are left as-is 286 287 return X_encoded 288 289 def fit_transform(self, X, y, **fit_params): 290 """Fit encoder and return encoded version of X.""" 291 return self.fit(X, y).transform(X) 292 293 def get_feature_names_out(self, input_features=None): 294 """Get output feature names for transformation.""" 295 if not hasattr(self, "category_mappings_"): 296 raise NotFittedError( 297 "This %s instance is not fitted yet." % self.__class__.__name__ 298 ) 299 300 if input_features is None: 301 return np.array(self.feature_names_in_) 302 else: 303 return np.array(input_features) 304 305 def get_category_mappings(self): 306 """Get the learned category mappings for inspection.""" 307 if not hasattr(self, "category_mappings_"): 308 raise NotFittedError( 309 "This %s instance is not fitted yet." % self.__class__.__name__ 310 ) 311 312 return self.category_mappings_.copy() 313 314 def validate_encoding(self, X, y, plot=True): 315 """ 316 Comprehensive validation of the encoding process, including correlation 317 preservation, distribution analysis, and category-level statistics. 318 319 Parameters: 320 ----------- 321 X : pandas DataFrame 322 Input features (must be the same as used in fitting) 323 y : array-like 324 True target values 325 plot : bool, default=True 326 Whether to generate diagnostic plots 327 328 Returns: 329 -------- 330 dict 331 Dictionary containing validation metrics and statistics 332 """ 333 if not hasattr(self, "category_mappings_"): 334 raise NotFittedError( 335 "This %s instance is not fitted yet." % self.__class__.__name__ 336 ) 337 338 if not isinstance(X, pd.DataFrame): 339 raise ValueError("X must be a pandas DataFrame") 340 341 X = X.reset_index(drop=True) 342 y = np.asarray(y) 343 344 # Generate multiple pseudo-targets for robust statistics 345 pseudo_targets = [] 346 correlations_achieved = [] 347 348 for i in range(self.ensemble_size): 349 seed = self.random_state + i 350 z = self._generate_pseudo_target(y, seed) 351 pseudo_targets.append(z) 352 353 # Compute achieved correlation 354 if self.correlation_type == "spearman": 355 from scipy.stats import spearmanr 356 357 corr, _ = spearmanr(y, z) 358 else: # kendall 359 from scipy.stats import kendalltau 360 361 corr, _ = kendalltau(y, z) 362 correlations_achieved.append(corr) 363 364 pseudo_targets = np.array(pseudo_targets) 365 mean_pseudo_target = np.mean(pseudo_targets, axis=0) 366 367 # Transform the data 368 X_encoded = self.transform(X) 369 370 # Compute overall validation metrics 371 validation_results = { 372 "target_correlation": self.correlation_strength, 373 "achieved_correlations": correlations_achieved, 374 "mean_achieved_correlation": np.mean(correlations_achieved), 375 "std_achieved_correlation": np.std(correlations_achieved), 376 "correlation_bias": np.mean(correlations_achieved) 377 - self.correlation_strength, 378 "original_target_stats": { 379 "mean": np.mean(y), 380 "std": np.std(y), 381 "min": np.min(y), 382 "max": np.max(y), 383 "median": np.median(y), 384 }, 385 "pseudo_target_stats": { 386 "mean": np.mean(mean_pseudo_target), 387 "std": np.std(mean_pseudo_target), 388 "min": np.min(mean_pseudo_target), 389 "max": np.max(mean_pseudo_target), 390 "median": np.median(mean_pseudo_target), 391 }, 392 } 393 394 # Category-level analysis 395 category_correlations = {} 396 category_stats = {} 397 398 for col in self.cat_columns_: 399 if col not in X.columns: 400 continue 401 402 unique_categories = X[col].unique() 403 cat_corrs = [] 404 cat_means_original = [] 405 cat_means_pseudo = [] 406 cat_counts = [] 407 408 for category in unique_categories: 409 mask = X[col] == category 410 if ( 411 np.sum(mask) > 5 412 ): # Only analyze categories with sufficient samples 413 if self.correlation_type == "spearman": 414 corr, _ = spearmanr(y[mask], mean_pseudo_target[mask]) 415 else: 416 corr, _ = kendalltau(y[mask], mean_pseudo_target[mask]) 417 418 cat_corrs.append(corr) 419 cat_means_original.append(np.mean(y[mask])) 420 cat_means_pseudo.append(np.mean(mean_pseudo_target[mask])) 421 cat_counts.append(np.sum(mask)) 422 423 category_correlations[col] = { 424 "mean_correlation": np.mean(cat_corrs) if cat_corrs else np.nan, 425 "std_correlation": np.std(cat_corrs) if cat_corrs else np.nan, 426 "min_correlation": np.min(cat_corrs) if cat_corrs else np.nan, 427 "max_correlation": np.max(cat_corrs) if cat_corrs else np.nan, 428 } 429 430 category_stats[col] = { 431 "n_categories": len(unique_categories), 432 "n_analyzed_categories": len(cat_corrs), 433 "category_means_original": cat_means_original, 434 "category_means_pseudo": cat_means_pseudo, 435 "category_counts": cat_counts, 436 } 437 438 validation_results["category_correlations"] = category_correlations 439 validation_results["category_stats"] = category_stats 440 441 # Generate plots if requested 442 if plot: 443 try: 444 fig, axes = plt.subplots(2, 2, figsize=(15, 12)) 445 axes = axes.flatten() 446 447 # Scatter plot: Original vs Pseudo-targets 448 axes[0].scatter(y, mean_pseudo_target, alpha=0.6, s=20) 449 axes[0].set_xlabel("Original Target") 450 axes[0].set_ylabel("Pseudo Target") 451 axes[0].set_title( 452 f'Original vs Pseudo-targets\n{self.correlation_type.capitalize()} correlation: {validation_results["mean_achieved_correlation"]:.3f}' 453 ) 454 455 # Add correlation line 456 z = np.polyfit(y, mean_pseudo_target, 1) 457 p = np.poly1d(z) 458 axes[0].plot(y, p(y), "r--", alpha=0.8) 459 460 # Distribution comparison 461 axes[1].hist( 462 y, alpha=0.7, bins=30, label="Original", density=True 463 ) 464 axes[1].hist( 465 mean_pseudo_target, 466 alpha=0.7, 467 bins=30, 468 label="Pseudo", 469 density=True, 470 ) 471 axes[1].set_xlabel("Value") 472 axes[1].set_ylabel("Density") 473 axes[1].set_title("Distribution Comparison") 474 axes[1].legend() 475 476 # Rank comparison 477 original_ranks = rankdata(y, method="average") 478 pseudo_ranks = rankdata(mean_pseudo_target, method="average") 479 axes[2].scatter(original_ranks, pseudo_ranks, alpha=0.6, s=20) 480 axes[2].set_xlabel("Original Ranks") 481 axes[2].set_ylabel("Pseudo Ranks") 482 axes[2].set_title("Rank Preservation") 483 484 # Category analysis - residual plot 485 residuals = y - mean_pseudo_target 486 # Use first categorical column for coloring if available 487 if self.cat_columns_: 488 cat_col = self.cat_columns_[0] 489 unique_cats = X[cat_col].unique()[ 490 :10 491 ] # Limit to top 10 categories 492 colors = plt.cm.tab10(np.linspace(0, 1, len(unique_cats))) 493 494 for i, category in enumerate(unique_cats): 495 mask = X[cat_col] == category 496 if np.sum(mask) > 0: 497 axes[3].scatter( 498 mean_pseudo_target[mask], 499 residuals[mask], 500 alpha=0.6, 501 s=20, 502 color=colors[i], 503 label=str(category), 504 ) 505 506 axes[3].axhline(y=0, color="r", linestyle="--", alpha=0.8) 507 axes[3].set_xlabel("Pseudo Target") 508 axes[3].set_ylabel("Residuals (Original - Pseudo)") 509 axes[3].set_title("Residuals by Category") 510 axes[3].legend(bbox_to_anchor=(1.05, 1), loc="upper left") 511 else: 512 axes[3].scatter( 513 mean_pseudo_target, residuals, alpha=0.6, s=20 514 ) 515 axes[3].axhline(y=0, color="r", linestyle="--", alpha=0.8) 516 axes[3].set_xlabel("Pseudo Target") 517 axes[3].set_ylabel("Residuals (Original - Pseudo)") 518 axes[3].set_title("Residual Plot") 519 520 plt.tight_layout() 521 plt.show() 522 523 except ImportError: 524 print("Matplotlib/seaborn not available for plotting") 525 526 return validation_results 527 528 def get_validation_report(self, validation_results): 529 """ 530 Generate a human-readable validation report from validation results. 531 532 Parameters: 533 ----------- 534 validation_results : dict 535 Results from validate_encoding method 536 537 Returns: 538 -------- 539 str 540 Formatted validation report 541 """ 542 report = [] 543 report.append("=" * 60) 544 report.append("RANK TARGET ENCODER VALIDATION REPORT") 545 report.append("=" * 60) 546 547 report.append(f"\nCORRELATION VALIDATION:") 548 report.append( 549 f"Target {self.correlation_type} correlation: {validation_results['target_correlation']:.3f}" 550 ) 551 report.append( 552 f"Achieved mean correlation: {validation_results['mean_achieved_correlation']:.3f}" 553 ) 554 report.append( 555 f"Correlation bias: {validation_results['correlation_bias']:.3f}" 556 ) 557 report.append( 558 f"Correlation std across ensemble: {validation_results['std_achieved_correlation']:.3f}" 559 ) 560 561 report.append(f"\nDISTRIBUTION COMPARISON:") 562 orig = validation_results["original_target_stats"] 563 pseudo = validation_results["pseudo_target_stats"] 564 report.append( 565 f"Original target - Mean: {orig['mean']:.3f}, Std: {orig['std']:.3f}" 566 ) 567 report.append( 568 f"Pseudo target - Mean: {pseudo['mean']:.3f}, Std: {pseudo['std']:.3f}" 569 ) 570 571 report.append(f"\nCATEGORY-LEVEL ANALYSIS:") 572 for col, stats in validation_results["category_correlations"].items(): 573 if not np.isnan(stats["mean_correlation"]): 574 report.append( 575 f" {col}: {stats['mean_correlation']:.3f} ± {stats['std_correlation']:.3f} " 576 f"(min: {stats['min_correlation']:.3f}, max: {stats['max_correlation']:.3f})" 577 ) 578 579 report.append(f"\nROBUST STATISTICS:") 580 report.append(f"Ensemble size: {self.ensemble_size}") 581 report.append( 582 f"Individual correlations: {[f'{c:.3f}' for c in validation_results['achieved_correlations']]}" 583 ) 584 585 report.append("=" * 60) 586 return "\n".join(report)
Rank-based target encoder using Spearman rho or Kendall tau via Gaussian copula with proper cross-validation.
This encoder uses cross-validation and pseudo-targets generated via Gaussian copula with specified rank correlation to create robust, regularized encodings that prevent overfitting.
Parameters:
correlation_type : str, default='spearman' Type of rank correlation ('spearman' or 'kendall'). correlation_strength : float, default=0.5 Desired strength of rank correlation (between 0 and 1). shrinkage : float, default=10 Shrinkage parameter for regularization (Bayesian average). n_folds : int, default=3 Number of CV folds for leakage-free encoding. ensemble_size : int, default=5 Number of pseudo-targets to average over (reduces variance). aggregate : str, default='mean' Aggregation method for combining values within categories ('mean' or 'median'). random_state : int, default=42 Random seed for reproducibility.
154 def fit(self, X, y): 155 """Fit the encoder using cross-validation to prevent leakage.""" 156 if not isinstance(X, pd.DataFrame): 157 raise ValueError("X must be a pandas DataFrame") 158 159 X = X.reset_index(drop=True) # Ensure clean integer indices 160 y = np.asarray(y) 161 162 if len(X) != len(y): 163 raise ValueError("X and y must have the same number of samples") 164 165 if len(X) == 0: 166 raise ValueError("X cannot be empty") 167 168 self.feature_names_in_ = list(X.columns) 169 self.y_mean_ = np.mean(y) if len(y) > 0 else 0.0 170 self.category_mappings_ = {} 171 172 # Identify categorical columns 173 self.cat_columns_ = self._identify_categorical_columns(X) 174 self.non_cat_columns_ = [ 175 col for col in X.columns if col not in self.cat_columns_ 176 ] 177 178 # Set up cross-validation 179 kf = KFold( 180 n_splits=self.n_folds, shuffle=True, random_state=self.random_state 181 ) 182 183 for col in self.cat_columns_: 184 if X[col].nunique() <= 1: 185 # Handle constant columns 186 self.category_mappings_[col] = {X[col].iloc[0]: self.y_mean_} 187 continue 188 189 # Collect encodings for each category across all CV folds and ensemble members 190 category_encodings = defaultdict(list) 191 192 for ensemble_idx in range(self.ensemble_size): 193 ensemble_seed = self.random_state + ensemble_idx 194 fold_encodings = np.full(len(y), np.nan) 195 196 for fold_idx, (train_idx, val_idx) in enumerate(kf.split(X)): 197 # Training data for this fold 198 X_train_fold = X.iloc[train_idx] 199 y_train_fold = y[train_idx] 200 X_val_fold = X.iloc[val_idx] 201 202 if len(y_train_fold) == 0: 203 continue 204 205 # Generate pseudo-target for this fold and ensemble member 206 # Use deterministic seed based on ensemble_idx, fold_idx, and column 207 fold_seed = ( 208 ensemble_seed + fold_idx * 1000 + hash(col) % 10000 209 ) 210 z_train = self._generate_pseudo_target( 211 y_train_fold, fold_seed 212 ) 213 214 # Compute category statistics 215 cat_stats = self._compute_category_statistics( 216 X_train_fold[col].values, z_train 217 ) 218 219 if not cat_stats: 220 continue 221 222 # Apply shrinkage regularization 223 if self.aggregate == "mean": 224 global_stat = np.mean(z_train) 225 else: # median 226 global_stat = np.median(z_train) 227 228 regularized_stats = self._apply_shrinkage( 229 cat_stats, global_stat 230 ) 231 232 # Encode validation fold 233 for idx in val_idx: 234 category = X_val_fold.loc[idx, col] 235 if category in regularized_stats: 236 fold_encodings[idx] = regularized_stats[category] 237 else: 238 fold_encodings[idx] = global_stat 239 240 # Collect encodings by category for this ensemble member 241 for idx, encoding in enumerate(fold_encodings): 242 if not np.isnan(encoding): 243 category = X.iloc[idx][col] 244 category_encodings[category].append(encoding) 245 246 # Average encodings for each category across all ensemble members and folds 247 final_mappings = {} 248 for category, encodings in category_encodings.items(): 249 if encodings: 250 final_mappings[category] = np.mean(encodings) 251 else: 252 final_mappings[category] = self.y_mean_ 253 254 self.category_mappings_[col] = final_mappings 255 256 return self
Fit the encoder using cross-validation to prevent leakage.
7class RollingOriginForecaster(BaseEstimator, RegressorMixin): 8 """ 9 A flexible rolling origin forecaster that supports both autoregressive and 10 exogenous features modes, with multiple prediction strategies. 11 """ 12 13 def __init__( 14 self, 15 estimator, 16 max_horizon=1, 17 n_lags=1, 18 mode="auto", 19 multi_output="auto", 20 recursive=True, 21 ): 22 self.estimator = estimator 23 self.max_horizon = max_horizon 24 self.n_lags = n_lags 25 self.mode = mode 26 self.multi_output = multi_output 27 self.recursive = recursive 28 29 def fit(self, X=None, y=None): 30 if y is None: 31 raise ValueError("y cannot be None") 32 33 # Determine mode 34 if self.mode == "auto": 35 self.mode_ = "ar" if X is None else "exog" 36 else: 37 self.mode_ = self.mode 38 39 # Fit in appropriate mode 40 if self.mode_ == "ar": 41 return self._fit_ar(y) 42 else: 43 return self._fit_exog(X, y) 44 45 def predict(self, X=None, h=None): 46 check_is_fitted(self) 47 48 # Validate horizon 49 if h is None: 50 h = self.max_horizon 51 elif h > self.max_horizon: 52 raise ValueError( 53 f"Requested horizon {h} exceeds max_horizon {self.max_horizon}" 54 ) 55 56 if self.mode_ == "ar": 57 return self._predict_ar(h) 58 else: 59 if X is None: 60 raise ValueError("X cannot be None in exog mode") 61 X = check_array(X) 62 return self._predict_exog(X, h) 63 64 def _fit_ar(self, y): 65 """Fit autoregressive model.""" 66 y = check_array(y, ensure_2d=False) 67 68 # Validate series length 69 if len(y) < self.n_lags + 1: 70 raise ValueError( 71 f"y must have at least n_lags+1 ({self.n_lags+1}) samples" 72 ) 73 74 # Create lagged features matrix 75 X, y = self._create_lagged_features(y) 76 77 # Fit model 78 self.estimator_ = self._fit_model(X, y) 79 80 # Store last window for predictions 81 self.last_window_ = y[-self.n_lags :].reshape(1, -1) 82 83 return self 84 85 def _fit_exog(self, X, y): 86 """Fit model with exogenous features.""" 87 X, y = check_X_y(X, y, multi_output=True) 88 self.n_features_in_ = X.shape[1] 89 90 # Validate series length 91 if len(y) < self.max_horizon: 92 raise ValueError( 93 f"Need at least max_horizon ({self.max_horizon}) samples" 94 ) 95 96 # Fit model 97 self.estimator_ = self._fit_model(X, y) 98 99 return self 100 101 def _fit_model(self, X, y): 102 """Internal method to fit model with selected strategy.""" 103 # Determine multi-output strategy 104 if self.multi_output == "auto": 105 try: 106 # Test if estimator supports multi-output 107 dummy = clone(self.estimator) 108 test_shape = (min(10, len(X)), self.max_horizon) 109 dummy.fit(X[: min(10, len(X))], np.zeros(test_shape)) 110 self.multi_output_ = True 111 except Exception: 112 self.multi_output_ = False 113 else: 114 self.multi_output_ = self.multi_output 115 116 # Prepare targets for multi-step forecasting 117 if y.ndim == 1: 118 y = y.reshape(-1, 1) 119 120 if self.multi_output_: 121 # Create shifted targets matrix 122 T = len(y) 123 if T < self.max_horizon: 124 raise ValueError("Time series too short for max_horizon") 125 y_shifted = np.zeros((T - self.max_horizon + 1, self.max_horizon)) 126 for i in range(self.max_horizon): 127 y_shifted[:, i] = y[i : T - self.max_horizon + 1 + i].ravel() 128 129 # Trim X to match 130 X = X[: len(y_shifted)] 131 y = y_shifted 132 133 return clone(self.estimator).fit(X, y) 134 135 else: 136 if self.recursive: 137 # Single model for recursive predictions 138 return clone(self.estimator).fit(X, y.ravel()) 139 else: 140 # Separate models for each horizon 141 self.estimators_ = [] 142 for i in range(self.max_horizon): 143 X_i = X[: len(y) - i] 144 y_i = y[i:].ravel() 145 est = clone(self.estimator) 146 est.fit(X_i, y_i) 147 self.estimators_.append(est) 148 return self 149 150 def _predict_ar(self, h): 151 """Make autoregressive predictions.""" 152 current_window = self.last_window_.copy() 153 predictions = np.zeros((1, h)) 154 155 for i in range(h): 156 pred = self.estimator_.predict(current_window)[0] 157 predictions[0, i] = pred 158 # Update window 159 current_window = np.roll(current_window, -1) 160 current_window[0, -1] = pred 161 162 return predictions 163 164 def _predict_exog(self, X, h): 165 """Make predictions with exogenous features.""" 166 if hasattr(self, "estimators_"): # Direct strategy 167 preds = np.zeros((X.shape[0], h)) 168 for i in range(h): 169 preds[:, i] = self.estimators_[i].predict(X).ravel() 170 return preds 171 else: 172 if self.multi_output_: 173 pred_out = self.estimator_.predict(X) 174 if pred_out.shape[1] < h: 175 # Pad if model outputs fewer horizons than requested 176 pad_width = ((0, 0), (0, h - pred_out.shape[1])) 177 pred_out = np.pad(pred_out, pad_width, mode="constant") 178 return pred_out[:, :h] 179 else: # Recursive 180 preds = np.zeros((X.shape[0], h)) 181 current_pred = self.estimator_.predict(X) 182 if current_pred.ndim == 1: 183 current_pred = current_pred.reshape(-1, 1) 184 preds[:, 0] = current_pred.ravel() 185 186 for i in range(1, h): 187 if X.shape[1] > 1: 188 X_new = np.hstack([X[:, 1:], current_pred]) 189 else: 190 X_new = current_pred 191 current_pred = self.estimator_.predict(X_new) 192 if current_pred.ndim == 1: 193 current_pred = current_pred.reshape(-1, 1) 194 preds[:, i] = current_pred.ravel() 195 196 return preds 197 198 def _create_lagged_features(self, y): 199 """Create lagged features matrix for AR mode.""" 200 if len(y) <= self.n_lags: 201 raise ValueError("Not enough samples to create lagged features") 202 n_samples = len(y) - self.n_lags 203 X = np.zeros((n_samples, self.n_lags)) 204 for i in range(self.n_lags): 205 X[:, i] = y[i : i + n_samples] 206 y = y[self.n_lags :] 207 return X, y 208 209 def get_params(self, deep=True): 210 """Get parameters for this estimator.""" 211 params = { 212 "estimator": self.estimator, 213 "max_horizon": self.max_horizon, 214 "n_lags": self.n_lags, 215 "mode": self.mode, 216 "multi_output": self.multi_output, 217 "recursive": self.recursive, 218 } 219 if deep: 220 for key, value in params.items(): 221 if hasattr(value, "get_params"): 222 params[key] = value.get_params(deep) 223 return params 224 225 def set_params(self, **params): 226 """Set the parameters of this estimator.""" 227 for parameter, value in params.items(): 228 setattr(self, parameter, value) 229 return self
A flexible rolling origin forecaster that supports both autoregressive and exogenous features modes, with multiple prediction strategies.
29 def fit(self, X=None, y=None): 30 if y is None: 31 raise ValueError("y cannot be None") 32 33 # Determine mode 34 if self.mode == "auto": 35 self.mode_ = "ar" if X is None else "exog" 36 else: 37 self.mode_ = self.mode 38 39 # Fit in appropriate mode 40 if self.mode_ == "ar": 41 return self._fit_ar(y) 42 else: 43 return self._fit_exog(X, y)
45 def predict(self, X=None, h=None): 46 check_is_fitted(self) 47 48 # Validate horizon 49 if h is None: 50 h = self.max_horizon 51 elif h > self.max_horizon: 52 raise ValueError( 53 f"Requested horizon {h} exceeds max_horizon {self.max_horizon}" 54 ) 55 56 if self.mode_ == "ar": 57 return self._predict_ar(h) 58 else: 59 if X is None: 60 raise ValueError("X cannot be None in exog mode") 61 X = check_array(X) 62 return self._predict_exog(X, h)
12class RVFLJackknifePlus: 13 """RVFL network with closed-form jackknife+ prediction intervals. 14 15 Parameters 16 ---------- 17 n_hidden : int 18 Number of random hidden features. Set to 0 for plain ridge regression. 19 lambda_ : float 20 Ridge penalty for the read-out layer. 21 activation : {"tanh", "relu", "sigmoid"} 22 Nonlinearity for the random hidden layer. 23 random_state : int 24 Seed for random hidden-layer weights. 25 symmetric : bool 26 If True, use symmetric jackknife+ (absolute residuals). 27 If False (default), use asymmetric jackknife+. 28 """ 29 30 def __init__( 31 self, 32 n_hidden=200, 33 lambda_=1.0, 34 activation="tanh", 35 random_state=0, 36 symmetric=False, 37 ): 38 self.n_hidden = n_hidden 39 self.lambda_ = lambda_ 40 self.activation = activation 41 self.random_state = random_state 42 self.symmetric = symmetric 43 44 def _act(self, U): 45 if self.activation == "tanh": 46 return np.tanh(U) 47 if self.activation == "relu": 48 return np.maximum(U, 0.0) 49 if self.activation == "sigmoid": 50 return 1.0 / (1.0 + np.exp(-U)) 51 raise ValueError(f"unknown activation '{self.activation}'") 52 53 def _augment(self, X): 54 if self.n_hidden == 0: 55 return X 56 H = self._act(X @ self.W_ + self.b_) 57 return np.hstack([X, H]) 58 59 def fit(self, X_train, y_train): 60 n, p = X_train.shape 61 62 # Fixed random hidden layer 63 rng = np.random.default_rng(self.random_state) 64 self.W_ = rng.normal(scale=1.0 / np.sqrt(p), size=(p, self.n_hidden)) 65 self.b_ = rng.normal(scale=1.0, size=(self.n_hidden,)) 66 67 Z = self._augment(X_train) 68 q = Z.shape[1] 69 70 # Center response (intercept not penalized) 71 self.ybar_ = y_train.mean() 72 yc = y_train - self.ybar_ 73 74 # Ridge solution 75 A = Z.T @ Z + self.lambda_ * np.eye(q) 76 self.A_inv_ = np.linalg.inv(A) 77 self.beta_ = self.A_inv_ @ Z.T @ yc 78 self.Z_train_ = Z 79 80 # Closed-form LOO residuals (memory efficient) 81 h = np.sum((Z @ self.A_inv_) * Z, axis=1) # diag(Z @ A_inv @ Z.T) 82 e = yc - Z @ self.beta_ # in-sample residuals 83 self.r_ = e / np.maximum(1.0 - h, 1e-10) # LOO residuals 84 85 return self 86 87 def predict(self, X, alpha=0.1, return_pi=False): 88 """Predict and optionally return prediction intervals. 89 90 Parameters 91 ---------- 92 X : array-like, shape (n_samples, n_features) 93 Test data. 94 alpha : float, default=0.1 95 Significance level for prediction intervals (1-alpha coverage). 96 return_pi : bool, default=False 97 If True, return Prediction namedtuple with mean, lower, and upper. 98 If False, return only the mean predictions. 99 100 Returns 101 ------- 102 If return_pi=False: 103 y_pred : array, shape (n_samples,) 104 Mean predictions. 105 If return_pi=True: 106 Prediction : namedtuple 107 Named tuple with fields 'mean', 'lower', 'upper'. 108 """ 109 Z = self._augment(X) 110 yhat = Z @ self.beta_ + self.ybar_ 111 112 if not return_pi: 113 return yhat 114 115 # Cross-term: n_test x n_train 116 G = Z @ self.A_inv_ @ self.Z_train_.T 117 118 # f^{-i}(x_j) = f(x_j) - G[j,i] * r_i (Sherman-Morrison) 119 loo_pred = yhat[:, None] - G * self.r_[None, :] 120 121 if self.symmetric: 122 # Symmetric version: use absolute residuals 123 scores = np.abs(loo_pred - yhat[:, None]) + np.abs(self.r_[None, :]) 124 q = np.quantile(scores, 1 - alpha, axis=1) 125 lo = yhat - q 126 hi = yhat + q 127 else: 128 # Asymmetric version: use signed residuals 129 scores = loo_pred + self.r_[None, :] 130 lo = np.quantile(scores, alpha / 2, axis=1) 131 hi = np.quantile(scores, 1 - alpha / 2, axis=1) 132 133 return Prediction(mean=yhat, lower=lo, upper=hi)
RVFL network with closed-form jackknife+ prediction intervals.
Parameters
n_hidden : int Number of random hidden features. Set to 0 for plain ridge regression. lambda_ : float Ridge penalty for the read-out layer. activation : {"tanh", "relu", "sigmoid"} Nonlinearity for the random hidden layer. random_state : int Seed for random hidden-layer weights. symmetric : bool If True, use symmetric jackknife+ (absolute residuals). If False (default), use asymmetric jackknife+.
59 def fit(self, X_train, y_train): 60 n, p = X_train.shape 61 62 # Fixed random hidden layer 63 rng = np.random.default_rng(self.random_state) 64 self.W_ = rng.normal(scale=1.0 / np.sqrt(p), size=(p, self.n_hidden)) 65 self.b_ = rng.normal(scale=1.0, size=(self.n_hidden,)) 66 67 Z = self._augment(X_train) 68 q = Z.shape[1] 69 70 # Center response (intercept not penalized) 71 self.ybar_ = y_train.mean() 72 yc = y_train - self.ybar_ 73 74 # Ridge solution 75 A = Z.T @ Z + self.lambda_ * np.eye(q) 76 self.A_inv_ = np.linalg.inv(A) 77 self.beta_ = self.A_inv_ @ Z.T @ yc 78 self.Z_train_ = Z 79 80 # Closed-form LOO residuals (memory efficient) 81 h = np.sum((Z @ self.A_inv_) * Z, axis=1) # diag(Z @ A_inv @ Z.T) 82 e = yc - Z @ self.beta_ # in-sample residuals 83 self.r_ = e / np.maximum(1.0 - h, 1e-10) # LOO residuals 84 85 return self
87 def predict(self, X, alpha=0.1, return_pi=False): 88 """Predict and optionally return prediction intervals. 89 90 Parameters 91 ---------- 92 X : array-like, shape (n_samples, n_features) 93 Test data. 94 alpha : float, default=0.1 95 Significance level for prediction intervals (1-alpha coverage). 96 return_pi : bool, default=False 97 If True, return Prediction namedtuple with mean, lower, and upper. 98 If False, return only the mean predictions. 99 100 Returns 101 ------- 102 If return_pi=False: 103 y_pred : array, shape (n_samples,) 104 Mean predictions. 105 If return_pi=True: 106 Prediction : namedtuple 107 Named tuple with fields 'mean', 'lower', 'upper'. 108 """ 109 Z = self._augment(X) 110 yhat = Z @ self.beta_ + self.ybar_ 111 112 if not return_pi: 113 return yhat 114 115 # Cross-term: n_test x n_train 116 G = Z @ self.A_inv_ @ self.Z_train_.T 117 118 # f^{-i}(x_j) = f(x_j) - G[j,i] * r_i (Sherman-Morrison) 119 loo_pred = yhat[:, None] - G * self.r_[None, :] 120 121 if self.symmetric: 122 # Symmetric version: use absolute residuals 123 scores = np.abs(loo_pred - yhat[:, None]) + np.abs(self.r_[None, :]) 124 q = np.quantile(scores, 1 - alpha, axis=1) 125 lo = yhat - q 126 hi = yhat + q 127 else: 128 # Asymmetric version: use signed residuals 129 scores = loo_pred + self.r_[None, :] 130 lo = np.quantile(scores, alpha / 2, axis=1) 131 hi = np.quantile(scores, 1 - alpha / 2, axis=1) 132 133 return Prediction(mean=yhat, lower=lo, upper=hi)
Predict and optionally return prediction intervals.
Parameters
X : array-like, shape (n_samples, n_features) Test data. alpha : float, default=0.1 Significance level for prediction intervals (1-alpha coverage). return_pi : bool, default=False If True, return Prediction namedtuple with mean, lower, and upper. If False, return only the mean predictions.
Returns
If return_pi=False: y_pred : array, shape (n_samples,) Mean predictions. If return_pi=True: Prediction : namedtuple Named tuple with fields 'mean', 'lower', 'upper'.
7def penalized_cross_val_score( 8 estimator, 9 X, 10 y, 11 param_dict, 12 cv=5, 13 scorer=None, 14 penalty_strength=0.1, 15 penalty_type="ci", 16 greater_is_better=False, 17): 18 """ 19 Calculates a penalized cross-validation score that balances mean performance 20 and result stability (low variability across folds). 21 22 Parameters: 23 ----------- 24 estimator : sklearn estimator 25 Model to evaluate. 26 X, y : array-like 27 Training data. 28 param_dict : dict 29 Hyperparameters to set on the estimator. 30 cv : int, default=5 31 Number of cross-validation folds (must be >= 2). 32 scorer : callable or str, optional 33 Scikit-learn scorer (e.g., from sklearn.metrics.make_scorer). 34 penalty_strength : float, default=0.1 35 Multiplicative factor for the variability penalty. 36 penalty_strength=0.1 = penalize by up to 10% of mean score 37 penalty_type : {'std', 'max', 'range', 'ci'} 38 Type of variability to penalize: 39 - 'std': standard deviation of fold scores 40 - 'max': maximum deviation from mean across folds 41 - 'range': difference between best and worst fold 42 - 'ci': approximate 95% confidence interval width (2 * SEM) 43 greater_is_better : bool 44 Whether higher raw scores are better (e.g., accuracy=True, RMSE=False). 45 46 Returns: 47 -------- 48 penalized_score : float 49 Mean CV score adjusted by penalty. Always "lower is better" in effect, 50 so unstable models are penalized. 51 """ 52 53 if penalty_strength < 0: 54 raise ValueError("penalty_strength must be non-negative.") 55 56 if cv < 2: 57 raise ValueError("cv must be at least 2.") 58 59 # Validate parameters 60 estimator_params = estimator.get_params() 61 missing_params = [key for key in param_dict if key not in estimator_params] 62 if missing_params: 63 raise ValueError( 64 f"Estimator does not have parameters: {', '.join(missing_params)}" 65 ) 66 67 # Clone and configure estimator 68 current_estimator = clone(estimator) 69 current_estimator.set_params(**param_dict) 70 71 # Perform cross-validation 72 cv_scores = cross_val_score(current_estimator, X, y, cv=cv, scoring=scorer) 73 74 if len(cv_scores) == 0: 75 raise ValueError("Cross-validation scores are empty.") 76 77 mean_score = np.mean(cv_scores) 78 79 # Compute variability measure 80 if penalty_type == "std": 81 variability_measure = np.std(cv_scores) 82 elif penalty_type == "max": 83 variability_measure = np.max(np.abs(cv_scores - mean_score)) 84 elif penalty_type == "range": 85 variability_measure = np.ptp(cv_scores) # max - min 86 elif penalty_type == "ci": 87 # Approximate 95% CI width: 2 * standard error of the mean 88 variability_measure = 2 * (np.std(cv_scores) / np.sqrt(len(cv_scores))) 89 else: 90 raise ValueError("penalty_type must be 'std', 'max', 'range', or 'ci'.") 91 92 # Scale penalty relative to mean score magnitude 93 if abs(mean_score) > 1e-10: # avoid division by zero 94 normalized_penalty = penalty_strength * ( 95 variability_measure / abs(mean_score) 96 ) 97 else: 98 normalized_penalty = penalty_strength * variability_measure 99 100 # Apply penalty: make worse for instability 101 if greater_is_better: 102 return mean_score - normalized_penalty # lower score = penalized 103 else: 104 return mean_score + normalized_penalty # higher score = penalized
Calculates a penalized cross-validation score that balances mean performance and result stability (low variability across folds).
Parameters:
estimator : sklearn estimator Model to evaluate. X, y : array-like Training data. param_dict : dict Hyperparameters to set on the estimator. cv : int, default=5 Number of cross-validation folds (must be >= 2). scorer : callable or str, optional Scikit-learn scorer (e.g., from sklearn.metrics.make_scorer). penalty_strength : float, default=0.1 Multiplicative factor for the variability penalty. penalty_strength=0.1 = penalize by up to 10% of mean score penalty_type : {'std', 'max', 'range', 'ci'} Type of variability to penalize: - 'std': standard deviation of fold scores - 'max': maximum deviation from mean across folds - 'range': difference between best and worst fold - 'ci': approximate 95% confidence interval width (2 * SEM) greater_is_better : bool Whether higher raw scores are better (e.g., accuracy=True, RMSE=False).
Returns:
penalized_score : float Mean CV score adjusted by penalty. Always "lower is better" in effect, so unstable models are penalized.
7def make_diverse_classification(n_datasets=100, random_state=None): 8 rng = np.random.default_rng(random_state) 9 10 for _ in range(n_datasets): 11 # Sample parameters 12 n_samples = int(loguniform(100, 10000).rvs(random_state=rng)) 13 n_features = int(rng.uniform(10, 50)) 14 15 # --- Step 1: Choose n_classes safely --- 16 max_classes_by_sample = max(2, n_samples // 10) 17 n_classes = rng.integers(2, min(100, max_classes_by_sample) + 1) 18 n_classes = max(2, min(n_classes, n_samples // 2)) 19 20 # --- Step 2: Class weights with minimum samples --- 21 alpha = [0.5] * n_classes 22 weights = dirichlet.rvs(alpha, random_state=rng.integers(0, 2**32))[0] 23 weights /= weights.sum() 24 25 min_per_class = 2 26 total_min = n_classes * min_per_class 27 if total_min > n_samples: 28 weights = np.ones(n_classes) / n_classes 29 else: 30 # Distribute at least min_per_class, then scale 31 y_counts = np.maximum( 32 np.round(weights * n_samples), min_per_class 33 ).astype(int) 34 y_counts = (y_counts / y_counts.sum() * n_samples).astype(int) 35 y_counts[-1] += n_samples - y_counts.sum() # fix rounding 36 y_counts = np.maximum(y_counts, min_per_class) 37 y_counts[-1] += n_samples - y_counts.sum() 38 weights = (y_counts / n_samples).tolist() 39 40 # --- Step 3: Informative features --- 41 # Must support n_classes * n_clusters_per_class <= 2 ** n_informative 42 # So let's first pick n_informative large enough or cap n_classes 43 n_informative = max( 44 4, int(rng.uniform(4, min(10, n_features))) 45 ) # start higher 46 n_informative = min(n_informative, n_features - 6, n_samples - 1) 47 48 # Cap n_classes based on n_informative 49 max_possible_classes = 2**n_informative 50 if n_classes > max_possible_classes: 51 n_classes = max_possible_classes 52 # Recompute weights 53 alpha = [0.5] * n_classes 54 weights = dirichlet.rvs( 55 alpha, random_state=rng.integers(0, 2**32) 56 )[0] 57 weights = (weights / weights.sum()).tolist() 58 59 # --- Step 4: Redundant, repeated, noise --- 60 n_redundant = min( 61 n_informative, 62 int(rng.uniform(0, 0.5) * (n_features - n_informative)), 63 ) 64 available = n_features - n_informative - n_redundant 65 n_repeated = ( 66 int(rng.uniform(0, 0.2) * available) if available > 0 else 0 67 ) 68 n_noise = n_features - n_informative - n_redundant - n_repeated 69 70 if n_noise < 0: 71 continue # should not happen 72 73 # --- Step 5: Clusters per class --- 74 max_clusters_total = 2**n_informative 75 n_clusters_per_class = rng.integers(1, 4) 76 n_clusters_per_class = min( 77 n_clusters_per_class, max_clusters_total // n_classes 78 ) 79 n_clusters_per_class = max(1, n_clusters_per_class) 80 81 # --- Step 6: Other parameters --- 82 class_sep = loguniform(0.1, 10).rvs(random_state=rng) 83 flip_y = rng.uniform(0.0, 0.5) 84 hypercube = rng.choice([True, False]) 85 shift = rng.uniform(-1, 1, n_features) if rng.random() < 0.5 else 0.0 86 scale = rng.uniform(0.5, 5.0) 87 88 # --- Final safety --- 89 if n_informative + n_redundant + n_repeated > n_features: 90 continue 91 92 try: 93 X, y = make_classification( 94 n_samples=n_samples, 95 n_features=n_features, 96 n_informative=n_informative, 97 n_redundant=n_redundant, 98 n_repeated=n_repeated, 99 n_classes=n_classes, 100 n_clusters_per_class=n_clusters_per_class, 101 weights=weights, 102 flip_y=flip_y, 103 class_sep=class_sep, 104 hypercube=hypercube, 105 shift=shift, 106 scale=scale, 107 shuffle=True, 108 random_state=rng.integers(0, 2**32), 109 ) 110 except Exception as e: 111 print(f"Skipped due to error: {e}") 112 continue 113 114 metadata = { 115 "n_samples": n_samples, 116 "n_features": n_features, 117 "n_classes": n_classes, 118 "n_informative": n_informative, 119 "n_redundant": n_redundant, 120 "n_repeated": n_repeated, 121 "n_noise": n_noise, 122 "weights": weights, 123 "flip_y": flip_y, 124 "class_sep": class_sep, 125 "n_clusters_per_class": n_clusters_per_class, 126 "hypercube": hypercube, 127 "scale": scale, 128 } 129 130 yield X, y, metadata
10class HealthcareTimeSeriesGenerator: 11 def __init__(self, seed=42): 12 np.random.seed(seed) 13 random.seed(seed) 14 15 # Define realistic ranges for vital signs and lab values 16 self.vital_ranges = { 17 "heart_rate": (60, 100, 10), # (min, max, std) 18 "systolic_bp": (90, 140, 15), 19 "diastolic_bp": (60, 90, 10), 20 "temperature": (36.1, 37.2, 0.3), # Celsius 21 "respiratory_rate": (12, 20, 3), 22 "oxygen_saturation": (95, 100, 2), 23 } 24 25 self.lab_ranges = { 26 "glucose": (70, 110, 20), # mg/dL 27 "creatinine": (0.6, 1.2, 0.2), # mg/dL 28 "hemoglobin": (12, 16, 1.5), # g/dL 29 "white_blood_cells": (4000, 11000, 1500), # cells/μL 30 "sodium": (136, 145, 3), # mEq/L 31 "potassium": (3.5, 5.0, 0.4), # mEq/L 32 } 33 34 # Medical conditions that affect vital signs 35 self.conditions = [ 36 "hypertension", 37 "diabetes", 38 "copd", 39 "heart_failure", 40 "kidney_disease", 41 "anemia", 42 "infection", 43 "healthy", 44 ] 45 46 def generate_patient_demographics(self, n_patients=100): 47 """Generate realistic patient demographics""" 48 patients = [] 49 50 for i in range(n_patients): 51 age = np.random.normal(65, 15) # Average hospital patient age 52 age = max(18, min(95, int(age))) # Clamp between 18-95 53 54 gender = random.choice(["M", "F"]) 55 56 # Assign conditions based on age and gender 57 conditions = self._assign_conditions(age, gender) 58 59 patient = { 60 "patient_id": f"P{i+1:04d}", 61 "age": age, 62 "gender": gender, 63 "conditions": conditions, 64 "admission_date": self._random_date(), 65 "length_of_stay": random.randint(1, 30), 66 } 67 patients.append(patient) 68 69 return pd.DataFrame(patients) 70 71 def _assign_conditions(self, age, gender): 72 """Assign medical conditions based on demographics""" 73 conditions = [] 74 75 # Age-related condition probabilities 76 if age > 50: 77 if random.random() < 0.3: 78 conditions.append("hypertension") 79 if random.random() < 0.15: 80 conditions.append("diabetes") 81 if random.random() < 0.1: 82 conditions.append("heart_failure") 83 84 if age > 60: 85 if random.random() < 0.08: 86 conditions.append("copd") 87 if random.random() < 0.12: 88 conditions.append("kidney_disease") 89 90 if gender == "F" and random.random() < 0.1: 91 conditions.append("anemia") 92 93 if random.random() < 0.05: 94 conditions.append("infection") 95 96 if not conditions: 97 conditions.append("healthy") 98 99 return conditions 100 101 def _random_date(self): 102 """Generate random date within last 2 years""" 103 start_date = datetime.now() - timedelta(days=730) 104 random_days = random.randint(0, 730) 105 return start_date + timedelta(days=random_days) 106 107 def generate_time_series( 108 self, patients_df, measurements_per_day=4, include_missing=True 109 ): 110 """Generate time series data for all patients""" 111 all_measurements = [] 112 113 for _, patient in patients_df.iterrows(): 114 patient_measurements = self._generate_patient_timeseries( 115 patient, measurements_per_day, include_missing 116 ) 117 all_measurements.extend(patient_measurements) 118 119 return pd.DataFrame(all_measurements) 120 121 def _generate_patient_timeseries( 122 self, patient, measurements_per_day, include_missing 123 ): 124 """Generate time series for a single patient""" 125 measurements = [] 126 127 start_date = patient["admission_date"] 128 length_of_stay = patient["length_of_stay"] 129 conditions = patient["conditions"] 130 131 # Generate measurements for each day 132 for day in range(length_of_stay): 133 current_date = start_date + timedelta(days=day) 134 135 # Generate multiple measurements per day 136 for measurement_num in range(measurements_per_day): 137 timestamp = current_date + timedelta( 138 hours=measurement_num * (24 / measurements_per_day) 139 ) 140 141 measurement = { 142 "patient_id": patient["patient_id"], 143 "timestamp": timestamp, 144 "day_of_stay": day + 1, 145 } 146 147 # Generate vital signs 148 vitals = self._generate_vitals(conditions, day, patient["age"]) 149 measurement.update(vitals) 150 151 # Generate lab values (less frequent) 152 if ( 153 measurement_num == 0 or random.random() < 0.1 154 ): # Morning labs or random 155 labs = self._generate_labs(conditions, day) 156 measurement.update(labs) 157 else: 158 # Add NaN for missing lab values 159 for lab in self.lab_ranges.keys(): 160 measurement[lab] = np.nan 161 162 # Add some random missing values if requested 163 if include_missing: 164 measurement = self._add_missing_values(measurement) 165 166 measurements.append(measurement) 167 168 return measurements 169 170 def _generate_vitals(self, conditions, day, age): 171 """Generate vital signs based on patient conditions and progression""" 172 vitals = {} 173 174 for vital, (base_min, base_max, base_std) in self.vital_ranges.items(): 175 base_mean = (base_min + base_max) / 2 176 177 # Adjust based on conditions 178 mean_adjustment = 0 179 std_adjustment = 1 180 181 if "hypertension" in conditions: 182 if "systolic" in vital: 183 mean_adjustment += 20 184 elif "diastolic" in vital: 185 mean_adjustment += 10 186 187 if "heart_failure" in conditions: 188 if vital == "heart_rate": 189 mean_adjustment += 15 190 elif vital == "respiratory_rate": 191 mean_adjustment += 5 192 elif vital == "oxygen_saturation": 193 mean_adjustment -= 3 194 195 if "copd" in conditions: 196 if vital == "respiratory_rate": 197 mean_adjustment += 8 198 elif vital == "oxygen_saturation": 199 mean_adjustment -= 5 200 201 if "infection" in conditions: 202 if vital == "temperature": 203 mean_adjustment += np.random.normal(1.5, 0.5) 204 elif vital == "heart_rate": 205 mean_adjustment += 20 206 207 # Age adjustments 208 if age > 70: 209 if vital == "systolic_bp": 210 mean_adjustment += 10 211 elif vital == "heart_rate": 212 mean_adjustment -= 5 213 214 # Day progression (recovery/deterioration) 215 day_effect = np.sin(day * 0.2) * 2 # Subtle oscillation 216 217 # Generate value 218 adjusted_mean = base_mean + mean_adjustment + day_effect 219 adjusted_std = base_std * std_adjustment 220 221 value = np.random.normal(adjusted_mean, adjusted_std) 222 223 # Apply realistic bounds 224 if vital == "temperature": 225 value = max(35.0, min(42.0, value)) 226 elif vital == "oxygen_saturation": 227 value = max(70, min(100, value)) 228 elif vital == "heart_rate": 229 value = max(40, min(180, value)) 230 elif "bp" in vital: 231 value = max(40, min(200, value)) 232 elif vital == "respiratory_rate": 233 value = max(8, min(40, value)) 234 235 vitals[vital] = round(value, 1) 236 237 return vitals 238 239 def _generate_labs(self, conditions, day): 240 """Generate lab values based on conditions""" 241 labs = {} 242 243 for lab, (base_min, base_max, base_std) in self.lab_ranges.items(): 244 base_mean = (base_min + base_max) / 2 245 246 # Condition-based adjustments 247 mean_adjustment = 0 248 249 if "diabetes" in conditions and lab == "glucose": 250 mean_adjustment += np.random.normal(50, 20) 251 252 if "kidney_disease" in conditions: 253 if lab == "creatinine": 254 mean_adjustment += np.random.normal(1.0, 0.5) 255 elif lab == "potassium": 256 mean_adjustment += np.random.normal(0.5, 0.2) 257 258 if "anemia" in conditions and lab == "hemoglobin": 259 mean_adjustment -= np.random.normal(3, 1) 260 261 if "infection" in conditions and lab == "white_blood_cells": 262 mean_adjustment += np.random.normal(5000, 2000) 263 264 # Generate value 265 adjusted_mean = base_mean + mean_adjustment 266 value = np.random.normal(adjusted_mean, base_std) 267 268 # Apply bounds 269 if lab == "glucose": 270 value = max(30, min(500, value)) 271 elif lab == "creatinine": 272 value = max(0.3, min(10.0, value)) 273 elif lab == "hemoglobin": 274 value = max(5.0, min(20.0, value)) 275 elif lab == "white_blood_cells": 276 value = max(1000, min(50000, value)) 277 elif lab == "sodium": 278 value = max(120, min(160, value)) 279 elif lab == "potassium": 280 value = max(2.0, min(7.0, value)) 281 282 labs[lab] = round(value, 2) 283 284 return labs 285 286 def _add_missing_values(self, measurement, missing_prob=0.05): 287 """Randomly add missing values to simulate real-world data""" 288 for key, value in measurement.items(): 289 if key not in [ 290 "patient_id", 291 "timestamp", 292 "day_of_stay", 293 ] and not pd.isna(value): 294 if random.random() < missing_prob: 295 measurement[key] = np.nan 296 return measurement 297 298 def generate_outcomes(self, patients_df, timeseries_df): 299 """Generate patient outcomes based on their data""" 300 outcomes = [] 301 302 for _, patient in patients_df.iterrows(): 303 patient_data = timeseries_df[ 304 timeseries_df["patient_id"] == patient["patient_id"] 305 ] 306 307 # Calculate outcome probability based on conditions and vital trends 308 readmission_prob = self._calculate_readmission_risk( 309 patient, patient_data 310 ) 311 mortality_risk = self._calculate_mortality_risk( 312 patient, patient_data 313 ) 314 315 outcome = { 316 "patient_id": patient["patient_id"], 317 "readmitted_30_days": random.random() < readmission_prob, 318 "mortality_risk_score": round(mortality_risk, 3), 319 "length_of_stay_actual": patient["length_of_stay"], 320 "discharge_disposition": self._assign_discharge_disposition( 321 patient, mortality_risk 322 ), 323 } 324 outcomes.append(outcome) 325 326 return pd.DataFrame(outcomes) 327 328 def _calculate_readmission_risk(self, patient, patient_data): 329 """Calculate 30-day readmission risk""" 330 base_risk = 0.1 # 10% base readmission rate 331 332 # Condition-based risk 333 if "heart_failure" in patient["conditions"]: 334 base_risk += 0.15 335 if "diabetes" in patient["conditions"]: 336 base_risk += 0.08 337 if "kidney_disease" in patient["conditions"]: 338 base_risk += 0.12 339 340 # Age-based risk 341 if patient["age"] > 75: 342 base_risk += 0.1 343 344 # Vital signs instability 345 if len(patient_data) > 0: 346 hr_std = patient_data["heart_rate"].std() 347 if hr_std > 15: 348 base_risk += 0.05 349 350 return min(0.8, base_risk) 351 352 def _calculate_mortality_risk(self, patient, patient_data): 353 """Calculate mortality risk score""" 354 risk_score = 0 355 356 # Age component 357 risk_score += patient["age"] * 0.02 358 359 # Condition components 360 condition_weights = { 361 "heart_failure": 0.3, 362 "kidney_disease": 0.25, 363 "copd": 0.2, 364 "infection": 0.15, 365 "diabetes": 0.1, 366 "hypertension": 0.05, 367 } 368 369 for condition in patient["conditions"]: 370 if condition in condition_weights: 371 risk_score += condition_weights[condition] 372 373 # Vital signs component 374 if len(patient_data) > 0: 375 # Abnormal vital signs increase risk 376 avg_o2_sat = patient_data["oxygen_saturation"].mean() 377 if avg_o2_sat < 92: 378 risk_score += 0.2 379 380 avg_temp = patient_data["temperature"].mean() 381 if avg_temp > 38.5: 382 risk_score += 0.15 383 384 return min(1.0, risk_score) 385 386 def _assign_discharge_disposition(self, patient, mortality_risk): 387 """Assign discharge disposition""" 388 if mortality_risk > 0.7: 389 return random.choice(["ICU Transfer", "Deceased"]) 390 elif mortality_risk > 0.4: 391 return random.choice( 392 ["Skilled Nursing Facility", "Home with Services"] 393 ) 394 else: 395 return random.choice( 396 ["Home", "Home with Services", "Rehabilitation"] 397 ) 398 399 def create_visualizations(self, patients, timeseries, outcomes): 400 """Create comprehensive visualizations of the healthcare data""" 401 402 # Set up the plotting style 403 plt.style.use("default") 404 sns.set_palette("husl") 405 406 # Create figure with subplots 407 fig = plt.figure(figsize=(20, 16)) 408 409 # 1. Patient Demographics 410 plt.subplot(3, 4, 1) 411 patients["age"].hist( 412 bins=15, alpha=0.7, color="skyblue", edgecolor="black" 413 ) 414 plt.title("Age Distribution", fontsize=12, fontweight="bold") 415 plt.xlabel("Age") 416 plt.ylabel("Frequency") 417 418 # 2. Gender Distribution 419 plt.subplot(3, 4, 2) 420 gender_counts = patients["gender"].value_counts() 421 plt.pie( 422 gender_counts.values, 423 labels=gender_counts.index, 424 autopct="%1.1f%%", 425 colors=["lightcoral", "lightblue"], 426 ) 427 plt.title("Gender Distribution", fontsize=12, fontweight="bold") 428 429 # 3. Medical Conditions Frequency 430 plt.subplot(3, 4, 3) 431 all_conditions = [ 432 cond for conditions in patients["conditions"] for cond in conditions 433 ] 434 condition_counts = pd.Series(all_conditions).value_counts() 435 condition_counts.plot(kind="bar", color="lightgreen", alpha=0.8) 436 plt.title( 437 "Medical Conditions Frequency", fontsize=12, fontweight="bold" 438 ) 439 plt.xticks(rotation=45) 440 plt.ylabel("Count") 441 442 # 4. Length of Stay Distribution 443 plt.subplot(3, 4, 4) 444 patients["length_of_stay"].hist( 445 bins=15, alpha=0.7, color="orange", edgecolor="black" 446 ) 447 plt.title("Length of Stay Distribution", fontsize=12, fontweight="bold") 448 plt.xlabel("Days") 449 plt.ylabel("Frequency") 450 451 # 5. Heart Rate Time Series for Sample Patients 452 plt.subplot(3, 4, 5) 453 sample_patients = patients["patient_id"].head(5) 454 for pid in sample_patients: 455 patient_data = timeseries[timeseries["patient_id"] == pid].copy() 456 if len(patient_data) > 0: 457 patient_data = patient_data.sort_values("timestamp") 458 plt.plot( 459 patient_data["day_of_stay"], 460 patient_data["heart_rate"], 461 marker="o", 462 markersize=3, 463 alpha=0.7, 464 label=pid, 465 ) 466 plt.title( 467 "Heart Rate Over Time (Sample Patients)", 468 fontsize=12, 469 fontweight="bold", 470 ) 471 plt.xlabel("Day of Stay") 472 plt.ylabel("Heart Rate (bpm)") 473 plt.legend(fontsize=8) 474 475 # 6. Blood Pressure Correlation 476 plt.subplot(3, 4, 6) 477 clean_bp = timeseries.dropna(subset=["systolic_bp", "diastolic_bp"]) 478 plt.scatter( 479 clean_bp["systolic_bp"], 480 clean_bp["diastolic_bp"], 481 alpha=0.5, 482 s=10, 483 color="red", 484 ) 485 plt.title("Blood Pressure Correlation", fontsize=12, fontweight="bold") 486 plt.xlabel("Systolic BP") 487 plt.ylabel("Diastolic BP") 488 489 # 7. Temperature vs Heart Rate 490 plt.subplot(3, 4, 7) 491 clean_temp_hr = timeseries.dropna(subset=["temperature", "heart_rate"]) 492 plt.scatter( 493 clean_temp_hr["temperature"], 494 clean_temp_hr["heart_rate"], 495 alpha=0.5, 496 s=10, 497 color="purple", 498 ) 499 plt.title("Temperature vs Heart Rate", fontsize=12, fontweight="bold") 500 plt.xlabel("Temperature (°C)") 501 plt.ylabel("Heart Rate (bpm)") 502 503 # 8. Vital Signs Distribution 504 plt.subplot(3, 4, 8) 505 vital_cols = ["heart_rate", "respiratory_rate", "oxygen_saturation"] 506 timeseries[vital_cols].boxplot() 507 plt.title("Vital Signs Distribution", fontsize=12, fontweight="bold") 508 plt.xticks(rotation=45) 509 510 # 9. Lab Values Over Time 511 plt.subplot(3, 4, 9) 512 sample_patient = timeseries[ 513 timeseries["patient_id"] == sample_patients.iloc[0] 514 ].copy() 515 sample_patient = sample_patient.sort_values("timestamp") 516 517 # Plot glucose if available 518 glucose_data = sample_patient.dropna(subset=["glucose"]) 519 if len(glucose_data) > 0: 520 plt.plot( 521 glucose_data["day_of_stay"], 522 glucose_data["glucose"], 523 "o-", 524 color="green", 525 label="Glucose", 526 ) 527 528 # Plot creatinine if available 529 creat_data = sample_patient.dropna(subset=["creatinine"]) 530 if len(creat_data) > 0: 531 plt.twinx() 532 plt.plot( 533 creat_data["day_of_stay"], 534 creat_data["creatinine"], 535 "o-", 536 color="blue", 537 label="Creatinine", 538 ) 539 plt.ylabel("Creatinine (mg/dL)", color="blue") 540 541 plt.title( 542 f"Lab Values - {sample_patients.iloc[0]}", 543 fontsize=12, 544 fontweight="bold", 545 ) 546 plt.xlabel("Day of Stay") 547 plt.ylabel("Glucose (mg/dL)", color="green") 548 549 # 10. Readmission Risk by Age Group 550 plt.subplot(3, 4, 10) 551 merged_data = patients.merge(outcomes, on="patient_id") 552 merged_data["age_group"] = pd.cut( 553 merged_data["age"], 554 bins=[0, 40, 60, 80, 100], 555 labels=["<40", "40-60", "60-80", "80+"], 556 ) 557 readmission_by_age = merged_data.groupby("age_group")[ 558 "readmitted_30_days" 559 ].mean() 560 readmission_by_age.plot(kind="bar", color="salmon", alpha=0.8) 561 plt.title( 562 "30-Day Readmission Rate by Age", fontsize=12, fontweight="bold" 563 ) 564 plt.ylabel("Readmission Rate") 565 plt.xticks(rotation=0) 566 567 # 11. Mortality Risk Distribution 568 plt.subplot(3, 4, 11) 569 outcomes["mortality_risk_score"].hist( 570 bins=20, alpha=0.7, color="darkred", edgecolor="black" 571 ) 572 plt.title( 573 "Mortality Risk Score Distribution", fontsize=12, fontweight="bold" 574 ) 575 plt.xlabel("Risk Score") 576 plt.ylabel("Frequency") 577 578 # 12. Missing Data Heatmap 579 plt.subplot(3, 4, 12) 580 # Calculate missing data percentage for each column 581 missing_data = timeseries.isnull().sum() / len(timeseries) * 100 582 missing_data = missing_data[missing_data > 0].sort_values( 583 ascending=False 584 ) 585 586 if len(missing_data) > 0: 587 missing_data.plot(kind="bar", color="gray", alpha=0.8) 588 plt.title("Missing Data Percentage", fontsize=12, fontweight="bold") 589 plt.ylabel("Missing %") 590 plt.xticks(rotation=45) 591 else: 592 plt.text( 593 0.5, 594 0.5, 595 "No Missing Data", 596 ha="center", 597 va="center", 598 transform=plt.gca().transAxes, 599 fontsize=14, 600 ) 601 plt.title("Missing Data Percentage", fontsize=12, fontweight="bold") 602 603 plt.tight_layout() 604 plt.savefig( 605 "healthcare_data_visualization.png", dpi=300, bbox_inches="tight" 606 ) 607 print("Visualization saved as: healthcare_data_visualization.png") 608 plt.show() 609 # Create additional detailed plots 610 self.create_detailed_plots(patients, timeseries) 611 612 def create_detailed_plots(self, patients, timeseries): 613 """Create additional detailed visualizations""" 614 615 # Time Series Plot for Multiple Vital Signs 616 fig, axes = plt.subplots(2, 2, figsize=(16, 12)) 617 618 # Select a patient with longer stay for better visualization 619 long_stay_patients = patients[patients["length_of_stay"] >= 7][ 620 "patient_id" 621 ].head(3) 622 623 vital_signs = [ 624 "heart_rate", 625 "systolic_bp", 626 "temperature", 627 "oxygen_saturation", 628 ] 629 colors = ["red", "blue", "orange", "green"] 630 631 for i, vital in enumerate(vital_signs): 632 ax = axes[i // 2, i % 2] 633 634 for j, pid in enumerate(long_stay_patients): 635 patient_data = timeseries[ 636 timeseries["patient_id"] == pid 637 ].copy() 638 patient_data = patient_data.sort_values("timestamp") 639 clean_data = patient_data.dropna(subset=[vital]) 640 641 if len(clean_data) > 0: 642 ax.plot( 643 clean_data["day_of_stay"], 644 clean_data[vital], 645 marker="o", 646 label=pid, 647 alpha=0.7, 648 linewidth=2, 649 ) 650 651 ax.set_title( 652 f'{vital.replace("_", " ").title()} Over Time', 653 fontweight="bold", 654 ) 655 ax.set_xlabel("Day of Stay") 656 ax.set_ylabel(vital.replace("_", " ").title()) 657 ax.legend() 658 ax.grid(True, alpha=0.3) 659 660 plt.tight_layout() 661 plt.savefig( 662 "detailed_vitals_timeseries.png", dpi=300, bbox_inches="tight" 663 ) 664 print( 665 "Detailed vital signs plot saved as: detailed_vitals_timeseries.png" 666 ) 667 plt.show() 668 669 # Correlation Heatmap 670 plt.figure(figsize=(12, 10)) 671 numeric_cols = [ 672 "heart_rate", 673 "systolic_bp", 674 "diastolic_bp", 675 "temperature", 676 "respiratory_rate", 677 "oxygen_saturation", 678 "glucose", 679 "creatinine", 680 "hemoglobin", 681 "white_blood_cells", 682 "sodium", 683 "potassium", 684 ] 685 686 correlation_matrix = timeseries[numeric_cols].corr() 687 688 sns.heatmap( 689 correlation_matrix, 690 annot=True, 691 cmap="coolwarm", 692 center=0, 693 square=True, 694 fmt=".2f", 695 cbar_kws={"shrink": 0.8}, 696 ) 697 plt.title( 698 "Healthcare Parameters Correlation Matrix", 699 fontsize=16, 700 fontweight="bold", 701 ) 702 plt.tight_layout() 703 plt.savefig("correlation_heatmap.png", dpi=300, bbox_inches="tight") 704 print("Correlation heatmap saved as: correlation_heatmap.png") 705 plt.show()
8def generate_synthetic_returns( 9 n_days=252 * 10, # ~10 years of daily data 10 mu=0.0002, # Daily drift 11 kappa=0.05, # Vol mean reversion 12 theta=0.0001, # Long-run variance 13 sigma_v=0.01, # Vol of vol 14 rho=-0.7, # Leverage effect 15 lambda_jump=0.05, # Jump intensity (per day) 16 jump_size_dist="normal", # "normal", "log_normal", or "exponential" 17 sigma_jump=0.02, # Jump size (scale parameter) 18 noise_dist="normal", # "normal" or "student_t" 19 noise_scale=0.0005, # Microstructure noise scale 20 noise_df=3.0, # Degrees of freedom for Student’s t 21 regime_params=None, # Regime switching params 22 random_seed=None, # Reproducibility 23): 24 """ 25 Generates synthetic stock returns with: 26 - Stochastic volatility (Heston-like) 27 - Jumps (Poisson-driven, with configurable distribution) 28 - Regime switching (Markov) 29 - Leverage effect 30 - Fat tails (via jumps and noise) 31 - Microstructure noise (Gaussian or Student’s t) 32 33 Args: 34 jump_size_dist: Jump size distribution ("normal", "log_normal", "exponential"). 35 noise_dist: Microstructure noise distribution ("normal", "student_t"). 36 noise_df: Degrees of freedom for Student’s t noise (if used). 37 """ 38 if random_seed is not None: 39 np.random.seed(random_seed) 40 41 # Default regime switching (2 regimes: calm and turbulent) 42 if regime_params is None: 43 regime_params = { 44 "transition_matrix": np.array([[0.99, 0.01], [0.03, 0.97]]), 45 "theta_high_multiplier": 3.0, 46 "kappa_high_multiplier": 2.0, 47 } 48 49 # Initialize 50 v = np.zeros(n_days) # Variance 51 r = np.zeros(n_days) # Returns 52 regime = np.zeros(n_days, dtype=int) 53 v[0] = theta 54 55 # Simulate regime switching (Markov chain) 56 for t in range(1, n_days): 57 regime[t] = np.random.choice( 58 [0, 1], p=regime_params["transition_matrix"][regime[t - 1]] 59 ) 60 61 # Simulate returns and volatility 62 for t in range(1, n_days): 63 # Regime-dependent params 64 if regime[t] == 1: # High-vol regime 65 theta_t = theta * regime_params["theta_high_multiplier"] 66 kappa_t = kappa * regime_params["kappa_high_multiplier"] 67 else: 68 theta_t = theta 69 kappa_t = kappa 70 71 # Volatility process (Euler discretization) 72 eta = np.random.normal() 73 epsilon = rho * eta + np.sqrt(1 - rho**2) * np.random.normal() 74 dv = kappa_t * (theta_t - v[t - 1]) + sigma_v * np.sqrt(v[t - 1]) * eta 75 v[t] = max(v[t - 1] + dv, 1e-6) # Ensure positivity 76 77 # Jumps (with configurable distribution) 78 if np.random.poisson(lambda_jump) > 0: 79 if jump_size_dist == "normal": 80 J = np.random.normal(0, sigma_jump) 81 elif jump_size_dist == "log_normal": 82 J = ( 83 np.exp(np.random.normal(0, sigma_jump)) - 1 84 ) # Log-normal (positive skew) 85 elif jump_size_dist == "exponential": 86 J = np.random.exponential(sigma_jump) * np.sign( 87 np.random.uniform(-1, 1) 88 ) # Double-sided 89 else: 90 raise ValueError( 91 "Invalid jump_size_dist. Use 'normal', 'log_normal', or 'exponential'." 92 ) 93 else: 94 J = 0 95 96 # Returns 97 r[t] = mu + np.sqrt(v[t - 1]) * epsilon + J 98 99 # Microstructure noise (Gaussian or Student’s t) 100 if noise_dist == "normal": 101 r += np.random.normal(0, noise_scale, n_days) 102 elif noise_dist == "student_t": 103 r += ( 104 np.random.standard_t(noise_df, n_days) 105 * noise_scale 106 / np.sqrt(noise_df / (noise_df - 2)) 107 ) 108 else: 109 raise ValueError("Invalid noise_dist. Use 'normal' or 'student_t'.") 110 111 # Create DataFrame 112 df = pd.DataFrame( 113 {"returns": r, "volatility": np.sqrt(v), "regime": regime}, 114 index=pd.date_range(start="1970-01-01", periods=n_days), 115 ) 116 117 return df
Generates synthetic stock returns with:
- Stochastic volatility (Heston-like)
- Jumps (Poisson-driven, with configurable distribution)
- Regime switching (Markov)
- Leverage effect
- Fat tails (via jumps and noise)
- Microstructure noise (Gaussian or Student’s t)
Args: jump_size_dist: Jump size distribution ("normal", "log_normal", "exponential"). noise_dist: Microstructure noise distribution ("normal", "student_t"). noise_df: Degrees of freedom for Student’s t noise (if used).
120def plot_synthetic_returns( 121 df, title="Synthetic Stock Returns Analysis", figsize=(14, 10) 122): 123 """ 124 Plot synthetic stock returns with multiple panels: 125 - Returns over time 126 - Volatility (sqrt variance) 127 - Regime indicators 128 - Distribution vs. normal (QQ plot and histogram) 129 - Autocorrelation of returns and squared returns 130 131 Args: 132 df (pd.DataFrame): Output from generate_synthetic_returns 133 Must have: 'returns', 'volatility', 'regime' 134 title (str): Title for the plot 135 figsize (tuple): Figure size 136 """ 137 # Set style 138 sns.set_style("darkgrid") 139 plt.rcParams["figure.dpi"] = 100 140 141 fig = plt.figure(figsize=figsize) 142 gs = fig.add_gridspec(3, 2, height_ratios=[1, 1, 1], hspace=0.4, wspace=0.3) 143 144 # ------------------------- 145 # 1. Returns Over Time 146 # ------------------------- 147 ax1 = fig.add_subplot(gs[0, 0]) 148 ax1.plot(df.index, df["returns"], lw=0.8, color="tab:blue", alpha=0.9) 149 ax1.set_title("Daily Returns") 150 ax1.set_ylabel("Return") 151 ax1.axhline(0, color="gray", linestyle="--", lw=0.8) 152 153 # Highlight large jumps (optional) 154 threshold = df["returns"].std() * 3 155 jumps = df[np.abs(df["returns"]) > threshold] 156 if not jumps.empty: 157 ax1.scatter( 158 jumps.index, 159 jumps["returns"], 160 color="red", 161 s=10, 162 zorder=5, 163 label="Large Moves", 164 ) 165 ax1.legend() 166 167 # ------------------------- 168 # 2. Volatility 169 # ------------------------- 170 ax2 = fig.add_subplot(gs[0, 1]) 171 ax2.plot(df.index, df["volatility"], lw=1.2, color="tab:orange") 172 ax2.set_title("Volatility (Latent)") 173 ax2.set_ylabel("Volatility") 174 175 # Shade turbulent regimes 176 if "regime" in df.columns: 177 turbulent_days = df[df["regime"] == 1] 178 if not turbulent_days.empty: 179 ax2.fill_between( 180 turbulent_days.index, 181 df.loc[turbulent_days.index, "volatility"].min(), 182 df.loc[turbulent_days.index, "volatility"], 183 color="red", 184 alpha=0.2, 185 label="Turbulent Regime", 186 ) 187 ax2.legend() 188 189 # ------------------------- 190 # 3. Regime Plot 191 # ------------------------- 192 ax3 = fig.add_subplot(gs[1, 0]) 193 ax3.fill_between( 194 df.index, 195 0, 196 1, 197 where=(df["regime"] == 0), 198 interpolate=True, 199 color="green", 200 alpha=0.3, 201 label="Calm Regime", 202 ) 203 ax3.fill_between( 204 df.index, 205 0, 206 1, 207 where=(df["regime"] == 1), 208 interpolate=True, 209 color="red", 210 alpha=0.3, 211 label="Turbulent Regime", 212 ) 213 ax3.set_ylim(0, 1) 214 ax3.set_yticks([]) 215 ax3.set_title("Regime Switching (Hidden State)") 216 ax3.legend(loc="upper right") 217 218 # ------------------------- 219 # 4. Return Distribution (QQ + Histogram) 220 # ------------------------- 221 from scipy import stats 222 223 ax4 = fig.add_subplot(gs[1, 1]) 224 stats.probplot(df["returns"], dist="norm", plot=ax4) 225 ax4.set_title("QQ Plot (Fat Tails Detection)") 226 ax4.get_lines()[0].set_marker(".") 227 ax4.get_lines()[0].set_markersize(4) 228 ax4.get_lines()[1].set_color("red") 229 ax4.get_lines()[1].set_linewidth(1.5) 230 231 # ------------------------- 232 # 5. Histogram with Normal Fit 233 # ------------------------- 234 ax5 = fig.add_subplot(gs[2, 0]) 235 mu_norm, std_norm = stats.norm.fit(df["returns"]) 236 sns.histplot( 237 df["returns"], 238 bins=50, 239 kde=False, 240 stat="density", 241 ax=ax5, 242 alpha=0.7, 243 color="skyblue", 244 ) 245 xmin, xmax = ax5.get_xlim() 246 x = np.linspace(xmin, xmax, 100) 247 p = stats.norm.pdf(x, mu_norm, std_norm) 248 ax5.plot(x, p, "k--", linewidth=1.5, label="Normal Fit") 249 ax5.set_title("Return Distribution") 250 ax5.set_xlabel("Return") 251 ax5.legend() 252 253 # Annotate kurtosis and skew 254 kurt = df["returns"].kurtosis() 255 skew = df["returns"].skew() 256 ax5.text( 257 0.02, 258 0.9, 259 f"Kurtosis: {kurt:.2f}\nSkewness: {skew:.2f}", 260 transform=ax5.transAxes, 261 fontsize=10, 262 bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.8), 263 ) 264 265 # ------------------------- 266 # 6. Autocorrelation 267 # ------------------------- 268 ax6 = fig.add_subplot(gs[2, 1]) 269 from statsmodels.graphics.tsaplots import plot_acf 270 271 plot_acf( 272 df["returns"] ** 2, 273 ax=ax6, 274 lags=40, 275 title="ACF of Squared Returns", 276 alpha=0.05, 277 ) 278 ax6.set_xlabel("Lag (Days)") 279 ax6.set_ylabel("Autocorrelation") 280 281 # Add title at top 282 fig.suptitle(title, fontsize=14, fontweight="bold", y=0.98) 283 284 # Adjust layout 285 plt.tight_layout() 286 plt.show()
Plot synthetic stock returns with multiple panels:
- Returns over time
- Volatility (sqrt variance)
- Regime indicators
- Distribution vs. normal (QQ plot and histogram)
- Autocorrelation of returns and squared returns
Args: df (pd.DataFrame): Output from generate_synthetic_returns Must have: 'returns', 'volatility', 'regime' title (str): Title for the plot figsize (tuple): Figure size