panelbox 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. panelbox/__init__.py +41 -0
  2. panelbox/__version__.py +13 -1
  3. panelbox/core/formula_parser.py +9 -2
  4. panelbox/core/panel_data.py +1 -1
  5. panelbox/datasets/__init__.py +39 -0
  6. panelbox/datasets/load.py +334 -0
  7. panelbox/gmm/difference_gmm.py +63 -15
  8. panelbox/gmm/estimator.py +46 -5
  9. panelbox/gmm/system_gmm.py +136 -21
  10. panelbox/models/static/__init__.py +4 -0
  11. panelbox/models/static/between.py +434 -0
  12. panelbox/models/static/first_difference.py +494 -0
  13. panelbox/models/static/fixed_effects.py +80 -11
  14. panelbox/models/static/pooled_ols.py +80 -11
  15. panelbox/models/static/random_effects.py +52 -10
  16. panelbox/standard_errors/__init__.py +119 -0
  17. panelbox/standard_errors/clustered.py +386 -0
  18. panelbox/standard_errors/comparison.py +528 -0
  19. panelbox/standard_errors/driscoll_kraay.py +386 -0
  20. panelbox/standard_errors/newey_west.py +324 -0
  21. panelbox/standard_errors/pcse.py +358 -0
  22. panelbox/standard_errors/robust.py +324 -0
  23. panelbox/standard_errors/utils.py +390 -0
  24. panelbox/validation/__init__.py +6 -0
  25. panelbox/validation/robustness/__init__.py +51 -0
  26. panelbox/validation/robustness/bootstrap.py +933 -0
  27. panelbox/validation/robustness/checks.py +143 -0
  28. panelbox/validation/robustness/cross_validation.py +538 -0
  29. panelbox/validation/robustness/influence.py +364 -0
  30. panelbox/validation/robustness/jackknife.py +457 -0
  31. panelbox/validation/robustness/outliers.py +529 -0
  32. panelbox/validation/robustness/sensitivity.py +809 -0
  33. {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/METADATA +32 -3
  34. {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/RECORD +38 -21
  35. {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/WHEEL +1 -1
  36. {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/entry_points.txt +0 -0
  37. {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/licenses/LICENSE +0 -0
  38. {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,143 @@
1
+ """
2
+ Robustness checks for panel data models.
3
+
4
+ Provides tools to test robustness of results across different
5
+ specifications, samples, and estimators.
6
+ """
7
+
8
+ from typing import List, Optional, Dict, Any
9
+ import pandas as pd
10
+ import numpy as np
11
+
12
+ from panelbox.core.results import PanelResults
13
+
14
+
15
+ class RobustnessChecker:
16
+ """
17
+ Robustness checking framework for panel data models.
18
+
19
+ Parameters
20
+ ----------
21
+ results : PanelResults
22
+ Base model results
23
+ verbose : bool
24
+ Print progress
25
+
26
+ Examples
27
+ --------
28
+ >>> checker = pb.RobustnessChecker(results)
29
+ >>> alt_specs = checker.check_alternative_specs([
30
+ ... "y ~ x1",
31
+ ... "y ~ x1 + x2",
32
+ ... "y ~ x1 + x2 + x3"
33
+ ... ])
34
+ >>> print(checker.generate_robustness_table(alt_specs))
35
+ """
36
+
37
+ def __init__(self, results: PanelResults, verbose: bool = True):
38
+ self.results = results
39
+ self.verbose = verbose
40
+ self.model = results._model
41
+ self.data = self.model.data.data
42
+ self.entity_col = self.model.data.entity_col
43
+ self.time_col = self.model.data.time_col
44
+
45
+ def check_alternative_specs(
46
+ self,
47
+ formulas: List[str],
48
+ model_type: Optional[str] = None
49
+ ) -> List[PanelResults]:
50
+ """
51
+ Test alternative specifications.
52
+
53
+ Parameters
54
+ ----------
55
+ formulas : list of str
56
+ Alternative model formulas
57
+ model_type : str, optional
58
+ Model type to use. If None, uses same as base model
59
+
60
+ Returns
61
+ -------
62
+ results_list : list of PanelResults
63
+ Results for each specification
64
+ """
65
+ results_list = []
66
+
67
+ if model_type is None:
68
+ model_class = type(self.model)
69
+ else:
70
+ # Import appropriate model class
71
+ from panelbox import FixedEffects, PooledOLS, RandomEffects
72
+ model_map = {
73
+ 'fe': FixedEffects,
74
+ 'pooled': PooledOLS,
75
+ 're': RandomEffects
76
+ }
77
+ model_class = model_map.get(model_type, type(self.model))
78
+
79
+ for formula in formulas:
80
+ if self.verbose:
81
+ print(f"Estimating: {formula}")
82
+
83
+ try:
84
+ model = model_class(formula, self.data, self.entity_col, self.time_col)
85
+ result = model.fit(cov_type=self.results.cov_type)
86
+ results_list.append(result)
87
+ except Exception as e:
88
+ if self.verbose:
89
+ print(f" Failed: {e}")
90
+ results_list.append(None)
91
+
92
+ return results_list
93
+
94
+ def generate_robustness_table(
95
+ self,
96
+ results_list: List[PanelResults],
97
+ parameters: Optional[List[str]] = None
98
+ ) -> pd.DataFrame:
99
+ """
100
+ Generate robustness table comparing specifications.
101
+
102
+ Parameters
103
+ ----------
104
+ results_list : list of PanelResults
105
+ Results to compare
106
+ parameters : list of str, optional
107
+ Parameters to include. If None, uses all common parameters
108
+
109
+ Returns
110
+ -------
111
+ table : pd.DataFrame
112
+ Comparison table
113
+ """
114
+ if parameters is None:
115
+ # Find common parameters across all models
116
+ param_sets = [set(r.params.index) for r in results_list if r is not None]
117
+ parameters = sorted(set.intersection(*param_sets)) if param_sets else []
118
+
119
+ data = []
120
+ for i, result in enumerate(results_list, 1):
121
+ if result is None:
122
+ continue
123
+
124
+ for param in parameters:
125
+ if param in result.params.index:
126
+ data.append({
127
+ 'Specification': f'({i})',
128
+ 'Parameter': param,
129
+ 'Coefficient': result.params[param],
130
+ 'SE': result.std_errors[param],
131
+ 'p-value': result.pvalues[param]
132
+ })
133
+
134
+ df = pd.DataFrame(data)
135
+
136
+ # Pivot to wide format
137
+ if len(df) > 0:
138
+ table = df.pivot(index='Parameter', columns='Specification',
139
+ values=['Coefficient', 'SE', 'p-value'])
140
+ else:
141
+ table = pd.DataFrame()
142
+
143
+ return table
@@ -0,0 +1,538 @@
1
+ """
2
+ Time-series cross-validation for panel data models.
3
+
4
+ This module implements cross-validation methods that respect the temporal
5
+ structure of panel data, essential for evaluating out-of-sample predictive
6
+ performance.
7
+
8
+ References
9
+ ----------
10
+ Bergmeir, C., & Benítez, J. M. (2012). On the use of cross-validation for
11
+ time series predictor evaluation. Information Sciences, 191, 192-213.
12
+ Tashman, L. J. (2000). Out-of-sample tests of forecasting accuracy: an
13
+ analysis and review. International Journal of Forecasting, 16(4), 437-450.
14
+ """
15
+
16
+ from typing import Optional, Union, Literal, Dict, Any, Tuple, List
17
+ import warnings
18
+ import numpy as np
19
+ import pandas as pd
20
+ from dataclasses import dataclass
21
+
22
+ from panelbox.core.results import PanelResults
23
+
24
+
25
+ @dataclass
26
+ class CVResults:
27
+ """
28
+ Container for cross-validation results.
29
+
30
+ Attributes
31
+ ----------
32
+ predictions : pd.DataFrame
33
+ Out-of-sample predictions with columns ['actual', 'predicted', 'fold']
34
+ metrics : Dict[str, float]
35
+ Dictionary of evaluation metrics (MSE, RMSE, MAE, R²)
36
+ fold_metrics : pd.DataFrame
37
+ Per-fold metrics
38
+ method : str
39
+ CV method used ('expanding' or 'rolling')
40
+ n_folds : int
41
+ Number of CV folds
42
+ window_size : Optional[int]
43
+ Window size for rolling CV
44
+ """
45
+ predictions: pd.DataFrame
46
+ metrics: Dict[str, float]
47
+ fold_metrics: pd.DataFrame
48
+ method: str
49
+ n_folds: int
50
+ window_size: Optional[int] = None
51
+
52
+ def summary(self) -> str:
53
+ """Generate summary of CV results."""
54
+ lines = []
55
+ lines.append("Cross-Validation Results")
56
+ lines.append("=" * 70)
57
+ lines.append(f"Method: {self.method.capitalize()} Window")
58
+ lines.append(f"Number of folds: {self.n_folds}")
59
+ if self.window_size is not None:
60
+ lines.append(f"Window size: {self.window_size}")
61
+ lines.append("")
62
+
63
+ lines.append("Overall Metrics:")
64
+ lines.append("-" * 70)
65
+ lines.append(f" MSE: {self.metrics['mse']:>12.6f}")
66
+ lines.append(f" RMSE: {self.metrics['rmse']:>12.6f}")
67
+ lines.append(f" MAE: {self.metrics['mae']:>12.6f}")
68
+ lines.append(f" R² (OOS): {self.metrics['r2_oos']:>12.6f}")
69
+ lines.append("")
70
+
71
+ lines.append("Per-Fold Metrics:")
72
+ lines.append("-" * 70)
73
+ lines.append(self.fold_metrics.to_string())
74
+
75
+ return "\n".join(lines)
76
+
77
+
78
+ class TimeSeriesCV:
79
+ """
80
+ Time-series cross-validation for panel data models.
81
+
82
+ This class implements cross-validation methods that respect the temporal
83
+ ordering of panel data. Two main methods are supported:
84
+
85
+ 1. Expanding window: Train on periods [1, t], predict period t+1
86
+ 2. Rolling window: Train on periods [t-w, t], predict period t+1
87
+
88
+ Parameters
89
+ ----------
90
+ results : PanelResults
91
+ Fitted model results containing the model and data
92
+ method : {'expanding', 'rolling'}, default='expanding'
93
+ Cross-validation method:
94
+
95
+ - 'expanding': Expanding window (cumulative training)
96
+ - 'rolling': Rolling window (fixed-size training)
97
+ window_size : int, optional
98
+ Window size for rolling CV. Required if method='rolling'.
99
+ Recommended: at least 0.5 * total_periods
100
+ min_train_periods : int, default=3
101
+ Minimum number of periods for training set
102
+ verbose : bool, default=True
103
+ Whether to print progress information
104
+
105
+ Attributes
106
+ ----------
107
+ cv_results_ : CVResults
108
+ Cross-validation results after calling cross_validate()
109
+ predictions_ : pd.DataFrame
110
+ Out-of-sample predictions
111
+ metrics_ : Dict[str, float]
112
+ Overall evaluation metrics
113
+
114
+ Examples
115
+ --------
116
+ >>> import panelbox as pb
117
+ >>> import pandas as pd
118
+ >>>
119
+ >>> # Fit model
120
+ >>> data = pd.read_csv('panel_data.csv')
121
+ >>> fe = pb.FixedEffects("y ~ x1 + x2", data, "entity_id", "time")
122
+ >>> results = fe.fit()
123
+ >>>
124
+ >>> # Expanding window CV
125
+ >>> cv = pb.TimeSeriesCV(results, method='expanding')
126
+ >>> cv_results = cv.cross_validate()
127
+ >>> print(f"Out-of-sample R²: {cv_results.metrics['r2_oos']:.3f}")
128
+ >>>
129
+ >>> # Rolling window CV
130
+ >>> cv_roll = pb.TimeSeriesCV(results, method='rolling', window_size=5)
131
+ >>> cv_results_roll = cv_roll.cross_validate()
132
+ >>>
133
+ >>> # Plot predictions
134
+ >>> cv.plot_predictions()
135
+
136
+ Notes
137
+ -----
138
+ - Cross-validation is performed at the time-period level
139
+ - All entities are included in each fold
140
+ - Models are re-estimated for each fold
141
+ - This can be computationally expensive for large datasets
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ results: PanelResults,
147
+ method: Literal['expanding', 'rolling'] = 'expanding',
148
+ window_size: Optional[int] = None,
149
+ min_train_periods: int = 3,
150
+ verbose: bool = True
151
+ ):
152
+ self.results = results
153
+ self.method = method
154
+ self.window_size = window_size
155
+ self.min_train_periods = min_train_periods
156
+ self.verbose = verbose
157
+
158
+ # Validate inputs
159
+ self._validate_inputs()
160
+
161
+ # Extract model information
162
+ self.model = results._model
163
+ self.formula = results.formula
164
+ self.entity_col = self.model.data.entity_col
165
+ self.time_col = self.model.data.time_col
166
+
167
+ # Get original data
168
+ self.data = self.model.data.data # Full dataset
169
+
170
+ # Get unique time periods
171
+ self.time_periods = sorted(self.data[self.time_col].unique())
172
+ self.n_periods = len(self.time_periods)
173
+
174
+ # Results storage
175
+ self.cv_results_: Optional[CVResults] = None
176
+ self.predictions_: Optional[pd.DataFrame] = None
177
+ self.metrics_: Optional[Dict[str, float]] = None
178
+
179
+ def _validate_inputs(self):
180
+ """Validate input parameters."""
181
+ if self.method not in ['expanding', 'rolling']:
182
+ raise ValueError(f"method must be 'expanding' or 'rolling', got '{self.method}'")
183
+
184
+ if self.method == 'rolling' and self.window_size is None:
185
+ raise ValueError("window_size must be specified for rolling window CV")
186
+
187
+ if self.min_train_periods < 2:
188
+ raise ValueError("min_train_periods must be at least 2")
189
+
190
+ def cross_validate(self) -> CVResults:
191
+ """
192
+ Perform time-series cross-validation.
193
+
194
+ Returns
195
+ -------
196
+ cv_results : CVResults
197
+ Cross-validation results containing predictions and metrics
198
+
199
+ Notes
200
+ -----
201
+ The cross-validation procedure:
202
+
203
+ 1. For each time period t (starting from min_train_periods):
204
+ - Define training window based on method
205
+ - Fit model on training data
206
+ - Predict on period t
207
+ - Store predictions and compute metrics
208
+
209
+ 2. Aggregate results across all folds
210
+
211
+ The number of folds depends on the method and parameters:
212
+ - Expanding: n_periods - min_train_periods
213
+ - Rolling: n_periods - min_train_periods
214
+ """
215
+ if self.verbose:
216
+ print(f"Starting {self.method} window cross-validation...")
217
+ print(f"Total periods: {self.n_periods}")
218
+ print(f"Min train periods: {self.min_train_periods}")
219
+ if self.method == 'rolling':
220
+ print(f"Window size: {self.window_size}")
221
+
222
+ # Storage for predictions and metrics
223
+ all_predictions = []
224
+ fold_metrics_list = []
225
+
226
+ # Determine CV folds
227
+ folds = self._get_cv_folds()
228
+ n_folds = len(folds)
229
+
230
+ if self.verbose:
231
+ print(f"Number of CV folds: {n_folds}")
232
+ print("")
233
+
234
+ # Perform CV
235
+ for fold_idx, (train_periods, test_period) in enumerate(folds, 1):
236
+ if self.verbose:
237
+ print(f"Fold {fold_idx}/{n_folds}: Training on {len(train_periods)} periods, "
238
+ f"testing on period {test_period}")
239
+
240
+ # Split data
241
+ train_data = self.data[self.data[self.time_col].isin(train_periods)]
242
+ test_data = self.data[self.data[self.time_col] == test_period]
243
+
244
+ # Fit model on training data
245
+ try:
246
+ model_class = type(self.model)
247
+ train_model = model_class(
248
+ self.formula,
249
+ train_data,
250
+ self.entity_col,
251
+ self.time_col
252
+ )
253
+ train_results = train_model.fit(cov_type=self.results.cov_type)
254
+
255
+ # Predict on test data
256
+ predictions = self._predict_fold(train_results, test_data)
257
+
258
+ # Store predictions
259
+ predictions['fold'] = fold_idx
260
+ predictions['test_period'] = test_period
261
+ all_predictions.append(predictions)
262
+
263
+ # Compute fold metrics
264
+ fold_metrics = self._compute_metrics(
265
+ predictions['actual'].values,
266
+ predictions['predicted'].values
267
+ )
268
+ fold_metrics['fold'] = fold_idx
269
+ fold_metrics['test_period'] = test_period
270
+ fold_metrics_list.append(fold_metrics)
271
+
272
+ if self.verbose:
273
+ print(f" Fold {fold_idx} R²: {fold_metrics['r2_oos']:.4f}, "
274
+ f"RMSE: {fold_metrics['rmse']:.4f}")
275
+
276
+ except Exception as e:
277
+ warnings.warn(f"Fold {fold_idx} failed: {str(e)}")
278
+ continue
279
+
280
+ # Combine all predictions
281
+ if not all_predictions:
282
+ raise RuntimeError("All CV folds failed")
283
+
284
+ predictions_df = pd.concat(all_predictions, ignore_index=True)
285
+ fold_metrics_df = pd.DataFrame(fold_metrics_list)
286
+
287
+ # Compute overall metrics
288
+ overall_metrics = self._compute_metrics(
289
+ predictions_df['actual'].values,
290
+ predictions_df['predicted'].values
291
+ )
292
+
293
+ # Create results object
294
+ self.cv_results_ = CVResults(
295
+ predictions=predictions_df,
296
+ metrics=overall_metrics,
297
+ fold_metrics=fold_metrics_df,
298
+ method=self.method,
299
+ n_folds=n_folds,
300
+ window_size=self.window_size
301
+ )
302
+
303
+ self.predictions_ = predictions_df
304
+ self.metrics_ = overall_metrics
305
+
306
+ if self.verbose:
307
+ print("\nCross-Validation Complete!")
308
+ print(f"Overall Out-of-Sample R²: {overall_metrics['r2_oos']:.4f}")
309
+ print(f"Overall RMSE: {overall_metrics['rmse']:.4f}")
310
+
311
+ return self.cv_results_
312
+
313
+ def _get_cv_folds(self) -> List[Tuple[List, Any]]:
314
+ """
315
+ Generate CV folds based on method.
316
+
317
+ Returns
318
+ -------
319
+ folds : List[Tuple[List, Any]]
320
+ List of (train_periods, test_period) tuples
321
+ """
322
+ folds = []
323
+
324
+ if self.method == 'expanding':
325
+ # Expanding window: train on [1, t], test on t+1
326
+ for t in range(self.min_train_periods, self.n_periods):
327
+ train_periods = self.time_periods[:t]
328
+ test_period = self.time_periods[t]
329
+ folds.append((train_periods, test_period))
330
+
331
+ elif self.method == 'rolling':
332
+ # Rolling window: train on [t-w, t], test on t+1
333
+ for t in range(self.min_train_periods, self.n_periods):
334
+ # Determine window start
335
+ window_start = max(0, t - self.window_size)
336
+ train_periods = self.time_periods[window_start:t]
337
+ test_period = self.time_periods[t]
338
+
339
+ # Ensure minimum training size
340
+ if len(train_periods) >= self.min_train_periods:
341
+ folds.append((train_periods, test_period))
342
+
343
+ return folds
344
+
345
+ def _predict_fold(self, train_results: PanelResults, test_data: pd.DataFrame) -> pd.DataFrame:
346
+ """
347
+ Generate predictions for a CV fold.
348
+
349
+ Parameters
350
+ ----------
351
+ train_results : PanelResults
352
+ Results from model trained on training data
353
+ test_data : pd.DataFrame
354
+ Test data for prediction
355
+
356
+ Returns
357
+ -------
358
+ predictions : pd.DataFrame
359
+ DataFrame with columns ['actual', 'predicted', 'entity', 'time']
360
+ """
361
+ # Extract dependent variable name from formula
362
+ dependent_var = train_results.formula.split('~')[0].strip()
363
+
364
+ # Get X matrix for test data using patsy
365
+ from patsy import dmatrix
366
+ formula_rhs = train_results.formula.split('~')[1].strip()
367
+
368
+ # Build design matrix
369
+ X_test = dmatrix(formula_rhs, test_data, return_type='dataframe')
370
+
371
+ # Get parameter estimates
372
+ params = train_results.params
373
+
374
+ # Match columns between training and test
375
+ # Get only the columns that are in params
376
+ param_names = params.index.tolist()
377
+
378
+ # Ensure X_test has same columns as training parameters
379
+ X_test_aligned = X_test[param_names] if all(col in X_test.columns for col in param_names) else X_test
380
+
381
+ # Make predictions
382
+ predictions_raw = X_test_aligned.values @ params.values
383
+
384
+ # Create results dataframe
385
+ predictions_df = pd.DataFrame({
386
+ 'actual': test_data[dependent_var].values,
387
+ 'predicted': predictions_raw,
388
+ 'entity': test_data[self.entity_col].values,
389
+ 'time': test_data[self.time_col].values
390
+ })
391
+
392
+ return predictions_df
393
+
394
+ def _compute_metrics(self, y_true: np.ndarray, y_pred: np.ndarray) -> Dict[str, float]:
395
+ """
396
+ Compute evaluation metrics.
397
+
398
+ Parameters
399
+ ----------
400
+ y_true : np.ndarray
401
+ Actual values
402
+ y_pred : np.ndarray
403
+ Predicted values
404
+
405
+ Returns
406
+ -------
407
+ metrics : Dict[str, float]
408
+ Dictionary of metrics
409
+ """
410
+ # Residuals
411
+ residuals = y_true - y_pred
412
+
413
+ # MSE and RMSE
414
+ mse = np.mean(residuals ** 2)
415
+ rmse = np.sqrt(mse)
416
+
417
+ # MAE
418
+ mae = np.mean(np.abs(residuals))
419
+
420
+ # R² (out-of-sample)
421
+ ss_res = np.sum(residuals ** 2)
422
+ ss_tot = np.sum((y_true - np.mean(y_true)) ** 2)
423
+ r2_oos = 1 - (ss_res / ss_tot)
424
+
425
+ return {
426
+ 'mse': mse,
427
+ 'rmse': rmse,
428
+ 'mae': mae,
429
+ 'r2_oos': r2_oos
430
+ }
431
+
432
+ def plot_predictions(
433
+ self,
434
+ entity: Optional[Union[int, str]] = None,
435
+ save_path: Optional[str] = None
436
+ ):
437
+ """
438
+ Plot actual vs predicted values.
439
+
440
+ Parameters
441
+ ----------
442
+ entity : int or str, optional
443
+ Specific entity to plot. If None, plots all entities.
444
+ save_path : str, optional
445
+ Path to save the plot. If None, displays the plot.
446
+
447
+ Raises
448
+ ------
449
+ RuntimeError
450
+ If cross_validate() has not been called yet
451
+ ImportError
452
+ If matplotlib is not installed
453
+ """
454
+ if self.cv_results_ is None:
455
+ raise RuntimeError("Must call cross_validate() before plotting")
456
+
457
+ try:
458
+ import matplotlib.pyplot as plt
459
+ except ImportError:
460
+ raise ImportError("matplotlib is required for plotting. "
461
+ "Install with: pip install matplotlib")
462
+
463
+ predictions = self.cv_results_.predictions
464
+
465
+ # Filter by entity if specified
466
+ if entity is not None:
467
+ predictions = predictions[predictions['entity'] == entity]
468
+ if len(predictions) == 0:
469
+ raise ValueError(f"No predictions found for entity {entity}")
470
+
471
+ # Create plot
472
+ fig, axes = plt.subplots(2, 1, figsize=(12, 8))
473
+
474
+ # Plot 1: Actual vs Predicted
475
+ ax1 = axes[0]
476
+ ax1.scatter(predictions['actual'], predictions['predicted'],
477
+ alpha=0.5, s=30)
478
+
479
+ # Add diagonal line
480
+ min_val = min(predictions['actual'].min(), predictions['predicted'].min())
481
+ max_val = max(predictions['actual'].max(), predictions['predicted'].max())
482
+ ax1.plot([min_val, max_val], [min_val, max_val],
483
+ 'r--', lw=2, label='Perfect prediction')
484
+
485
+ ax1.set_xlabel('Actual Values')
486
+ ax1.set_ylabel('Predicted Values')
487
+ ax1.set_title(f'Out-of-Sample Predictions: Actual vs Predicted\n'
488
+ f'R² = {self.cv_results_.metrics["r2_oos"]:.4f}')
489
+ ax1.legend()
490
+ ax1.grid(True, alpha=0.3)
491
+
492
+ # Plot 2: Time series of predictions
493
+ ax2 = axes[1]
494
+
495
+ # Group by time period and compute means
496
+ time_means = predictions.groupby('time').agg({
497
+ 'actual': 'mean',
498
+ 'predicted': 'mean'
499
+ }).reset_index()
500
+
501
+ ax2.plot(time_means['time'], time_means['actual'],
502
+ 'o-', label='Actual', linewidth=2, markersize=6)
503
+ ax2.plot(time_means['time'], time_means['predicted'],
504
+ 's--', label='Predicted', linewidth=2, markersize=6)
505
+
506
+ ax2.set_xlabel('Time Period')
507
+ ax2.set_ylabel('Mean Value')
508
+ ax2.set_title(f'{self.method.capitalize()} Window CV: Mean Predictions Over Time')
509
+ ax2.legend()
510
+ ax2.grid(True, alpha=0.3)
511
+
512
+ plt.tight_layout()
513
+
514
+ if save_path:
515
+ plt.savefig(save_path, dpi=300, bbox_inches='tight')
516
+ if self.verbose:
517
+ print(f"Plot saved to {save_path}")
518
+ else:
519
+ plt.show()
520
+
521
+ def summary(self) -> str:
522
+ """
523
+ Generate summary of cross-validation results.
524
+
525
+ Returns
526
+ -------
527
+ summary_str : str
528
+ Formatted summary string
529
+
530
+ Raises
531
+ ------
532
+ RuntimeError
533
+ If cross_validate() has not been called yet
534
+ """
535
+ if self.cv_results_ is None:
536
+ raise RuntimeError("Must call cross_validate() before summary()")
537
+
538
+ return self.cv_results_.summary()