open-econs 0.3.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.
open_econs/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from ._version import __version__
2
+ from .models.linear.ols import ols
3
+ from .models.decomposition.oaxaca import oaxaca
4
+ from .core.context import Context
5
+
6
+ reg = ols
7
+
8
+ __all__ = ["ols", "reg", "oaxaca", "Context", "__version__"]
File without changes
@@ -0,0 +1,47 @@
1
+ from collections.abc import Sequence
2
+
3
+
4
+ def missing_column_error(name: str, available: Sequence[str]) -> ValueError:
5
+ available_str = ", ".join(sorted(available))
6
+ return ValueError(
7
+ f"Column '{name}' not found in DataFrame. "
8
+ f"Available columns: [{available_str}]"
9
+ )
10
+
11
+
12
+ def cluster_column_error(name: str, available: Sequence[str]) -> ValueError:
13
+ available_str = ", ".join(sorted(available))
14
+ return ValueError(
15
+ f"Cluster column '{name}' not found in DataFrame. "
16
+ f"Available columns: [{available_str}]"
17
+ )
18
+
19
+
20
+ def non_binary_error(name: str, values: Sequence) -> ValueError:
21
+ found = ", ".join(repr(v) for v in values)
22
+ return ValueError(
23
+ f"Column '{name}' must be binary (exactly 2 unique non-null values). "
24
+ f"Found {len(values)} unique values: [{found}]"
25
+ )
26
+
27
+
28
+ def empty_data_error(original_n: int, dropped: int = 0, cols: list[str] | None = None) -> ValueError:
29
+ msg = f"DataFrame has 0 rows after dropping missing values. Started with {original_n} rows"
30
+ if dropped:
31
+ cols_str = ", ".join(cols or ["unknown"])
32
+ msg += f", {dropped} had missing values in: [{cols_str}]"
33
+ msg += "."
34
+ return ValueError(msg)
35
+
36
+
37
+ def singular_matrix_error() -> RuntimeError:
38
+ return RuntimeError(
39
+ "Singular design matrix — check for collinear or duplicated variables in your formula."
40
+ )
41
+
42
+
43
+ def rows_dropped_warning(dropped: int, total: int, cols: list[str]) -> str:
44
+ cols_str = ", ".join(sorted(cols))
45
+ return (
46
+ f"Dropped {dropped} of {total} rows due to missing values in: [{cols_str}]"
47
+ )
@@ -0,0 +1,47 @@
1
+ import warnings
2
+
3
+ import pandas as pd
4
+ from formulaic import Formula
5
+
6
+ from open_econs._internal.errors import (
7
+ empty_data_error,
8
+ missing_column_error,
9
+ rows_dropped_warning,
10
+ )
11
+
12
+
13
+ def parse_formula(
14
+ formula: str,
15
+ data: pd.DataFrame,
16
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
17
+ _validate_columns_in_data(formula, data)
18
+
19
+ original_n = len(data)
20
+ result = Formula(formula).get_model_matrix(data, na_action="drop")
21
+ yy: pd.DataFrame = result.lhs
22
+ XX: pd.DataFrame = result.rhs
23
+
24
+ dropped = original_n - len(yy)
25
+ if dropped > 0:
26
+ cols = _find_missing_cols(formula, data)
27
+ warnings.warn(rows_dropped_warning(dropped, original_n, cols))
28
+
29
+ if len(yy) == 0:
30
+ cols = _find_missing_cols(formula, data)
31
+ raise empty_data_error(original_n, dropped, cols)
32
+
33
+ return yy, XX
34
+
35
+
36
+ def _validate_columns_in_data(formula: str, data: pd.DataFrame) -> None:
37
+ f = Formula(formula)
38
+ vars_needed: set[str] = {str(v) for v in f.required_variables}
39
+ missing = sorted(v for v in vars_needed if v not in data.columns)
40
+ if missing:
41
+ raise missing_column_error(missing[0], data.columns.tolist())
42
+
43
+
44
+ def _find_missing_cols(formula: str, data: pd.DataFrame) -> list[str]:
45
+ f = Formula(formula)
46
+ vars_needed: set[str] = {str(v) for v in f.required_variables}
47
+ return sorted(v for v in vars_needed if data[v].isna().any())
open_econs/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
File without changes
@@ -0,0 +1,83 @@
1
+ from abc import ABC, abstractmethod
2
+ from datetime import datetime
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+ class BaseModel(ABC):
7
+ formula: str = ""
8
+ data_shape: tuple[int, int] = (0, 0)
9
+ cov_type: str = ""
10
+ call: dict[str, Any] = {}
11
+ timestamp: datetime = datetime.fromtimestamp(0)
12
+ package_version: str = ""
13
+
14
+ _frozen: bool = False
15
+
16
+ def __setattr__(self, name: str, value: Any) -> None:
17
+ if getattr(self, "_frozen", False):
18
+ raise AttributeError(
19
+ f"{type(self).__name__} results are immutable after fit(). "
20
+ f"Cannot set '{name}'. Create a new estimate instead."
21
+ )
22
+ super().__setattr__(name, value)
23
+
24
+ def _freeze(self) -> None:
25
+ object.__setattr__(self, "_frozen", True)
26
+
27
+ # ── abstract interface ──────────────────────────────────────────
28
+
29
+ @abstractmethod
30
+ def tidy(self) -> pd.DataFrame:
31
+ """Coefficient or effect table, one row per term (R-broom style)."""
32
+
33
+ @abstractmethod
34
+ def summary(self) -> str:
35
+ """Pretty-printed terminal string."""
36
+
37
+ # ── optional stubs (loud) ───────────────────────────────────────
38
+
39
+ def predict(self, newdata: pd.DataFrame | None = None) -> pd.Series:
40
+ raise NotImplementedError(
41
+ f"{type(self).__name__} does not support predict(). "
42
+ "predict() is only defined for LinearModel-family results."
43
+ )
44
+
45
+ def plot(self) -> None:
46
+ raise NotImplementedError(
47
+ "plot() is not implemented in this version of open-econs. "
48
+ "Planned for a future release; matplotlib will be an optional "
49
+ "extra, not a hard dependency. In the meantime, call .tidy() "
50
+ "and plot the DataFrame yourself."
51
+ )
52
+
53
+ def export(self, path: str) -> None:
54
+ import json
55
+
56
+ if path.endswith(".json"):
57
+ data = self.to_dict()
58
+ with open(path, "w") as fh:
59
+ json.dump(data, fh, indent=2, default=str)
60
+ elif path.endswith(".csv"):
61
+ self.tidy().to_csv(path, index=False)
62
+ else:
63
+ raise NotImplementedError(
64
+ f"export() to '{path.rsplit('.', 1)[-1]}' is not supported in "
65
+ "this version. Only .json and .csv export are available. "
66
+ "Export with a '.json' or '.csv' extension, or call .tidy() "
67
+ "and save the DataFrame yourself."
68
+ )
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ d = self.tidy().to_dict(orient="records")
72
+ return {
73
+ "formula": self.formula,
74
+ "nobs": self.data_shape[0],
75
+ "cov_type": self.cov_type,
76
+ "call": {k: (str(v) if not isinstance(v, (str, int, float, bool, type(None))) else v) for k, v in self.call.items()},
77
+ "timestamp": str(self.timestamp),
78
+ "package_version": self.package_version,
79
+ "results": d,
80
+ }
81
+
82
+ def __repr__(self) -> str:
83
+ return self.summary()
@@ -0,0 +1,54 @@
1
+ from typing import Any
2
+
3
+ import pandas as pd
4
+
5
+
6
+ class Context:
7
+ """A context that remembers a dataset for repeated estimation.
8
+
9
+ Once created, every estimator method forwards to the top-level
10
+ function with ``data=self.data`` injected automatically.
11
+
12
+ Parameters
13
+ ----------
14
+ data : pd.DataFrame
15
+ The working dataset.
16
+
17
+ Examples
18
+ --------
19
+ >>> import open_econs as oe
20
+ >>> ctx = oe.Context(df)
21
+ >>> r1 = ctx.ols("income ~ education + age")
22
+ >>> r2 = ctx.oaxaca("income ~ education + age + female", by="female")
23
+ """
24
+
25
+ def __init__(self, data: pd.DataFrame) -> None:
26
+ self._data = data
27
+
28
+ def ols(
29
+ self,
30
+ formula: str,
31
+ cluster: str | None = None,
32
+ cov_type: str = "HC1",
33
+ ) -> Any:
34
+ from open_econs.models.linear.ols import ols as _ols
35
+
36
+ return _ols(formula=formula, data=self._data, cluster=cluster, cov_type=cov_type)
37
+
38
+ def oaxaca(
39
+ self,
40
+ formula: str,
41
+ by: str,
42
+ decomposition_type: str = "two-fold",
43
+ ) -> Any:
44
+ from open_econs.models.decomposition.oaxaca import oaxaca as _oaxaca
45
+
46
+ return _oaxaca(
47
+ formula=formula,
48
+ data=self._data,
49
+ by=by,
50
+ decomposition_type=decomposition_type,
51
+ )
52
+
53
+ def __repr__(self) -> str:
54
+ return f"Context(data: {self._data.shape[0]} rows, {self._data.shape[1]} cols)"
@@ -0,0 +1,341 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ from scipy import stats as _stats
7
+ from statsmodels.stats.diagnostic import het_breuschpagan as _het_breuschpagan
8
+ from statsmodels.stats.stattools import durbin_watson as _durbin_watson
9
+
10
+ from open_econs._version import __version__
11
+ from open_econs.core.base import BaseModel
12
+
13
+
14
+ def _ramsey_reset(
15
+ fitted: np.ndarray, resid: np.ndarray, power: int = 3,
16
+ ) -> tuple[float, float]:
17
+ n = len(resid)
18
+ y_hat_sq = np.column_stack([fitted ** p for p in range(2, power + 1)])
19
+ X_aug = np.column_stack([np.ones(n), fitted, y_hat_sq])
20
+ beta = np.linalg.lstsq(X_aug, fitted + resid, rcond=None)[0]
21
+ pred_aug = X_aug @ beta
22
+ resid_aug = (fitted + resid) - pred_aug
23
+ ssr_r = np.sum(resid ** 2)
24
+ ssr_u = np.sum(resid_aug ** 2)
25
+ k = 1
26
+ m = power - 1
27
+ df_num = m
28
+ df_den = n - k - m - 1
29
+ if df_den <= 0 or ssr_u <= 0:
30
+ return (float("nan"), float("nan"))
31
+ f_stat = ((ssr_r - ssr_u) / m) / (ssr_u / df_den)
32
+ p_val = 1.0 - _stats.f.cdf(f_stat, df_num, df_den)
33
+ return (float(f_stat), float(p_val))
34
+
35
+
36
+ class OLSResult(BaseModel):
37
+ def __init__(
38
+ self,
39
+ *,
40
+ formula: str,
41
+ rhs_formula: str,
42
+ nobs: int,
43
+ df_resid: int,
44
+ df_model: int,
45
+ cov_type: str,
46
+ coefficients: pd.Series,
47
+ std_errors: pd.Series,
48
+ t_stats: pd.Series,
49
+ p_values: pd.Series,
50
+ conf_int: pd.DataFrame,
51
+ r_squared: float,
52
+ adj_r_squared: float,
53
+ f_statistic: float,
54
+ f_p_value: float,
55
+ rsd: float,
56
+ llf: float,
57
+ aic: float,
58
+ bic: float,
59
+ fitted: pd.Series,
60
+ residuals: pd.Series,
61
+ call: dict[str, Any],
62
+ model_spec: Any = None,
63
+ condition_number: float = 0.0,
64
+ _X: pd.DataFrame | None = None,
65
+ _sm_fit: Any = None,
66
+ ) -> None:
67
+ self.formula = formula
68
+ self.rhs_formula = rhs_formula
69
+ self.data_shape = (nobs, coefficients.shape[0])
70
+ self.cov_type = cov_type
71
+ self.call = call
72
+ self.timestamp = datetime.now()
73
+ self.package_version = __version__
74
+
75
+ self.nobs = nobs
76
+ self.df_resid = df_resid
77
+ self.df_model = df_model
78
+ self.coefficients = coefficients
79
+ self.std_errors = std_errors
80
+ self.t_stats = t_stats
81
+ self.p_values = p_values
82
+ self.conf_int = conf_int
83
+ self.r_squared = r_squared
84
+ self.adj_r_squared = adj_r_squared
85
+ self.f_statistic = f_statistic
86
+ self.f_p_value = f_p_value
87
+ self.rsd = rsd
88
+ self.llf = llf
89
+ self.aic = aic
90
+ self.bic = bic
91
+ self.fitted_values = fitted if fitted is not None else pd.Series(dtype=float)
92
+ self.residuals = residuals
93
+ self._model_spec = model_spec
94
+ self.condition_number = condition_number
95
+ self._X = _X
96
+ self._sm_fit = _sm_fit
97
+
98
+ self._freeze()
99
+
100
+ def tidy(self) -> pd.DataFrame:
101
+ df = pd.DataFrame({
102
+ "Variable": self.coefficients.index,
103
+ "Coef": self.coefficients.values,
104
+ "Std Err": self.std_errors.values,
105
+ "t": self.t_stats.values,
106
+ "P>|t|": self.p_values.values,
107
+ "0.025": self.conf_int["lower"].values,
108
+ "0.975": self.conf_int["upper"].values,
109
+ })
110
+ df.index.name = None
111
+ return df
112
+
113
+ def summary(self) -> str:
114
+ llf_str = f"{self.llf:.3f}" if self.llf is not None and not self._isnan(self.llf) else "N/A"
115
+ aic_str = f"{self.aic:.2f}" if self.aic is not None and not self._isnan(self.aic) else "N/A"
116
+ bic_str = f"{self.bic:.2f}" if self.bic is not None and not self._isnan(self.bic) else "N/A"
117
+ header = (
118
+ f" OLS Regression Results \n"
119
+ f"======================================================================\n"
120
+ f"Dep. Variable: {self.formula.split('~')[0].strip()}\n"
121
+ f"No. Observations: {self.nobs}\n"
122
+ f"Df Residuals: {self.df_resid}\n"
123
+ f"Df Model: {self.df_model}\n"
124
+ f"Covariance Type: {self.cov_type}\n"
125
+ f"R-squared: {self.r_squared:.6f}\n"
126
+ f"Adj. R-squared: {self.adj_r_squared:.6f}\n"
127
+ f"Condition No.: {self._fmt(self.condition_number, '.2e')}\n"
128
+ f"F-statistic: {self._fmt(self.f_statistic, '.4f')}\n"
129
+ f"Prob (F-statistic): {self._fmt(self.f_p_value, '.6e')}\n"
130
+ f"Log-Likelihood: {llf_str}\n"
131
+ f"AIC: {aic_str}\n"
132
+ f"BIC: {bic_str}\n"
133
+ f"======================================================================\n"
134
+ )
135
+ tbl = self.tidy().to_string(index=False)
136
+ diag = self.diagnostics()
137
+ diag_lines = []
138
+ try:
139
+ jb_s, jb_p = diag.get("jarque_bera", (float("nan"), float("nan")))
140
+ diag_lines.append(f"Jarque-Bera (chi2={jb_s:.3f}, p={jb_p:.4f})")
141
+ except Exception:
142
+ diag_lines.append("Jarque-Bera: N/A")
143
+ try:
144
+ bp_s, bp_p = diag.get("breusch_pagan", (float("nan"), float("nan")))
145
+ diag_lines.append(f"Breusch-Pagan (LM={bp_s:.3f}, p={bp_p:.4f})")
146
+ except Exception:
147
+ diag_lines.append("Breusch-Pagan: N/A")
148
+ try:
149
+ dw = diag.get("durbin_watson", (float("nan"),))[0]
150
+ diag_lines.append(f"Durbin-Watson: {dw:.4f}")
151
+ except Exception:
152
+ diag_lines.append("Durbin-Watson: N/A")
153
+ try:
154
+ rs_s, rs_p = diag.get("ramsey_reset", (float("nan"), float("nan")))
155
+ diag_lines.append(f"Ramsey RESET (F={rs_s:.3f}, p={rs_p:.4f})")
156
+ except Exception:
157
+ diag_lines.append("Ramsey RESET: N/A")
158
+ diag_str = "\n".join(diag_lines)
159
+ return (
160
+ header + tbl +
161
+ "\n======================================================================\n"
162
+ f"Diagnostics:\n{diag_str}\n"
163
+ "======================================================================\n"
164
+ )
165
+
166
+ def _isnan(self, v: float) -> bool:
167
+ try:
168
+ return np.isnan(v)
169
+ except (TypeError, ValueError):
170
+ return True
171
+
172
+ def _fmt(self, v: float, spec: str) -> str:
173
+ if self._isnan(v):
174
+ return "N/A"
175
+ return f"{v:{spec}}"
176
+
177
+ def diagnostics(self) -> dict[str, tuple[float, float]]:
178
+ res = self.residuals.values.ravel()
179
+ fitted = self.fitted_values.values.ravel()
180
+ results: dict[str, tuple[float, float]] = {}
181
+ from scipy.stats import jarque_bera as _jb
182
+ jb_stat, jb_p = _jb(res)
183
+ results["jarque_bera"] = (float(jb_stat), float(jb_p))
184
+ dw = _durbin_watson(res)
185
+ results["durbin_watson"] = (float(dw), float("nan"))
186
+ if self._X is not None and len(self._X) == len(res):
187
+ X_vals = self._X.values.astype(float)
188
+ bp_stat, bp_p, _, _ = _het_breuschpagan(res, X_vals)
189
+ results["breusch_pagan"] = (float(bp_stat), float(bp_p))
190
+ reset_stat, reset_p = _ramsey_reset(fitted, res, power=3)
191
+ results["ramsey_reset"] = (float(reset_stat), float(reset_p))
192
+ return results
193
+
194
+ def wald_test(self, r_matrix: Any) -> Any:
195
+ if self._sm_fit is None:
196
+ raise RuntimeError(
197
+ "No fitted statsmodels result stored. "
198
+ "wald_test() is only available when ols() is used directly."
199
+ )
200
+ return self._sm_fit.wald_test(r_matrix)
201
+
202
+ def f_test(self, r_matrix: Any) -> Any:
203
+ if self._sm_fit is None:
204
+ raise RuntimeError(
205
+ "No fitted statsmodels result stored. "
206
+ "f_test() is only available when ols() is used directly."
207
+ )
208
+ return self._sm_fit.f_test(r_matrix)
209
+
210
+ def predict(self, newdata: pd.DataFrame | None = None) -> pd.Series:
211
+ if newdata is None:
212
+ return self.fitted_values
213
+ try:
214
+ if self._model_spec is not None:
215
+ matrices = self._model_spec.get_model_matrix(newdata, na_action="drop")
216
+ if hasattr(matrices, "rhs"):
217
+ XX = matrices.rhs
218
+ else:
219
+ XX = matrices
220
+ else:
221
+ from formulaic import Formula
222
+ matrices = Formula(self.rhs_formula).get_model_matrix(newdata, na_action="drop")
223
+ XX = matrices.rhs if hasattr(matrices, "rhs") else matrices
224
+ except Exception as e:
225
+ msg = str(e)
226
+ if "not present in the dataset" in msg or "is not present" in msg:
227
+ import re as _re
228
+ m = _re.search(r"`(\w+)`", msg)
229
+ bad_col = m.group(1) if m else self.rhs_formula
230
+ from open_econs._internal.errors import missing_column_error
231
+ raise missing_column_error(bad_col, newdata.columns.tolist()) from e
232
+ raise
233
+ pred = pd.Series(
234
+ np.dot(XX.values, self.coefficients.values),
235
+ index=XX.index,
236
+ name="predicted",
237
+ )
238
+ return pred
239
+
240
+ def plot(self) -> None:
241
+ try:
242
+ import matplotlib.pyplot as plt
243
+ except ImportError:
244
+ raise ImportError(
245
+ "plot() requires matplotlib. Install it with: "
246
+ "pip install open-econs[plot] or pip install matplotlib"
247
+ )
248
+ res = self.residuals.values.ravel()
249
+ fitted = self.fitted_values.values.ravel()
250
+ fig, axes = plt.subplots(2, 2, figsize=(10, 8))
251
+ ax1, ax2, ax3, ax4 = axes[0, 0], axes[0, 1], axes[1, 0], axes[1, 1]
252
+ ax1.scatter(fitted, res, alpha=0.5, s=20)
253
+ ax1.axhline(0, color="red", linestyle="--", linewidth=1)
254
+ ax1.set_xlabel("Fitted values")
255
+ ax1.set_ylabel("Residuals")
256
+ ax1.set_title("Residuals vs Fitted")
257
+ from scipy.stats import probplot
258
+ probplot(res, dist="norm", plot=ax2)
259
+ ax2.get_lines()[0].set_marker("o")
260
+ ax2.get_lines()[0].set_markersize(3)
261
+ ax2.get_lines()[0].set_alpha(0.5)
262
+ ax2.set_title("Normal Q-Q")
263
+ sqrt_abs_res = np.sqrt(np.abs(res))
264
+ ax3.scatter(fitted, sqrt_abs_res, alpha=0.5, s=20)
265
+ ax3.set_xlabel("Fitted values")
266
+ ax3.set_ylabel("Sqrt(|Residuals|)")
267
+ ax3.set_title("Scale-Location")
268
+ ax4.text(0.5, 0.5, "Leverage plot: planned for v0.4", ha="center", va="center", transform=ax4.transAxes)
269
+ ax4.set_title("Residuals vs Leverage")
270
+ fig.tight_layout()
271
+ plt.show()
272
+
273
+
274
+ class OaxacaResult(BaseModel):
275
+ def __init__(
276
+ self,
277
+ *,
278
+ formula: str,
279
+ nobs: int,
280
+ n_params: int,
281
+ cov_type: str,
282
+ explained: float,
283
+ unexplained: float,
284
+ interaction: float | None,
285
+ total_gap: float,
286
+ decomposition_type: str,
287
+ by_groups: tuple[str, str],
288
+ std: pd.Series | None,
289
+ call: dict[str, Any],
290
+ variable_detail: pd.DataFrame | None = None,
291
+ ) -> None:
292
+ self.formula = formula
293
+ self.data_shape = (nobs, n_params)
294
+ self.cov_type = cov_type
295
+ self.call = call
296
+ self.timestamp = datetime.now()
297
+ self.package_version = __version__
298
+
299
+ self.nobs = nobs
300
+ self.explained = explained
301
+ self.unexplained = unexplained
302
+ self.interaction = interaction if interaction is not None else 0.0
303
+ self.total_gap = total_gap
304
+ self.type = decomposition_type
305
+ self.by_groups = by_groups
306
+ self.std = std
307
+ self.variable_detail = variable_detail if variable_detail is not None else pd.DataFrame()
308
+
309
+ self._freeze()
310
+
311
+ def tidy(self, detail: bool = False) -> pd.DataFrame:
312
+ if detail and not self.variable_detail.empty:
313
+ return self.variable_detail
314
+ if self.type == "two-fold":
315
+ data = {
316
+ "Component": ["Explained", "Unexplained", "Total Gap"],
317
+ "Effect": [self.explained, self.unexplained, self.total_gap],
318
+ }
319
+ else:
320
+ data = {
321
+ "Component": ["Endowment", "Coefficients", "Interaction", "Total Gap"],
322
+ "Effect": [self.explained, self.unexplained, self.interaction, self.total_gap],
323
+ }
324
+ df = pd.DataFrame(data)
325
+ if self.std is not None:
326
+ std_vals = list(self.std) + [float("nan")]
327
+ df["Std Err"] = std_vals[: len(df)]
328
+ return df
329
+
330
+ def summary(self) -> str:
331
+ header = (
332
+ f" Oaxaca-Blinder Decomposition \n"
333
+ f"======================================================================\n"
334
+ f"Formula: {self.formula}\n"
335
+ f"No. Observations: {self.nobs}\n"
336
+ f"Groups: {self.by_groups[0]} vs {self.by_groups[1]}\n"
337
+ f"Type: {self.type}\n"
338
+ f"======================================================================\n"
339
+ )
340
+ tbl = self.tidy().to_string(index=False)
341
+ return header + tbl + "\n======================================================================\n"
File without changes
File without changes
@@ -0,0 +1,205 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ from statsmodels.stats.oaxaca import OaxacaBlinder
7
+
8
+ from open_econs._version import __version__
9
+ from open_econs._internal import errors
10
+ from open_econs._internal.formula import parse_formula
11
+ from open_econs.core.results import OaxacaResult
12
+
13
+
14
+ def oaxaca(
15
+ formula: str,
16
+ data: pd.DataFrame,
17
+ by: str,
18
+ decomposition_type: str = "two-fold",
19
+ std: bool = False,
20
+ bootstrap_n: int = 1000,
21
+ conf_level: float = 0.95,
22
+ seed: int | None = None,
23
+ swap: bool = True,
24
+ ) -> OaxacaResult:
25
+ """Perform an Oaxaca-Blinder decomposition.
26
+
27
+ Parameters
28
+ ----------
29
+ formula : str
30
+ Two-sided formula string, e.g. ``"income ~ education + age + female"``.
31
+ ``by`` must appear on the RHS of the formula.
32
+ data : pd.DataFrame
33
+ Data containing all variables referenced in *formula*.
34
+ by : str
35
+ Column name of the binary group indicator. Must appear in the RHS
36
+ of *formula*.
37
+ decomposition_type : str, default "two-fold"
38
+ Either ``"two-fold"`` or ``"three-fold"``.
39
+ std : bool, default False
40
+ If True, compute bootstrapped standard errors. Bootstrap is
41
+ computationally expensive; 500 iterations is a reasonable minimum
42
+ for exploration, 1000+ for publication.
43
+ bootstrap_n : int, default 1000
44
+ Number of bootstrap replications (only used when ``std=True``).
45
+ Statsmodels' own default is 5000; 1000 is a reasonable trade-off
46
+ for exploration, 5000+ for publication-grade estimates.
47
+ conf_level : float, default 0.95
48
+ Confidence level for trimming extreme bootstrap draws.
49
+ seed : int, optional
50
+ Random seed for reproducible bootstrap standard errors.
51
+ swap : bool, default True
52
+ If True, the decomposition is computed as group 1 minus group 0
53
+ (group 1 is the higher value of the binary ``by`` column). Setting
54
+ ``swap=False`` reverses this.
55
+
56
+ Returns
57
+ -------
58
+ OaxacaResult
59
+ Immutable result object with ``.explained``, ``.unexplained``,
60
+ ``.total_gap``, and ``.tidy()``.
61
+
62
+ Examples
63
+ --------
64
+ >>> import open_econs as oe
65
+ >>> result = oe.oaxaca("income ~ education + age + female", data=df, by="female")
66
+ >>> result.tidy()
67
+ """
68
+ if by not in data.columns:
69
+ raise errors.missing_column_error(by, data.columns.tolist())
70
+
71
+ unique_vals = data[by].dropna().unique()
72
+ if len(unique_vals) != 2:
73
+ raise errors.non_binary_error(by, unique_vals)
74
+
75
+ call = _capture_call(
76
+ formula=formula, by=by, decomposition_type=decomposition_type,
77
+ std=std, bootstrap_n=bootstrap_n, conf_level=conf_level, seed=seed,
78
+ swap=swap,
79
+ )
80
+
81
+ yy, XX = parse_formula(formula, data)
82
+ y_arr = yy.values.ravel()
83
+
84
+ if by in XX.columns:
85
+ bifurcate_idx = list(XX.columns).index(by)
86
+ else:
87
+ patterns = [f"C({by})[", f"{by}["]
88
+ encoded_cols = [
89
+ c for c in XX.columns
90
+ if any(c.startswith(p) for p in patterns)
91
+ ]
92
+ if encoded_cols:
93
+ from pandas import get_dummies
94
+ dummies = get_dummies(data.loc[XX.index, by], prefix=by, drop_first=True)
95
+ if len(dummies.columns) == 1:
96
+ XX = XX.copy()
97
+ for c in encoded_cols:
98
+ XX.drop(columns=[c], inplace=True)
99
+ cname = str(by)
100
+ XX.insert(XX.shape[1], cname, dummies.iloc[:, 0].values.astype(float))
101
+ bifurcate_idx = list(XX.columns).index(cname)
102
+ else:
103
+ raise ValueError(
104
+ f"The 'by' column '{by}' has {len(dummies.columns) + 1} unique "
105
+ f"values (must be exactly 2 for Oaxaca decomposition)."
106
+ )
107
+ else:
108
+ raise ValueError(
109
+ f"Column '{by}' not found in the design matrix. Ensure '{by}' "
110
+ f"appears on the RHS of the formula (e.g. '{formula}'). "
111
+ f"Available columns in design matrix: {list(XX.columns)}"
112
+ )
113
+ hasconst = any("Intercept" in c for c in XX.columns)
114
+
115
+ if seed is not None:
116
+ np.random.seed(seed)
117
+
118
+ model = OaxacaBlinder(
119
+ y_arr,
120
+ XX.values,
121
+ bifurcate=bifurcate_idx,
122
+ hasconst=hasconst,
123
+ swap=swap,
124
+ )
125
+
126
+ if decomposition_type == "two-fold":
127
+ stats_result = model.two_fold(std=std, n=bootstrap_n, conf=conf_level)
128
+ explained = float(stats_result.params[1])
129
+ unexplained = float(stats_result.params[0])
130
+ gap_val = float(stats_result.params[2])
131
+ interaction = None
132
+ elif decomposition_type == "three-fold":
133
+ stats_result = model.three_fold(std=std, n=bootstrap_n, conf=conf_level)
134
+ endowment = float(stats_result.params[0])
135
+ coefficients = float(stats_result.params[1])
136
+ interaction = float(stats_result.params[2])
137
+ gap_val = float(stats_result.params[3])
138
+ explained = endowment
139
+ unexplained = coefficients
140
+ else:
141
+ raise ValueError(
142
+ f"Unknown decomposition_type '{decomposition_type}'. "
143
+ f"Use 'two-fold' or 'three-fold'."
144
+ )
145
+
146
+ var_names = [c for c in XX.columns if c != by]
147
+ f_params = model._f_model.params
148
+ s_params = model._s_model.params
149
+ f_mean = model.exog_f_mean
150
+ s_mean = model.exog_s_mean
151
+
152
+ if decomposition_type == "two-fold":
153
+ ref_params = model.t_params
154
+ endow_vec = (f_mean - s_mean) * ref_params
155
+ unexpl_vec = f_mean * (f_params - ref_params) + s_mean * (ref_params - s_params)
156
+ var_detail = pd.DataFrame({
157
+ "Variable": var_names,
158
+ "Explained": endow_vec[:len(var_names)],
159
+ "Unexplained": unexpl_vec[:len(var_names)],
160
+ })
161
+ else:
162
+ endow_vec = (f_mean - s_mean) * s_params
163
+ coeff_vec = s_mean * (f_params - s_params)
164
+ inter_vec = (f_mean - s_mean) * (f_params - s_params)
165
+ var_detail = pd.DataFrame({
166
+ "Variable": var_names,
167
+ "Endowment": endow_vec[:len(var_names)],
168
+ "Coefficients": coeff_vec[:len(var_names)],
169
+ "Interaction": inter_vec[:len(var_names)],
170
+ })
171
+
172
+ unique_vals_sorted = sorted(data[by].dropna().unique(), key=str)
173
+ idx_map = {float(i): str(v) for i, v in enumerate(unique_vals_sorted)}
174
+ group_labels = (idx_map.get(model.bi[0], str(model.bi[0])),
175
+ idx_map.get(model.bi[1], str(model.bi[1])))
176
+
177
+ std_series: pd.Series | None = None
178
+ if std and stats_result.std:
179
+ if decomposition_type == "two-fold":
180
+ labels = ["Unexplained", "Explained"]
181
+ else:
182
+ labels = ["Endowment", "Coefficients", "Interaction"]
183
+ std_series = pd.Series(stats_result.std, index=labels, name="std_err")
184
+
185
+ return OaxacaResult(
186
+ formula=formula,
187
+ nobs=int(len(yy)),
188
+ n_params=XX.shape[1],
189
+ cov_type="nonrobust",
190
+ explained=explained,
191
+ unexplained=unexplained,
192
+ interaction=interaction,
193
+ total_gap=gap_val,
194
+ decomposition_type=decomposition_type,
195
+ by_groups=group_labels,
196
+ std=std_series,
197
+ call=call,
198
+ variable_detail=var_detail,
199
+ )
200
+
201
+
202
+ def _capture_call(**kwargs: Any) -> dict[str, Any]:
203
+ kwargs["timestamp"] = str(datetime.now())
204
+ kwargs["package_version"] = __version__
205
+ return kwargs
File without changes
@@ -0,0 +1,238 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ import statsmodels.api as sm
7
+
8
+ from open_econs._version import __version__
9
+ from open_econs._internal import errors
10
+ from open_econs.core.results import OLSResult
11
+
12
+
13
+ def ols(
14
+ formula: str,
15
+ data: pd.DataFrame,
16
+ cluster: str | None = None,
17
+ cov_type: str = "HC2",
18
+ weights: str | np.ndarray | pd.Series | None = None,
19
+ ) -> OLSResult:
20
+ """Estimate an ordinary least-squares or weighted least-squares regression.
21
+
22
+ Parameters
23
+ ----------
24
+ formula : str
25
+ Two-sided formula string, e.g. ``"income ~ education + age"``.
26
+ data : pd.DataFrame
27
+ Data containing all variables referenced in *formula*.
28
+ cluster : str, optional
29
+ Column name for cluster-robust standard errors.
30
+ cov_type : str, default "HC2"
31
+ Covariance estimator type. Common choices: ``"HC2"`` (default,
32
+ matches modern Stata ``reg, robust``; changed from HC1 in v0.2.0),
33
+ ``"HC1`` (classic White SE), ``"HC0"``, ``"HC3"``, ``"nonrobust"``.
34
+ Ignored when *cluster* is provided (cluster-robust is used instead).
35
+ weights : str or array-like, optional
36
+ Frequency/analytic weights for weighted least squares. If a string,
37
+ interpreted as a column name in *data*. If an array, must match the
38
+ number of rows in *data*.
39
+
40
+ Returns
41
+ -------
42
+ OLSResult
43
+ Immutable result object with named coefficient arrays.
44
+
45
+ Examples
46
+ --------
47
+ >>> import open_econs as oe
48
+ >>> result = oe.ols("income ~ education + age", data=df)
49
+ >>> result.tidy()
50
+ >>> result.coefficients["education"]
51
+ """
52
+ call = _capture_call(formula=formula, cluster=cluster, cov_type=cov_type, weights=weights)
53
+ rhs_formula = formula.split("~", 1)[1].strip()
54
+
55
+ from formulaic import Formula
56
+ try:
57
+ formula_obj = Formula(formula)
58
+ model_spec = formula_obj.get_model_matrix(data, na_action="drop")
59
+ except Exception as e:
60
+ msg = str(e)
61
+ if "not present in the dataset" in msg or "is not present" in msg:
62
+ import re as _re
63
+ m = _re.search(r"`(\w+)`", msg)
64
+ bad_col = m.group(1) if m else formula.split("~", 1)[1].strip()
65
+ raise errors.missing_column_error(bad_col, data.columns.tolist()) from e
66
+ raise
67
+ if hasattr(model_spec, "rhs"):
68
+ XX = model_spec.rhs
69
+ yy = model_spec.lhs
70
+ else:
71
+ from open_econs._internal.formula import parse_formula as _parse
72
+ yy, XX = _parse(formula, data)
73
+ model_spec = None
74
+
75
+ original_n = len(data)
76
+ dropped = original_n - len(yy)
77
+ vars_needed = {str(v) for v in formula_obj.required_variables}
78
+ cols_with_nas = sorted(v for v in vars_needed if v in data.columns and data[v].isna().any())
79
+ if dropped > 0:
80
+ from open_econs._internal.errors import rows_dropped_warning
81
+ import warnings as _w
82
+ _w.warn(rows_dropped_warning(dropped, original_n, cols_with_nas), RuntimeWarning, stacklevel=3)
83
+
84
+ if len(yy) == 0:
85
+ from open_econs._internal.errors import empty_data_error
86
+ raise empty_data_error(original_n, dropped, cols_with_nas)
87
+
88
+ y_arr = yy.values.ravel()
89
+
90
+ if model_spec is not None:
91
+ stored_spec = model_spec.model_spec.rhs
92
+ else:
93
+ stored_spec = None
94
+
95
+ condition_number = _check_collinearity(XX)
96
+
97
+ if weights is not None:
98
+ if isinstance(weights, str):
99
+ if weights not in data.columns:
100
+ raise errors.missing_column_error(weights, data.columns.tolist())
101
+ w_arr = data.loc[XX.index, weights].values.astype(float)
102
+ else:
103
+ w_arr = np.asarray(weights, dtype=float)
104
+ if len(w_arr) != original_n:
105
+ raise ValueError(
106
+ f"weights array length ({len(w_arr)}) does not match data rows ({original_n})"
107
+ )
108
+ w_arr = w_arr[XX.index]
109
+ if np.any(w_arr < 0):
110
+ raise ValueError("Weights must be non-negative.")
111
+ fitted = sm.WLS(y_arr, XX.values, weights=w_arr).fit(
112
+ cov_type="cluster" if cluster else cov_type,
113
+ cov_kwds={"groups": data.loc[XX.index, cluster]} if cluster else {},
114
+ )
115
+ cov_label = f"cluster({cluster})" if cluster else cov_type
116
+ else:
117
+ if cluster is not None:
118
+ if cluster not in data.columns:
119
+ raise errors.cluster_column_error(cluster, data.columns.tolist())
120
+ aligned_groups = data.loc[XX.index, cluster]
121
+ fitted = sm.OLS(y_arr, XX.values).fit(
122
+ cov_type="cluster",
123
+ cov_kwds={"groups": aligned_groups},
124
+ )
125
+ cov_label = f"cluster({cluster})"
126
+ else:
127
+ fitted = sm.OLS(y_arr, XX.values).fit(cov_type=cov_type)
128
+ cov_label = cov_type
129
+
130
+ coef_arr = fitted.params
131
+ se_arr = fitted.bse
132
+ t_arr = fitted.tvalues
133
+ p_arr = fitted.pvalues
134
+ conf_arr = fitted.conf_int()
135
+
136
+ conf_int = pd.DataFrame(
137
+ {"lower": conf_arr[:, 0], "upper": conf_arr[:, 1]},
138
+ index=XX.columns,
139
+ )
140
+
141
+ fitted_values = pd.Series(fitted.fittedvalues, index=XX.index, name="fitted")
142
+ residuals = pd.Series(fitted.resid, index=XX.index, name="residuals")
143
+
144
+ f_stat = _safe_fvalue(fitted)
145
+ f_pval = _safe_f_pvalue(fitted)
146
+
147
+ return OLSResult(
148
+ formula=formula,
149
+ rhs_formula=rhs_formula,
150
+ nobs=int(fitted.nobs),
151
+ df_resid=int(fitted.df_resid),
152
+ df_model=int(fitted.df_model),
153
+ cov_type=cov_label,
154
+ coefficients=pd.Series(coef_arr, index=XX.columns),
155
+ std_errors=pd.Series(se_arr, index=XX.columns),
156
+ t_stats=pd.Series(t_arr, index=XX.columns),
157
+ p_values=pd.Series(p_arr, index=XX.columns),
158
+ conf_int=conf_int,
159
+ r_squared=float(fitted.rsquared),
160
+ adj_r_squared=float(fitted.rsquared_adj),
161
+ f_statistic=f_stat,
162
+ f_p_value=f_pval,
163
+ rsd=float(np.sqrt(fitted.mse_resid)),
164
+ llf=_safe_llf(fitted),
165
+ aic=_safe_aic(fitted),
166
+ bic=_safe_bic(fitted),
167
+ fitted=fitted_values,
168
+ residuals=residuals,
169
+ call=call,
170
+ model_spec=stored_spec,
171
+ condition_number=condition_number,
172
+ _X=XX,
173
+ _sm_fit=fitted,
174
+ )
175
+
176
+
177
+ reg = ols
178
+
179
+
180
+ def _capture_call(**kwargs: Any) -> dict[str, Any]:
181
+ kwargs["timestamp"] = str(datetime.now())
182
+ kwargs["package_version"] = __version__
183
+ return kwargs
184
+
185
+
186
+ def _safe_fvalue(fitted: Any) -> float:
187
+ try:
188
+ return float(fitted.fvalue)
189
+ except (ValueError, AttributeError):
190
+ return float("nan")
191
+
192
+
193
+ def _safe_f_pvalue(fitted: Any) -> float:
194
+ try:
195
+ return float(fitted.f_pvalue)
196
+ except (ValueError, AttributeError):
197
+ return float("nan")
198
+
199
+
200
+ def _safe_llf(fitted: Any) -> float:
201
+ try:
202
+ return float(fitted.llf)
203
+ except (ValueError, AttributeError):
204
+ return float("nan")
205
+
206
+
207
+ def _safe_aic(fitted: Any) -> float:
208
+ try:
209
+ return float(fitted.aic)
210
+ except (ValueError, AttributeError):
211
+ return float("nan")
212
+
213
+
214
+ def _safe_bic(fitted: Any) -> float:
215
+ try:
216
+ return float(fitted.bic)
217
+ except (ValueError, AttributeError):
218
+ return float("nan")
219
+
220
+
221
+ def _check_collinearity(XX: pd.DataFrame) -> float:
222
+ from numpy.linalg import cond, matrix_rank
223
+ X_vals = XX.values
224
+ n_params = X_vals.shape[1]
225
+ rank = matrix_rank(X_vals)
226
+ if rank < n_params:
227
+ raise errors.singular_matrix_error()
228
+ cn = float(cond(X_vals))
229
+ if cn > 30:
230
+ import warnings as _w
231
+ _w.warn(
232
+ f"Design matrix is near-singular (condition number = {cn:.2e}). "
233
+ "Belsley, Kuh & Welsch (1980) recommend caution above 30. "
234
+ "Consider removing collinear predictors.",
235
+ RuntimeWarning,
236
+ stacklevel=3,
237
+ )
238
+ return cn
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: open-econs
3
+ Version: 0.3.0
4
+ Summary: The scikit-learn of empirical economics.
5
+ Project-URL: Homepage, https://github.com/anomalyco/open-econs
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: formulaic>=1.0
9
+ Requires-Dist: numpy>=1.26
10
+ Requires-Dist: pandas>=2.2
11
+ Requires-Dist: scipy>=1.11
12
+ Requires-Dist: statsmodels>=0.14
13
+ Provides-Extra: dev
14
+ Requires-Dist: hypothesis>=6.0; extra == 'dev'
15
+ Requires-Dist: pytest-cov; extra == 'dev'
16
+ Requires-Dist: pytest>=8.0; extra == 'dev'
17
+ Provides-Extra: lint
18
+ Requires-Dist: mypy>=1.8; extra == 'lint'
19
+ Requires-Dist: ruff>=0.3; extra == 'lint'
20
+ Provides-Extra: plot
21
+ Requires-Dist: matplotlib>=3.8; extra == 'plot'
@@ -0,0 +1,18 @@
1
+ open_econs/__init__.py,sha256=7RtocgpChkCnZS3yRtD7Ny2YfHTTQXmBr5iEvA-BN7g,223
2
+ open_econs/_version.py,sha256=5mDtfU2t04ATmg92S5B-scUcdjCy1ZBq620y3GmpdtQ,21
3
+ open_econs/_internal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ open_econs/_internal/errors.py,sha256=zMV2x_t_tBav_L9cqYv08hIFSjqa27Ww0Te846sJCO8,1606
5
+ open_econs/_internal/formula.py,sha256=U8hxWczkJLZHBtTSjTtkE7NJE_BxXsBj4EO3Fi80tO0,1378
6
+ open_econs/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ open_econs/core/base.py,sha256=AmTGlEoyZ5lzNQlo6GuE_GslnX95mo_hRXucPy9xGI4,3144
8
+ open_econs/core/context.py,sha256=6-PycFukd8hGjwK8_g3Mle9d14mVLKta4HK69qONxJ8,1411
9
+ open_econs/core/results.py,sha256=BLzIYTU6Mpcrc-ipNfqIa64a43VD67FzT_hPoAuuorE,13478
10
+ open_econs/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ open_econs/models/decomposition/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ open_econs/models/decomposition/oaxaca.py,sha256=9QEVoKdsw2Lbbv6QswPJUfEuo6oe27FEGmHZ6GF8rS8,7411
13
+ open_econs/models/linear/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ open_econs/models/linear/ols.py,sha256=Jgjs3u0TlFpBnKaAavLKXB_wTdGQOpmt8gubDs0cLbQ,7851
15
+ open_econs-0.3.0.dist-info/METADATA,sha256=d6lJf-n8d3pQtSPa4pHa5L_vJxPsBDjo9IINfg8EOus,683
16
+ open_econs-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
17
+ open_econs-0.3.0.dist-info/licenses/LICENSE,sha256=G29KdzB-lU2HHhxZdhrNvJR_R1yW-NjteextQhiTefU,1065
18
+ open_econs-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 M Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.