stats-transformer 1.2.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.
- stats_transformer/__init__.py +40 -0
- stats_transformer/data/__init__.py +94 -0
- stats_transformer/data/macrodb_gdp_inflation.parquet +0 -0
- stats_transformer/data/panel_builder.py +232 -0
- stats_transformer/featurization/__init__.py +9 -0
- stats_transformer/featurization/base.py +69 -0
- stats_transformer/featurization/data_merger.py +128 -0
- stats_transformer/featurization/event_study.py +145 -0
- stats_transformer/featurization/feature_engineering.py +478 -0
- stats_transformer/models/__init__.py +7 -0
- stats_transformer/models/base.py +189 -0
- stats_transformer/models/discrete/logit.py +52 -0
- stats_transformer/models/regression/__init__.py +0 -0
- stats_transformer/models/regression/diagnostics.py +45 -0
- stats_transformer/models/regression/iv.py +60 -0
- stats_transformer/models/regression/panel.py +116 -0
- stats_transformer/models/regression/panel_diagnostics.py +126 -0
- stats_transformer/models/regression/regression.py +190 -0
- stats_transformer/models/regression/robust_ols.py +30 -0
- stats_transformer/models/regression/spec_runner.py +133 -0
- stats_transformer/models/timeseries/__init__.py +18 -0
- stats_transformer/models/timeseries/base_irf.py +73 -0
- stats_transformer/models/timeseries/diagnostics.py +86 -0
- stats_transformer/models/timeseries/granger.py +135 -0
- stats_transformer/models/timeseries/local_projections.py +86 -0
- stats_transformer/models/timeseries/svar.py +61 -0
- stats_transformer/models/timeseries/utilities.py +104 -0
- stats_transformer/models/timeseries/var.py +61 -0
- stats_transformer/models/timeseries/vecm.py +56 -0
- stats_transformer/models/unsupervised/__init__.py +0 -0
- stats_transformer/models/unsupervised/unsupervised.py +119 -0
- stats_transformer/pipeline.py +310 -0
- stats_transformer/py.typed +1 -0
- stats_transformer/reporting/__init__.py +3 -0
- stats_transformer/reporting/exporters.py +80 -0
- stats_transformer/utils/__init__.py +5 -0
- stats_transformer/utils/config.py +164 -0
- stats_transformer/utils/dict_country_converter.py +1574 -0
- stats_transformer/visualization/__init__.py +34 -0
- stats_transformer/visualization/base.py +75 -0
- stats_transformer/visualization/charts/__init__.py +16 -0
- stats_transformer/visualization/charts/bar.py +166 -0
- stats_transformer/visualization/charts/heatmap.py +60 -0
- stats_transformer/visualization/charts/scatter.py +102 -0
- stats_transformer/visualization/charts/time_series.py +175 -0
- stats_transformer/visualization/defaults/__init__.py +14 -0
- stats_transformer/visualization/defaults/colors.py +43 -0
- stats_transformer/visualization/defaults/labels.py +32 -0
- stats_transformer/visualization/defaults/styles/barchart.mplstyle +50 -0
- stats_transformer/visualization/defaults/styles/line_matplot.mplstyle +46 -0
- stats_transformer/visualization/defaults/styles/line_seaborn.mplstyle +46 -0
- stats_transformer/visualization/defaults/styles/scatter.mplstyle +45 -0
- stats_transformer/visualization/defaults/styles/scatter_text.mplstyle +45 -0
- stats_transformer/visualization/defaults/styles/timeseries.mplstyle +46 -0
- stats_transformer/visualization/defaults/styles/wordcloud.mplstyle +37 -0
- stats_transformer/visualization/eda/__init__.py +0 -0
- stats_transformer/visualization/eda/data_viz.py +195 -0
- stats_transformer/visualization/eda/eda.py +89 -0
- stats_transformer/visualization/formatters/__init__.py +10 -0
- stats_transformer/visualization/formatters/significance.py +46 -0
- stats_transformer/visualization/formatters/style.py +43 -0
- stats_transformer/visualization/models/__init__.py +0 -0
- stats_transformer/visualization/models/model_viz.py +81 -0
- stats_transformer/visualization/models/regression_viz.py +340 -0
- stats_transformer/visualization/tables/__init__.py +5 -0
- stats_transformer/visualization/tables/descriptive_stats.py +108 -0
- stats_transformer/visualization/tables/table_generator.py +114 -0
- stats_transformer/visualization/utils/__init__.py +0 -0
- stats_transformer/visualization/utils/viz_utils.py +44 -0
- stats_transformer-1.2.0.dist-info/METADATA +149 -0
- stats_transformer-1.2.0.dist-info/RECORD +73 -0
- stats_transformer-1.2.0.dist-info/WHEEL +4 -0
- stats_transformer-1.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
__version__ = importlib.metadata.version("stats-transformer")
|
|
5
|
+
except importlib.metadata.PackageNotFoundError:
|
|
6
|
+
__version__ = "unknown"
|
|
7
|
+
|
|
8
|
+
from .featurization import FeatureEngineer, EventStudyBuilder
|
|
9
|
+
from .models import RegressionModel
|
|
10
|
+
from .pipeline import Pipeline
|
|
11
|
+
from .visualization import (
|
|
12
|
+
BaseVisualizer, DataVisualizer, ModelVisualizer, RegressionVisualizer,
|
|
13
|
+
CoefficientBarChart, GroupedBarChart, StackedBarChart,
|
|
14
|
+
TimeSeriesPlot, IRFPlot, FacetedTimeSeries,
|
|
15
|
+
BinnedScatterPlot, ScatterWithRegression, CorrelationHeatmap
|
|
16
|
+
)
|
|
17
|
+
from .visualization.tables import TableGenerator
|
|
18
|
+
from .models.timeseries.local_projections import LocalProjectionsModel
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"FeatureEngineer",
|
|
22
|
+
"RegressionModel",
|
|
23
|
+
"Pipeline",
|
|
24
|
+
"BaseVisualizer",
|
|
25
|
+
"DataVisualizer",
|
|
26
|
+
"ModelVisualizer",
|
|
27
|
+
"RegressionVisualizer",
|
|
28
|
+
"CoefficientBarChart",
|
|
29
|
+
"GroupedBarChart",
|
|
30
|
+
"StackedBarChart",
|
|
31
|
+
"TimeSeriesPlot",
|
|
32
|
+
"IRFPlot",
|
|
33
|
+
"FacetedTimeSeries",
|
|
34
|
+
"BinnedScatterPlot",
|
|
35
|
+
"ScatterWithRegression",
|
|
36
|
+
"CorrelationHeatmap",
|
|
37
|
+
"TableGenerator",
|
|
38
|
+
"LocalProjectionsModel",
|
|
39
|
+
"EventStudyBuilder",
|
|
40
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Data utilities for the stats-transformer library."""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
EXAMPLE_DATASETS = {
|
|
8
|
+
"macrodb_gdp_inflation": {
|
|
9
|
+
"filename": "macrodb_gdp_inflation.parquet",
|
|
10
|
+
"description": "Macroeconomic dataset with GDP and inflation data for multiple countries",
|
|
11
|
+
"columns": {
|
|
12
|
+
"country": "Country code (ISO 3-letter)",
|
|
13
|
+
"date": "Year of observation",
|
|
14
|
+
"inflation": "Inflation rate (annual %)",
|
|
15
|
+
"gdp": "GDP per capita (current US$)",
|
|
16
|
+
},
|
|
17
|
+
"shape": (11490, 4),
|
|
18
|
+
"time_period": "Annual data",
|
|
19
|
+
"source": "Derived from World Bank and IMF data",
|
|
20
|
+
"notes": "Includes data for multiple countries across different time periods",
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_examples():
|
|
26
|
+
"""List packaged example datasets."""
|
|
27
|
+
return sorted(EXAMPLE_DATASETS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def describe_example(name="macrodb_gdp_inflation"):
|
|
31
|
+
"""Return metadata for a packaged example dataset."""
|
|
32
|
+
if name not in EXAMPLE_DATASETS:
|
|
33
|
+
available = ", ".join(list_examples())
|
|
34
|
+
raise ValueError(f"Unknown example dataset '{name}'. Available examples: {available}")
|
|
35
|
+
|
|
36
|
+
metadata = EXAMPLE_DATASETS[name].copy()
|
|
37
|
+
metadata.pop("filename", None)
|
|
38
|
+
metadata["name"] = name
|
|
39
|
+
return metadata
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_example(name="macrodb_gdp_inflation"):
|
|
43
|
+
"""Load a packaged example dataset."""
|
|
44
|
+
if name not in EXAMPLE_DATASETS:
|
|
45
|
+
available = ", ".join(list_examples())
|
|
46
|
+
raise ValueError(f"Unknown example dataset '{name}'. Available examples: {available}")
|
|
47
|
+
|
|
48
|
+
filename = EXAMPLE_DATASETS[name]["filename"]
|
|
49
|
+
try:
|
|
50
|
+
data_path = files("stats_transformer.data").joinpath(filename)
|
|
51
|
+
return pd.read_parquet(data_path)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
# Fallback for development or testing
|
|
54
|
+
import os
|
|
55
|
+
fallback_path = os.path.join(os.path.dirname(__file__), filename)
|
|
56
|
+
if os.path.exists(fallback_path):
|
|
57
|
+
return pd.read_parquet(fallback_path)
|
|
58
|
+
else:
|
|
59
|
+
raise FileNotFoundError(
|
|
60
|
+
f"Example data file '{filename}' not found. Make sure the package was installed correctly."
|
|
61
|
+
) from e
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_sample_data():
|
|
65
|
+
"""Load the default sample macroeconomic dataset.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
DataFrame with columns: country, date, inflation, gdp
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
>>> from stats_transformer.data import load_sample_data
|
|
72
|
+
>>> df = load_sample_data()
|
|
73
|
+
>>> print(df.head())
|
|
74
|
+
"""
|
|
75
|
+
return load_example("macrodb_gdp_inflation")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_sample_data_description():
|
|
79
|
+
"""Get description of the sample dataset.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Dictionary with dataset description
|
|
83
|
+
"""
|
|
84
|
+
return describe_example("macrodb_gdp_inflation")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"EXAMPLE_DATASETS",
|
|
89
|
+
"describe_example",
|
|
90
|
+
"get_sample_data_description",
|
|
91
|
+
"list_examples",
|
|
92
|
+
"load_example",
|
|
93
|
+
"load_sample_data",
|
|
94
|
+
]
|
|
Binary file
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
class PanelDataBuilder:
|
|
5
|
+
"""
|
|
6
|
+
Utility class to standardize the assembly of panel data,
|
|
7
|
+
especially handling mixed-frequency joins (e.g., merging monthly
|
|
8
|
+
macroeconomic data onto a daily panel) and broadcasting global
|
|
9
|
+
series (e.g., VIX, oil prices) onto country panels.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, entity_col='country', time_col='date'):
|
|
13
|
+
self.entity_col = entity_col
|
|
14
|
+
self.time_col = time_col
|
|
15
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
16
|
+
|
|
17
|
+
def join(self, panel, df, name, on_freq='M', cols=None, index_cols=None):
|
|
18
|
+
"""
|
|
19
|
+
Merge a lower-frequency (or matched-frequency) dataframe onto a panel.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
panel (pd.DataFrame): The base panel, indexed by [entity_col, time_col].
|
|
23
|
+
df (pd.DataFrame): The dataset to merge.
|
|
24
|
+
name (str): A descriptive name for the dataset being joined (for logging).
|
|
25
|
+
on_freq (str): The frequency to align on before joining.
|
|
26
|
+
E.g., 'M' means both the panel and df will have their dates
|
|
27
|
+
converted to monthly periods to perform the match.
|
|
28
|
+
cols (list): Specific columns from `df` to keep. If None, keeps all.
|
|
29
|
+
index_cols (list): Columns in `df` that represent the entity and time.
|
|
30
|
+
Defaults to [self.entity_col, self.time_col].
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
pd.DataFrame: The merged panel.
|
|
34
|
+
"""
|
|
35
|
+
if df is None or df.empty:
|
|
36
|
+
self.logger.warning(f"Dataset '{name}' is empty or None. Returning original panel.")
|
|
37
|
+
return panel
|
|
38
|
+
|
|
39
|
+
index_cols = index_cols or [self.entity_col, self.time_col]
|
|
40
|
+
|
|
41
|
+
# Prepare the incoming dataframe
|
|
42
|
+
df_clean = df.copy()
|
|
43
|
+
|
|
44
|
+
# Un-index if necessary to work with columns
|
|
45
|
+
if isinstance(df_clean.index, pd.MultiIndex):
|
|
46
|
+
df_clean = df_clean.reset_index()
|
|
47
|
+
elif df_clean.index.name in index_cols:
|
|
48
|
+
df_clean = df_clean.reset_index()
|
|
49
|
+
|
|
50
|
+
# Ensure index columns are present
|
|
51
|
+
missing_cols = [c for c in index_cols if c not in df_clean.columns]
|
|
52
|
+
if missing_cols:
|
|
53
|
+
self.logger.warning(f"Cannot join '{name}': missing index columns {missing_cols}")
|
|
54
|
+
return panel
|
|
55
|
+
|
|
56
|
+
# Keep only required columns
|
|
57
|
+
if cols:
|
|
58
|
+
keep_cols = index_cols + [c for c in cols if c in df_clean.columns and c not in index_cols]
|
|
59
|
+
df_clean = df_clean[keep_cols]
|
|
60
|
+
|
|
61
|
+
# Drop overlapping columns that already exist in the panel (except the join keys)
|
|
62
|
+
panel_cols = panel.columns.tolist() if not isinstance(panel.index, pd.MultiIndex) else panel.columns.tolist() + list(panel.index.names)
|
|
63
|
+
overlap = [c for c in df_clean.columns if c in panel_cols and c not in index_cols]
|
|
64
|
+
if overlap:
|
|
65
|
+
df_clean = df_clean.drop(columns=overlap)
|
|
66
|
+
|
|
67
|
+
if len(df_clean.columns) <= len(index_cols):
|
|
68
|
+
self.logger.info(f"No new columns to join for '{name}'. Returning original panel.")
|
|
69
|
+
return panel
|
|
70
|
+
|
|
71
|
+
# Align frequencies
|
|
72
|
+
panel_reset = panel.copy()
|
|
73
|
+
is_multiindex_panel = isinstance(panel_reset.index, pd.MultiIndex)
|
|
74
|
+
if is_multiindex_panel:
|
|
75
|
+
panel_reset = panel_reset.reset_index()
|
|
76
|
+
|
|
77
|
+
if self.time_col not in panel_reset.columns:
|
|
78
|
+
self.logger.warning(f"Panel is missing time column '{self.time_col}'.")
|
|
79
|
+
return panel
|
|
80
|
+
|
|
81
|
+
# Create a temporary period column for merging
|
|
82
|
+
temp_period_col = f'_period_merge_{on_freq}'
|
|
83
|
+
|
|
84
|
+
# Convert panel dates to period
|
|
85
|
+
panel_reset[self.time_col] = pd.to_datetime(panel_reset[self.time_col])
|
|
86
|
+
panel_reset[temp_period_col] = panel_reset[self.time_col].dt.to_period(on_freq)
|
|
87
|
+
|
|
88
|
+
# Convert incoming df dates to period
|
|
89
|
+
# Assume the second element in index_cols is the time column
|
|
90
|
+
df_time_col = index_cols[1]
|
|
91
|
+
df_entity_col = index_cols[0]
|
|
92
|
+
|
|
93
|
+
df_clean[df_time_col] = pd.to_datetime(df_clean[df_time_col])
|
|
94
|
+
df_clean[temp_period_col] = df_clean[df_time_col].dt.to_period(on_freq)
|
|
95
|
+
|
|
96
|
+
# Deduplicate incoming df based on entity and period (keep last)
|
|
97
|
+
df_clean = df_clean.drop_duplicates(subset=[df_entity_col, temp_period_col], keep='last')
|
|
98
|
+
|
|
99
|
+
# Prepare for merge
|
|
100
|
+
df_clean = df_clean.rename(columns={df_entity_col: self.entity_col})
|
|
101
|
+
df_clean = df_clean.drop(columns=[df_time_col])
|
|
102
|
+
|
|
103
|
+
# Perform the merge
|
|
104
|
+
merged = panel_reset.merge(
|
|
105
|
+
df_clean,
|
|
106
|
+
on=[self.entity_col, temp_period_col],
|
|
107
|
+
how='left'
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Cleanup
|
|
111
|
+
merged = merged.drop(columns=[temp_period_col])
|
|
112
|
+
|
|
113
|
+
if is_multiindex_panel:
|
|
114
|
+
merged = merged.set_index([self.entity_col, self.time_col])
|
|
115
|
+
|
|
116
|
+
# Log successful merge metrics
|
|
117
|
+
added_cols = [c for c in df_clean.columns if c not in [self.entity_col, temp_period_col]]
|
|
118
|
+
not_null_counts = [f"{c}={merged[c].notna().sum()}" for c in added_cols if c in merged.columns]
|
|
119
|
+
self.logger.info(f"Joined '{name}': {merged.shape}, non-null: {', '.join(not_null_counts)}")
|
|
120
|
+
|
|
121
|
+
return merged
|
|
122
|
+
|
|
123
|
+
def join_global(self, panel, df, name, time_col=None, cols=None):
|
|
124
|
+
"""
|
|
125
|
+
Broadcast a global time series (e.g., VIX, oil) onto a panel where
|
|
126
|
+
the incoming dataset has no entity column, just dates.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
panel (pd.DataFrame): The base panel.
|
|
130
|
+
df (pd.DataFrame): The global dataset to merge.
|
|
131
|
+
name (str): A descriptive name for logging.
|
|
132
|
+
time_col (str): The column in `df` representing time. If None, defaults to `self.time_col`.
|
|
133
|
+
cols (list): Specific columns from `df` to keep.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
pd.DataFrame: The merged panel.
|
|
137
|
+
"""
|
|
138
|
+
if df is None or df.empty:
|
|
139
|
+
self.logger.warning(f"Global dataset '{name}' is empty or None. Returning original panel.")
|
|
140
|
+
return panel
|
|
141
|
+
|
|
142
|
+
time_col = time_col or self.time_col
|
|
143
|
+
df_clean = df.copy()
|
|
144
|
+
|
|
145
|
+
if df_clean.index.name == time_col:
|
|
146
|
+
df_clean = df_clean.reset_index()
|
|
147
|
+
|
|
148
|
+
if time_col not in df_clean.columns:
|
|
149
|
+
self.logger.warning(f"Cannot join global '{name}': missing time column '{time_col}'")
|
|
150
|
+
return panel
|
|
151
|
+
|
|
152
|
+
if cols:
|
|
153
|
+
keep_cols = [time_col] + [c for c in cols if c in df_clean.columns and c != time_col]
|
|
154
|
+
df_clean = df_clean[keep_cols]
|
|
155
|
+
|
|
156
|
+
panel_cols = panel.columns.tolist() if not isinstance(panel.index, pd.MultiIndex) else panel.columns.tolist() + list(panel.index.names)
|
|
157
|
+
overlap = [c for c in df_clean.columns if c in panel_cols and c != time_col]
|
|
158
|
+
if overlap:
|
|
159
|
+
df_clean = df_clean.drop(columns=overlap)
|
|
160
|
+
|
|
161
|
+
if len(df_clean.columns) <= 1:
|
|
162
|
+
self.logger.info(f"No new columns to join for global '{name}'. Returning original panel.")
|
|
163
|
+
return panel
|
|
164
|
+
|
|
165
|
+
df_clean[time_col] = pd.to_datetime(df_clean[time_col])
|
|
166
|
+
df_clean = df_clean.drop_duplicates(subset=[time_col], keep='last')
|
|
167
|
+
|
|
168
|
+
panel_reset = panel.copy()
|
|
169
|
+
is_multiindex_panel = isinstance(panel_reset.index, pd.MultiIndex)
|
|
170
|
+
if is_multiindex_panel:
|
|
171
|
+
panel_reset = panel_reset.reset_index()
|
|
172
|
+
|
|
173
|
+
panel_reset[self.time_col] = pd.to_datetime(panel_reset[self.time_col])
|
|
174
|
+
|
|
175
|
+
# If the incoming time_col is named differently than the panel's time_col, align them
|
|
176
|
+
if time_col != self.time_col:
|
|
177
|
+
df_clean = df_clean.rename(columns={time_col: self.time_col})
|
|
178
|
+
|
|
179
|
+
# Left merge based on exact date match
|
|
180
|
+
# Note: If forward-filling (asof join) is desired instead of exact match,
|
|
181
|
+
# that would require a separate method or flag (pd.merge_asof).
|
|
182
|
+
merged = panel_reset.merge(
|
|
183
|
+
df_clean,
|
|
184
|
+
on=self.time_col,
|
|
185
|
+
how='left'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if is_multiindex_panel:
|
|
189
|
+
merged = merged.set_index([self.entity_col, self.time_col])
|
|
190
|
+
|
|
191
|
+
added_cols = [c for c in df_clean.columns if c != self.time_col]
|
|
192
|
+
not_null_counts = [f"{c}={merged[c].notna().sum()}" for c in added_cols if c in merged.columns]
|
|
193
|
+
self.logger.info(f"Joined global '{name}': {merged.shape}, non-null: {', '.join(not_null_counts)}")
|
|
194
|
+
|
|
195
|
+
return merged
|
|
196
|
+
|
|
197
|
+
def build(self, base_panel, join_specs):
|
|
198
|
+
"""
|
|
199
|
+
Fluent-style builder to chain multiple joins.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
base_panel (pd.DataFrame): The initial panel.
|
|
203
|
+
join_specs (list of dicts): E.g.
|
|
204
|
+
[
|
|
205
|
+
{"type": "entity", "df": cpi_df, "name": "CPI", "on_freq": "M", "cols": ["infl"]},
|
|
206
|
+
{"type": "global", "df": vix_df, "name": "VIX", "cols": ["vix_close"]}
|
|
207
|
+
]
|
|
208
|
+
Returns:
|
|
209
|
+
pd.DataFrame: Fully built panel.
|
|
210
|
+
"""
|
|
211
|
+
panel = base_panel.copy()
|
|
212
|
+
|
|
213
|
+
for spec in join_specs:
|
|
214
|
+
j_type = spec.get("type", "entity")
|
|
215
|
+
if j_type == "entity":
|
|
216
|
+
panel = self.join(
|
|
217
|
+
panel,
|
|
218
|
+
spec.get("df"),
|
|
219
|
+
spec.get("name"),
|
|
220
|
+
on_freq=spec.get("on_freq", "M"),
|
|
221
|
+
cols=spec.get("cols"),
|
|
222
|
+
index_cols=spec.get("index_cols")
|
|
223
|
+
)
|
|
224
|
+
elif j_type == "global":
|
|
225
|
+
panel = self.join_global(
|
|
226
|
+
panel,
|
|
227
|
+
spec.get("df"),
|
|
228
|
+
spec.get("name"),
|
|
229
|
+
time_col=spec.get("time_col"),
|
|
230
|
+
cols=spec.get("cols")
|
|
231
|
+
)
|
|
232
|
+
return panel
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from .feature_engineering import FeatureEngineer
|
|
3
|
+
from .data_merger import DataMerger
|
|
4
|
+
from .event_study import EventStudyBuilder
|
|
5
|
+
|
|
6
|
+
__all__ = ["FeatureEngineer", "DataMerger", "EventStudyBuilder", "suppress_logging"]
|
|
7
|
+
|
|
8
|
+
def suppress_logging():
|
|
9
|
+
logging.getLogger("stats_transformer.featurization.feature_engineering").setLevel(logging.CRITICAL + 1)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base feature engineering class.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseFeatureEngineer(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Abstract base class for feature engineering pipelines.
|
|
13
|
+
|
|
14
|
+
This class defines the interface for feature engineering transformations
|
|
15
|
+
that can be applied to time series data with entity-based grouping.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, params_path: Optional[str] = None, **kwargs):
|
|
19
|
+
"""
|
|
20
|
+
Initialize the base feature engineer.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
params_path: Optional path to YAML configuration file
|
|
24
|
+
**kwargs: Additional configuration parameters
|
|
25
|
+
"""
|
|
26
|
+
self.params = {}
|
|
27
|
+
if params_path:
|
|
28
|
+
self.params = self._load_params(params_path)
|
|
29
|
+
|
|
30
|
+
# Apply any kwargs to override params
|
|
31
|
+
for key, value in kwargs.items():
|
|
32
|
+
if key in self.params:
|
|
33
|
+
self.params[key] = value
|
|
34
|
+
|
|
35
|
+
# Initialize logger
|
|
36
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
37
|
+
|
|
38
|
+
def _load_params(self, params_path: str) -> Dict:
|
|
39
|
+
"""
|
|
40
|
+
Load parameters from YAML file.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
params_path: Path to YAML file
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Dictionary of parameters
|
|
47
|
+
"""
|
|
48
|
+
import yaml
|
|
49
|
+
try:
|
|
50
|
+
with open(params_path, "r") as f:
|
|
51
|
+
return yaml.safe_load(f)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
self.logger.error(f"Error loading parameters from {params_path}: {e}")
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def fit(self, df):
|
|
58
|
+
"""Fit the feature engineer to data."""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def transform(self, df):
|
|
63
|
+
"""Transform the data."""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def fit_transform(self, df):
|
|
68
|
+
"""Fit and transform the data."""
|
|
69
|
+
pass
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
ISO2_TO_ISO3 = {}
|
|
7
|
+
|
|
8
|
+
class DataMerger:
|
|
9
|
+
|
|
10
|
+
def __init__(self, params_path="params.yaml"):
|
|
11
|
+
self.params_path = params_path
|
|
12
|
+
self.logger = logging.getLogger(__name__)
|
|
13
|
+
self._setup_logging()
|
|
14
|
+
self.config = self._load_config()
|
|
15
|
+
|
|
16
|
+
def _setup_logging(self):
|
|
17
|
+
self.logger.setLevel(logging.INFO)
|
|
18
|
+
if not self.logger.handlers:
|
|
19
|
+
handler = logging.StreamHandler()
|
|
20
|
+
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
|
|
21
|
+
self.logger.addHandler(handler)
|
|
22
|
+
|
|
23
|
+
def _load_config(self):
|
|
24
|
+
if not self.params_path or not os.path.exists(self.params_path):
|
|
25
|
+
return {}
|
|
26
|
+
with open(self.params_path, "r") as f:
|
|
27
|
+
return yaml.safe_load(f)
|
|
28
|
+
|
|
29
|
+
def load_dataset(self, path):
|
|
30
|
+
if path.endswith(".parquet"):
|
|
31
|
+
return pd.read_parquet(path)
|
|
32
|
+
return pd.read_csv(path)
|
|
33
|
+
|
|
34
|
+
def _log_merge_diagnostics(self, df_left, df_right, merged_df, entity_col):
|
|
35
|
+
rows_left, rows_right, rows_merged = len(df_left), len(df_right), len(merged_df)
|
|
36
|
+
if entity_col in df_left.columns:
|
|
37
|
+
countries_left = set(df_left[entity_col].unique())
|
|
38
|
+
else:
|
|
39
|
+
countries_left = set(df_left.index.get_level_values(entity_col).unique())
|
|
40
|
+
if entity_col in df_right.columns:
|
|
41
|
+
countries_right = set(df_right[entity_col].unique())
|
|
42
|
+
else:
|
|
43
|
+
countries_right = set(df_right.index.get_level_values(entity_col).unique())
|
|
44
|
+
if entity_col in merged_df.columns:
|
|
45
|
+
countries_merged = set(merged_df[entity_col].unique())
|
|
46
|
+
elif merged_df.index.names and entity_col in merged_df.index.names:
|
|
47
|
+
countries_merged = set(merged_df.index.get_level_values(entity_col).unique())
|
|
48
|
+
else:
|
|
49
|
+
countries_merged = set()
|
|
50
|
+
dropped = countries_left - countries_merged
|
|
51
|
+
self.logger.info(f"Left: {rows_left:>7} rows, {len(countries_left):>4} countries")
|
|
52
|
+
self.logger.info(f"Right: {rows_right:>7} rows, {len(countries_right):>4} countries")
|
|
53
|
+
self.logger.info(f"Merged: {rows_merged:>7} rows, {len(countries_merged):>4} countries")
|
|
54
|
+
if dropped:
|
|
55
|
+
self.logger.warning(f"Dropped countries from left: {sorted(dropped)}")
|
|
56
|
+
|
|
57
|
+
def merge(self, df_left, df_right, on, how="inner"):
|
|
58
|
+
merged_df = pd.merge(df_left, df_right, on=on, how=how)
|
|
59
|
+
entity_col = on[0] if on else "country"
|
|
60
|
+
self._log_merge_diagnostics(df_left, df_right, merged_df, entity_col)
|
|
61
|
+
return merged_df
|
|
62
|
+
|
|
63
|
+
def merge_on_index(self, df_left, df_right, how="inner"):
|
|
64
|
+
merged_df = pd.merge(df_left, df_right, how=how, left_index=True, right_index=True)
|
|
65
|
+
entity_col = "country"
|
|
66
|
+
if "country" in df_left.index.names:
|
|
67
|
+
self._log_merge_diagnostics(df_left, df_right, merged_df, entity_col)
|
|
68
|
+
return merged_df
|
|
69
|
+
|
|
70
|
+
def merge_asof(self, df_left, df_right, left_on, right_on, by="country", direction="backward"):
|
|
71
|
+
df_left_sorted = df_left.sort_values(by=left_on)
|
|
72
|
+
df_right_sorted = df_right.sort_values(by=right_on)
|
|
73
|
+
merged_df = pd.merge_asof(df_left_sorted, df_right_sorted, left_on=left_on, right_on=right_on, by=by, direction=direction)
|
|
74
|
+
self._log_merge_diagnostics(df_left, df_right, merged_df, by)
|
|
75
|
+
return merged_df
|
|
76
|
+
|
|
77
|
+
def standardize_entity(self, df, entity_col):
|
|
78
|
+
df = df.rename(columns={entity_col: "country"})
|
|
79
|
+
df["country"] = df["country"].map(lambda x: ISO2_TO_ISO3.get(x, x))
|
|
80
|
+
return df
|
|
81
|
+
|
|
82
|
+
def run(self):
|
|
83
|
+
data_config = self.config.get("data", {})
|
|
84
|
+
datasets_config = data_config.get("datasets", [])
|
|
85
|
+
|
|
86
|
+
if not datasets_config:
|
|
87
|
+
self.logger.warning("No datasets found in config to merge")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
loaded_datasets = []
|
|
91
|
+
for ds_cfg in datasets_config:
|
|
92
|
+
name = ds_cfg.get("name")
|
|
93
|
+
self.logger.info(f"Loading dataset: {name}")
|
|
94
|
+
df = self.load_dataset(ds_cfg.get("path"))
|
|
95
|
+
entity_col = ds_cfg.get("entity_column", "country")
|
|
96
|
+
date_col = ds_cfg.get("date_column", "date")
|
|
97
|
+
if entity_col != "country":
|
|
98
|
+
df = self.standardize_entity(df, entity_col)
|
|
99
|
+
if date_col != "date":
|
|
100
|
+
df = df.rename(columns={date_col: "date"})
|
|
101
|
+
loaded_datasets.append(df)
|
|
102
|
+
|
|
103
|
+
if not loaded_datasets:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
merge_config = data_config.get("merge", {})
|
|
107
|
+
merge_on = merge_config.get("on", ["country", "date"])
|
|
108
|
+
merge_how = merge_config.get("how", "outer")
|
|
109
|
+
output_path = merge_config.get("output_path", "data/pipeline/resampled_merged.parquet")
|
|
110
|
+
|
|
111
|
+
merged_df = loaded_datasets[0]
|
|
112
|
+
for i, next_df in enumerate(loaded_datasets[1:], start=1):
|
|
113
|
+
self.logger.info(f"Merging dataset {i + 1} of {len(loaded_datasets)}")
|
|
114
|
+
merged_df = self.merge(merged_df, next_df, on=merge_on, how=merge_how)
|
|
115
|
+
|
|
116
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
117
|
+
merged_df.to_parquet(output_path)
|
|
118
|
+
self.logger.info(f"Merged data saved to {output_path} with shape {merged_df.shape}")
|
|
119
|
+
return merged_df
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
import argparse
|
|
123
|
+
parser = argparse.ArgumentParser()
|
|
124
|
+
parser.add_argument("--config", default="params.yaml")
|
|
125
|
+
args = parser.parse_args()
|
|
126
|
+
dm = DataMerger(params_path=args.config)
|
|
127
|
+
dm.run()
|
|
128
|
+
|