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.
- panelbox/__init__.py +41 -0
- panelbox/__version__.py +13 -1
- panelbox/core/formula_parser.py +9 -2
- panelbox/core/panel_data.py +1 -1
- panelbox/datasets/__init__.py +39 -0
- panelbox/datasets/load.py +334 -0
- panelbox/gmm/difference_gmm.py +63 -15
- panelbox/gmm/estimator.py +46 -5
- panelbox/gmm/system_gmm.py +136 -21
- panelbox/models/static/__init__.py +4 -0
- panelbox/models/static/between.py +434 -0
- panelbox/models/static/first_difference.py +494 -0
- panelbox/models/static/fixed_effects.py +80 -11
- panelbox/models/static/pooled_ols.py +80 -11
- panelbox/models/static/random_effects.py +52 -10
- panelbox/standard_errors/__init__.py +119 -0
- panelbox/standard_errors/clustered.py +386 -0
- panelbox/standard_errors/comparison.py +528 -0
- panelbox/standard_errors/driscoll_kraay.py +386 -0
- panelbox/standard_errors/newey_west.py +324 -0
- panelbox/standard_errors/pcse.py +358 -0
- panelbox/standard_errors/robust.py +324 -0
- panelbox/standard_errors/utils.py +390 -0
- panelbox/validation/__init__.py +6 -0
- panelbox/validation/robustness/__init__.py +51 -0
- panelbox/validation/robustness/bootstrap.py +933 -0
- panelbox/validation/robustness/checks.py +143 -0
- panelbox/validation/robustness/cross_validation.py +538 -0
- panelbox/validation/robustness/influence.py +364 -0
- panelbox/validation/robustness/jackknife.py +457 -0
- panelbox/validation/robustness/outliers.py +529 -0
- panelbox/validation/robustness/sensitivity.py +809 -0
- {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/METADATA +32 -3
- {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/RECORD +38 -21
- {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/WHEEL +1 -1
- {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/entry_points.txt +0 -0
- {panelbox-0.2.0.dist-info → panelbox-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {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()
|