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.
Files changed (73) hide show
  1. stats_transformer/__init__.py +40 -0
  2. stats_transformer/data/__init__.py +94 -0
  3. stats_transformer/data/macrodb_gdp_inflation.parquet +0 -0
  4. stats_transformer/data/panel_builder.py +232 -0
  5. stats_transformer/featurization/__init__.py +9 -0
  6. stats_transformer/featurization/base.py +69 -0
  7. stats_transformer/featurization/data_merger.py +128 -0
  8. stats_transformer/featurization/event_study.py +145 -0
  9. stats_transformer/featurization/feature_engineering.py +478 -0
  10. stats_transformer/models/__init__.py +7 -0
  11. stats_transformer/models/base.py +189 -0
  12. stats_transformer/models/discrete/logit.py +52 -0
  13. stats_transformer/models/regression/__init__.py +0 -0
  14. stats_transformer/models/regression/diagnostics.py +45 -0
  15. stats_transformer/models/regression/iv.py +60 -0
  16. stats_transformer/models/regression/panel.py +116 -0
  17. stats_transformer/models/regression/panel_diagnostics.py +126 -0
  18. stats_transformer/models/regression/regression.py +190 -0
  19. stats_transformer/models/regression/robust_ols.py +30 -0
  20. stats_transformer/models/regression/spec_runner.py +133 -0
  21. stats_transformer/models/timeseries/__init__.py +18 -0
  22. stats_transformer/models/timeseries/base_irf.py +73 -0
  23. stats_transformer/models/timeseries/diagnostics.py +86 -0
  24. stats_transformer/models/timeseries/granger.py +135 -0
  25. stats_transformer/models/timeseries/local_projections.py +86 -0
  26. stats_transformer/models/timeseries/svar.py +61 -0
  27. stats_transformer/models/timeseries/utilities.py +104 -0
  28. stats_transformer/models/timeseries/var.py +61 -0
  29. stats_transformer/models/timeseries/vecm.py +56 -0
  30. stats_transformer/models/unsupervised/__init__.py +0 -0
  31. stats_transformer/models/unsupervised/unsupervised.py +119 -0
  32. stats_transformer/pipeline.py +310 -0
  33. stats_transformer/py.typed +1 -0
  34. stats_transformer/reporting/__init__.py +3 -0
  35. stats_transformer/reporting/exporters.py +80 -0
  36. stats_transformer/utils/__init__.py +5 -0
  37. stats_transformer/utils/config.py +164 -0
  38. stats_transformer/utils/dict_country_converter.py +1574 -0
  39. stats_transformer/visualization/__init__.py +34 -0
  40. stats_transformer/visualization/base.py +75 -0
  41. stats_transformer/visualization/charts/__init__.py +16 -0
  42. stats_transformer/visualization/charts/bar.py +166 -0
  43. stats_transformer/visualization/charts/heatmap.py +60 -0
  44. stats_transformer/visualization/charts/scatter.py +102 -0
  45. stats_transformer/visualization/charts/time_series.py +175 -0
  46. stats_transformer/visualization/defaults/__init__.py +14 -0
  47. stats_transformer/visualization/defaults/colors.py +43 -0
  48. stats_transformer/visualization/defaults/labels.py +32 -0
  49. stats_transformer/visualization/defaults/styles/barchart.mplstyle +50 -0
  50. stats_transformer/visualization/defaults/styles/line_matplot.mplstyle +46 -0
  51. stats_transformer/visualization/defaults/styles/line_seaborn.mplstyle +46 -0
  52. stats_transformer/visualization/defaults/styles/scatter.mplstyle +45 -0
  53. stats_transformer/visualization/defaults/styles/scatter_text.mplstyle +45 -0
  54. stats_transformer/visualization/defaults/styles/timeseries.mplstyle +46 -0
  55. stats_transformer/visualization/defaults/styles/wordcloud.mplstyle +37 -0
  56. stats_transformer/visualization/eda/__init__.py +0 -0
  57. stats_transformer/visualization/eda/data_viz.py +195 -0
  58. stats_transformer/visualization/eda/eda.py +89 -0
  59. stats_transformer/visualization/formatters/__init__.py +10 -0
  60. stats_transformer/visualization/formatters/significance.py +46 -0
  61. stats_transformer/visualization/formatters/style.py +43 -0
  62. stats_transformer/visualization/models/__init__.py +0 -0
  63. stats_transformer/visualization/models/model_viz.py +81 -0
  64. stats_transformer/visualization/models/regression_viz.py +340 -0
  65. stats_transformer/visualization/tables/__init__.py +5 -0
  66. stats_transformer/visualization/tables/descriptive_stats.py +108 -0
  67. stats_transformer/visualization/tables/table_generator.py +114 -0
  68. stats_transformer/visualization/utils/__init__.py +0 -0
  69. stats_transformer/visualization/utils/viz_utils.py +44 -0
  70. stats_transformer-1.2.0.dist-info/METADATA +149 -0
  71. stats_transformer-1.2.0.dist-info/RECORD +73 -0
  72. stats_transformer-1.2.0.dist-info/WHEEL +4 -0
  73. 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
+ ]
@@ -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
+