formative-ds 1.2.0__tar.gz → 1.2.2__tar.gz

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 (84) hide show
  1. {formative_ds-1.2.0 → formative_ds-1.2.2}/PKG-INFO +4 -3
  2. {formative_ds-1.2.0 → formative_ds-1.2.2}/README.md +2 -2
  3. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/causal/estimators.rst +6 -0
  4. formative_ds-1.2.2/formative/causal/estimators/_base.py +93 -0
  5. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/estimators/did.py +10 -57
  6. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/estimators/iv.py +5 -61
  7. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/estimators/matching.py +5 -35
  8. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/estimators/ols.py +6 -62
  9. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/estimators/rct.py +5 -61
  10. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/estimators/rdd.py +5 -61
  11. {formative_ds-1.2.0 → formative_ds-1.2.2}/pyproject.toml +3 -2
  12. {formative_ds-1.2.0 → formative_ds-1.2.2}/uv.lock +16 -13
  13. {formative_ds-1.2.0 → formative_ds-1.2.2}/.github/workflows/ci.yml +0 -0
  14. {formative_ds-1.2.0 → formative_ds-1.2.2}/.github/workflows/publish.yml +0 -0
  15. {formative_ds-1.2.0 → formative_ds-1.2.2}/.gitignore +0 -0
  16. {formative_ds-1.2.0 → formative_ds-1.2.2}/CLAUDE.md +0 -0
  17. {formative_ds-1.2.0 → formative_ds-1.2.2}/LICENSE +0 -0
  18. {formative_ds-1.2.0 → formative_ds-1.2.2}/Makefile +0 -0
  19. {formative_ds-1.2.0 → formative_ds-1.2.2}/TODO.md +0 -0
  20. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/causal/dag.rst +0 -0
  21. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/causal/decisions.rst +0 -0
  22. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/causal/estimands.rst +0 -0
  23. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/causal/introduction.rst +0 -0
  24. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/causal/motivation.rst +0 -0
  25. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/causal/refutations.rst +0 -0
  26. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/causal/wizard.rst +0 -0
  27. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/conf.py +0 -0
  28. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/game/decision_rules.rst +0 -0
  29. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/game/introduction.rst +0 -0
  30. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/game/single_player.rst +0 -0
  31. {formative_ds-1.2.0 → formative_ds-1.2.2}/docs/index.rst +0 -0
  32. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/decision/01_decision_basic.py +0 -0
  33. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/did/01_did_basic.py +0 -0
  34. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/iv/01_iv_basic.py +0 -0
  35. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/iv/02_iv_vs_ols_bias.py +0 -0
  36. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/iv/03_iv_with_observed_confounder.py +0 -0
  37. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/iv/04_iv_refutation.py +0 -0
  38. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/matching/01_basic_matching.py +0 -0
  39. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/matching/02_matching_vs_naive.py +0 -0
  40. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/matching/03_matching_refutation.py +0 -0
  41. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/ols/01_ols_basic.py +0 -0
  42. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/ols/02_ols_unobserved_confounder.py +0 -0
  43. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/ols/03_ols_multiple_confounders.py +0 -0
  44. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/rct/01_rct_basic.py +0 -0
  45. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/rdd/01_rdd_basic.py +0 -0
  46. {formative_ds-1.2.0 → formative_ds-1.2.2}/examples/rdd/02_rdd_positive_running_var.py +0 -0
  47. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/__init__.py +0 -0
  48. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/__init__.py +0 -0
  49. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/_exceptions.py +0 -0
  50. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/_explain.py +0 -0
  51. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/dag.py +0 -0
  52. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/decision.py +0 -0
  53. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/estimators/__init__.py +0 -0
  54. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/refutations/__init__.py +0 -0
  55. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/refutations/_check.py +0 -0
  56. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/refutations/did.py +0 -0
  57. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/refutations/iv.py +0 -0
  58. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/refutations/matching.py +0 -0
  59. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/refutations/ols.py +0 -0
  60. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/refutations/rct.py +0 -0
  61. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/causal/refutations/rdd.py +0 -0
  62. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/game/__init__.py +0 -0
  63. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/game/expected_value.py +0 -0
  64. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/game/hurwicz.py +0 -0
  65. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/game/laplace.py +0 -0
  66. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/game/maximax.py +0 -0
  67. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/game/maximin.py +0 -0
  68. {formative_ds-1.2.0 → formative_ds-1.2.2}/formative/game/minimax_regret.py +0 -0
  69. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_dag.py +0 -0
  70. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_decision.py +0 -0
  71. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_did.py +0 -0
  72. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_did_refutation.py +0 -0
  73. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_game.py +0 -0
  74. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_iv.py +0 -0
  75. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_iv_refutation.py +0 -0
  76. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_matching.py +0 -0
  77. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_matching_refutation.py +0 -0
  78. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_ols.py +0 -0
  79. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_ols_refutation.py +0 -0
  80. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_rct.py +0 -0
  81. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_rct_refutation.py +0 -0
  82. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_rdd.py +0 -0
  83. {formative_ds-1.2.0 → formative_ds-1.2.2}/tests/test_rdd_refutation.py +0 -0
  84. {formative_ds-1.2.0 → formative_ds-1.2.2}/vercel.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: formative-ds
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: A Python library for quantitative reasoning.
5
5
  Project-URL: Homepage, https://getformative.dev
6
6
  Project-URL: Documentation, https://docs.getformative.dev
@@ -21,6 +21,7 @@ Classifier: Topic :: Scientific/Engineering :: Mathematics
21
21
  Requires-Python: >=3.10
22
22
  Requires-Dist: numpy<3,>=2
23
23
  Requires-Dist: pandas<4,>=2.1
24
+ Requires-Dist: scipy<2,>=1.11
24
25
  Requires-Dist: statsmodels<0.15,>=0.14
25
26
  Description-Content-Type: text/markdown
26
27
 
@@ -47,7 +48,7 @@ Comprehensive documentation is available at [docs.getformative.dev](https://docs
47
48
  ### Causal estimation
48
49
 
49
50
  ```python
50
- from formative import DAG, OLSObservational
51
+ from formative.causal import DAG, OLSObservational
51
52
 
52
53
  dag = DAG()
53
54
  dag.assume("ability").causes("education", "income")
@@ -119,7 +120,7 @@ To use `formative` from a script outside this repo without installing it, either
119
120
  import sys
120
121
  sys.path.insert(0, "/path/to/formative")
121
122
 
122
- from formative import DAG, OLSObservational
123
+ from formative.causal import DAG, OLSObservational
123
124
  ```
124
125
 
125
126
  Or set `PYTHONPATH` before running:
@@ -21,7 +21,7 @@ Comprehensive documentation is available at [docs.getformative.dev](https://docs
21
21
  ### Causal estimation
22
22
 
23
23
  ```python
24
- from formative import DAG, OLSObservational
24
+ from formative.causal import DAG, OLSObservational
25
25
 
26
26
  dag = DAG()
27
27
  dag.assume("ability").causes("education", "income")
@@ -93,7 +93,7 @@ To use `formative` from a script outside this repo without installing it, either
93
93
  import sys
94
94
  sys.path.insert(0, "/path/to/formative")
95
95
 
96
- from formative import DAG, OLSObservational
96
+ from formative.causal import DAG, OLSObservational
97
97
  ```
98
98
 
99
99
  Or set `PYTHONPATH` before running:
@@ -12,6 +12,7 @@ OLSResult
12
12
 
13
13
  .. autoclass:: formative.causal.OLSResult
14
14
  :members:
15
+ :inherited-members:
15
16
 
16
17
  IV2SLS
17
18
  ------
@@ -24,6 +25,7 @@ IVResult
24
25
 
25
26
  .. autoclass:: formative.causal.IVResult
26
27
  :members:
28
+ :inherited-members:
27
29
 
28
30
  PropensityScoreMatching
29
31
  -----------------------
@@ -36,6 +38,7 @@ MatchingResult
36
38
 
37
39
  .. autoclass:: formative.causal.MatchingResult
38
40
  :members:
41
+ :inherited-members:
39
42
 
40
43
  RCT
41
44
  ---
@@ -48,6 +51,7 @@ RCTResult
48
51
 
49
52
  .. autoclass:: formative.causal.RCTResult
50
53
  :members:
54
+ :inherited-members:
51
55
 
52
56
  DiD
53
57
  ---
@@ -60,6 +64,7 @@ DiDResult
60
64
 
61
65
  .. autoclass:: formative.causal.DiDResult
62
66
  :members:
67
+ :inherited-members:
63
68
 
64
69
  RDD
65
70
  ---
@@ -72,3 +77,4 @@ RDDResult
72
77
 
73
78
  .. autoclass:: formative.causal.RDDResult
74
79
  :members:
80
+ :inherited-members:
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from ..refutations._check import Assumption
4
+
5
+
6
+ class _BaseResult:
7
+ """
8
+ Shared behaviour for estimation result classes.
9
+
10
+ Subclasses set ``self._treatment`` and ``self._outcome`` in ``__init__``
11
+ and define a class-level ``_ASSUMPTIONS`` list. ``decide()`` reads the
12
+ ``effect`` / ``std_err`` / ``conf_int`` properties, which subclasses
13
+ (or ``_StatsmodelsResult``) provide.
14
+ """
15
+
16
+ _ASSUMPTIONS: list[Assumption] = []
17
+ _treatment: str
18
+ _outcome: str
19
+
20
+ @property
21
+ def assumptions(self) -> list[Assumption]:
22
+ """Modelling assumptions required for a causal interpretation."""
23
+ return list(self._ASSUMPTIONS)
24
+
25
+ def _assumptions_lines(self, width: int = 48) -> list[str]:
26
+ """Assumptions footer appended to every ``summary()``."""
27
+ lines = ["", " Assumptions", " " + "┄" * width]
28
+ lines += [f" {a.fmt_tag()} {a.name}" for a in self._ASSUMPTIONS]
29
+ lines.append("")
30
+ return lines
31
+
32
+ def decide(self, cost: float, benefit: float):
33
+ """
34
+ Compute a cost-benefit decision analysis from this causal estimate.
35
+
36
+ Parameters
37
+ ----------
38
+ cost : float
39
+ Cost per unit of treatment applied.
40
+ benefit : float
41
+ Benefit (revenue, utility, etc.) per unit increase in the outcome.
42
+
43
+ Returns
44
+ -------
45
+ DecisionReport
46
+ Optimal decision, net benefit, CI, confidence, and robustness flag.
47
+ """
48
+ from ..decision import _decide
49
+
50
+ return _decide(self.effect, self.std_err, self.conf_int, self._treatment, self._outcome, cost, benefit)
51
+
52
+ def __repr__(self) -> str:
53
+ return self.summary()
54
+
55
+
56
+ class _StatsmodelsResult(_BaseResult):
57
+ """
58
+ Result backed by a fitted statsmodels regression.
59
+
60
+ Effect statistics are read from ``self._result`` at parameter
61
+ ``self._param``, which defaults to the treatment name (DiD overrides
62
+ it with the group × time interaction term).
63
+ """
64
+
65
+ @property
66
+ def _param(self) -> str:
67
+ return self._treatment
68
+
69
+ @property
70
+ def effect(self) -> float:
71
+ """Point estimate of the causal effect of treatment on outcome."""
72
+ return float(self._result.params[self._param])
73
+
74
+ @property
75
+ def std_err(self) -> float:
76
+ """Standard error of the treatment effect estimate."""
77
+ return float(self._result.bse[self._param])
78
+
79
+ @property
80
+ def conf_int(self) -> tuple[float, float]:
81
+ """95% confidence interval for the treatment effect."""
82
+ ci = self._result.conf_int()
83
+ return (float(ci.loc[self._param, 0]), float(ci.loc[self._param, 1]))
84
+
85
+ @property
86
+ def pvalue(self) -> float:
87
+ """p-value for the treatment effect (``H0: effect = 0``)."""
88
+ return float(self._result.pvalues[self._param])
89
+
90
+ @property
91
+ def statsmodels_result(self):
92
+ """The underlying statsmodels result, for full diagnostics."""
93
+ return self._result
@@ -5,6 +5,7 @@ import statsmodels.formula.api as smf
5
5
 
6
6
  from ..dag import DAG
7
7
  from ..refutations._check import Assumption
8
+ from ._base import _StatsmodelsResult
8
9
 
9
10
  DID_ASSUMPTIONS: list[Assumption] = [
10
11
  Assumption(
@@ -17,7 +18,7 @@ DID_ASSUMPTIONS: list[Assumption] = [
17
18
  ]
18
19
 
19
20
 
20
- class DiDResult:
21
+ class DiDResult(_StatsmodelsResult):
21
22
  """
22
23
  The result of a Difference-in-Differences estimation.
23
24
 
@@ -26,6 +27,8 @@ class DiDResult:
26
27
  control group's trajectory.
27
28
  """
28
29
 
30
+ _ASSUMPTIONS = DID_ASSUMPTIONS
31
+
29
32
  def __init__(
30
33
  self,
31
34
  result,
@@ -45,45 +48,20 @@ class DiDResult:
45
48
  self._n = n
46
49
 
47
50
  @property
48
- def _interaction(self) -> str:
51
+ def _param(self) -> str:
52
+ # The DiD estimate is the group × time interaction coefficient.
49
53
  return f"{self._group}:{self._time}"
50
54
 
51
55
  @property
52
- def effect(self) -> float:
53
- """DiD point estimate: ATT under the parallel trends assumption."""
54
- return float(self._result.params[self._interaction])
56
+ def _treatment(self) -> str:
57
+ # The treatment label used by decide() is the group indicator.
58
+ return self._group
55
59
 
56
60
  @property
57
61
  def naive_diff(self) -> float:
58
62
  """Naive post-period difference: treated mean minus control mean in the post period."""
59
63
  return self._naive_diff
60
64
 
61
- @property
62
- def std_err(self) -> float:
63
- """Standard error of the DiD estimate."""
64
- return float(self._result.bse[self._interaction])
65
-
66
- @property
67
- def conf_int(self) -> tuple[float, float]:
68
- """95% confidence interval for the DiD estimate."""
69
- ci = self._result.conf_int()
70
- return (float(ci.loc[self._interaction, 0]), float(ci.loc[self._interaction, 1]))
71
-
72
- @property
73
- def pvalue(self) -> float:
74
- """p-value for the DiD estimate (``H0: ATT = 0``)."""
75
- return float(self._result.pvalues[self._interaction])
76
-
77
- @property
78
- def statsmodels_result(self):
79
- """The underlying statsmodels OLS result, for full diagnostics."""
80
- return self._result
81
-
82
- @property
83
- def assumptions(self) -> list[Assumption]:
84
- """Modelling assumptions required for a causal interpretation."""
85
- return list(DID_ASSUMPTIONS)
86
-
87
65
  def executive_summary(self) -> str:
88
66
  """Narrative explanation of the method, DAG, assumptions, and result."""
89
67
  from .._explain import explain_did
@@ -107,35 +85,10 @@ class DiDResult:
107
85
  f" 95% CI : [{lo:.4f}, {hi:.4f}]",
108
86
  f" p-value : {self.pvalue:>10.4f}",
109
87
  f" N : {self._n:>10}",
110
- "",
111
- " Assumptions",
112
- " " + "\u2504" * 48,
113
88
  ]
114
- for a in DID_ASSUMPTIONS:
115
- lines.append(f" {a.fmt_tag()} {a.name}")
116
- lines.append("")
89
+ lines += self._assumptions_lines()
117
90
  return "\n".join(lines)
118
91
 
119
- def decide(self, cost: float, benefit: float):
120
- """
121
- Compute a cost-benefit decision analysis from this causal estimate.
122
-
123
- Parameters
124
- ----------
125
- cost : float
126
- Cost per unit of treatment applied.
127
- benefit : float
128
- Benefit (revenue, utility, etc.) per unit increase in the outcome.
129
-
130
- Returns
131
- -------
132
- DecisionReport
133
- Optimal decision, net benefit, CI, confidence, and robustness flag.
134
- """
135
- from ..decision import _decide
136
-
137
- return _decide(self.effect, self.std_err, self.conf_int, self._group, self._outcome, cost, benefit)
138
-
139
92
  def refute(self, data: pd.DataFrame):
140
93
  """
141
94
  Run refutation checks against this DiD estimation.
@@ -7,6 +7,7 @@ from statsmodels.sandbox.regression.gmm import IV2SLS as _IV2SLS
7
7
 
8
8
  from ..dag import DAG
9
9
  from ..refutations._check import Assumption
10
+ from ._base import _StatsmodelsResult
10
11
 
11
12
  IV_ASSUMPTIONS: list[Assumption] = [
12
13
  Assumption("Relevance: the instrument strongly affects treatment", testable=True),
@@ -17,7 +18,7 @@ IV_ASSUMPTIONS: list[Assumption] = [
17
18
  ]
18
19
 
19
20
 
20
- class IVResult:
21
+ class IVResult(_StatsmodelsResult):
21
22
  """
22
23
  The result of an IV (2SLS) causal estimation.
23
24
 
@@ -26,6 +27,8 @@ class IVResult:
26
27
  bias that IV corrects.
27
28
  """
28
29
 
30
+ _ASSUMPTIONS = IV_ASSUMPTIONS
31
+
29
32
  def __init__(
30
33
  self,
31
34
  result,
@@ -46,78 +49,27 @@ class IVResult:
46
49
  self._dag = dag
47
50
  self._n = n
48
51
 
49
- @property
50
- def effect(self) -> float:
51
- """2SLS point estimate of the causal effect of treatment on outcome."""
52
- return float(self._result.params[self._treatment])
53
-
54
52
  @property
55
53
  def unadjusted_effect(self) -> float:
56
54
  """Unadjusted OLS estimate: naive regression without instrument or controls."""
57
55
  return float(self._unadjusted.params[self._treatment])
58
56
 
59
- @property
60
- def std_err(self) -> float:
61
- """Standard error of the 2SLS treatment effect estimate."""
62
- return float(self._result.bse[self._treatment])
63
-
64
- @property
65
- def conf_int(self) -> tuple[float, float]:
66
- """95% confidence interval for the 2SLS treatment effect."""
67
- ci = self._result.conf_int()
68
- return (float(ci.loc[self._treatment, 0]), float(ci.loc[self._treatment, 1]))
69
-
70
- @property
71
- def pvalue(self) -> float:
72
- """p-value for the 2SLS treatment effect (``H0: effect = 0``)."""
73
- return float(self._result.pvalues[self._treatment])
74
-
75
57
  @property
76
58
  def adjustment_set(self) -> set[str]:
77
59
  """Observed confounders included as controls in both OLS stages."""
78
60
  return self._adjustment_set
79
61
 
80
- @property
81
- def statsmodels_result(self):
82
- """The underlying statsmodels IV2SLS result, for full diagnostics."""
83
- return self._result
84
-
85
62
  @property
86
63
  def statsmodels_unadjusted_result(self):
87
64
  """The underlying unadjusted OLS result, for full diagnostics."""
88
65
  return self._unadjusted
89
66
 
90
- @property
91
- def assumptions(self) -> list[Assumption]:
92
- """Modelling assumptions required for a causal interpretation."""
93
- return list(IV_ASSUMPTIONS)
94
-
95
67
  def executive_summary(self) -> str:
96
68
  """Narrative explanation of the method, DAG, assumptions, and result."""
97
69
  from .._explain import explain_iv
98
70
 
99
71
  return explain_iv(self)
100
72
 
101
- def decide(self, cost: float, benefit: float):
102
- """
103
- Compute a cost-benefit decision analysis from this causal estimate.
104
-
105
- Parameters
106
- ----------
107
- cost : float
108
- Cost per unit of treatment applied.
109
- benefit : float
110
- Benefit (revenue, utility, etc.) per unit increase in the outcome.
111
-
112
- Returns
113
- -------
114
- DecisionReport
115
- Optimal decision, net benefit, CI, confidence, and robustness flag.
116
- """
117
- from ..decision import _decide
118
-
119
- return _decide(self.effect, self.std_err, self.conf_int, self._treatment, self._outcome, cost, benefit)
120
-
121
73
  def refute(self, data: pd.DataFrame):
122
74
  """
123
75
  Run refutation checks against this IV estimation.
@@ -181,18 +133,10 @@ class IVResult:
181
133
  f" 95% CI : [{lo:.4f}, {hi:.4f}]",
182
134
  f" p-value : {self.pvalue:>10.4f}",
183
135
  f" N : {self._n:>10}",
184
- "",
185
- " Assumptions",
186
- " " + "┄" * 48,
187
136
  ]
188
- for a in IV_ASSUMPTIONS:
189
- lines.append(f" {a.fmt_tag()} {a.name}")
190
- lines.append("")
137
+ lines += self._assumptions_lines()
191
138
  return "\n".join(lines)
192
139
 
193
- def __repr__(self) -> str:
194
- return self.summary()
195
-
196
140
 
197
141
  class IV2SLS:
198
142
  """
@@ -7,6 +7,7 @@ import statsmodels.formula.api as smf
7
7
  from .._exceptions import IdentificationError
8
8
  from ..dag import DAG
9
9
  from ..refutations._check import Assumption
10
+ from ._base import _BaseResult
10
11
 
11
12
  MATCHING_ASSUMPTIONS: list[Assumption] = [
12
13
  Assumption("Conditional independence: no unobserved confounders given matched variables", testable=False),
@@ -65,7 +66,7 @@ def _att_from_ps(
65
66
  # ── Result ─────────────────────────────────────────────────────────────────────
66
67
 
67
68
 
68
- class MatchingResult:
69
+ class MatchingResult(_BaseResult):
69
70
  """
70
71
  The result of a propensity score matching estimation.
71
72
 
@@ -75,6 +76,8 @@ class MatchingResult:
75
76
  full matching procedure.
76
77
  """
77
78
 
79
+ _ASSUMPTIONS = MATCHING_ASSUMPTIONS
80
+
78
81
  def __init__(
79
82
  self,
80
83
  att: float,
@@ -136,11 +139,6 @@ class MatchingResult:
136
139
  """Full array of per-bootstrap ATT values, for diagnostics."""
137
140
  return self._bootstrap_atts.copy()
138
141
 
139
- @property
140
- def assumptions(self) -> list[Assumption]:
141
- """Modelling assumptions required for a causal interpretation."""
142
- return list(MATCHING_ASSUMPTIONS)
143
-
144
142
  def executive_summary(self) -> str:
145
143
  """Narrative explanation of the method, DAG, assumptions, and result."""
146
144
  from .._explain import explain_matching
@@ -179,35 +177,10 @@ class MatchingResult:
179
177
  f" N : {self._n:>10}",
180
178
  "",
181
179
  " Matching: 1-to-1 nearest-neighbour on propensity score (with replacement)",
182
- "",
183
- " Assumptions",
184
- " " + "┄" * 48,
185
180
  ]
186
- for a in MATCHING_ASSUMPTIONS:
187
- lines.append(f" {a.fmt_tag()} {a.name}")
188
- lines.append("")
181
+ lines += self._assumptions_lines()
189
182
  return "\n".join(lines)
190
183
 
191
- def decide(self, cost: float, benefit: float):
192
- """
193
- Compute a cost-benefit decision analysis from this causal estimate.
194
-
195
- Parameters
196
- ----------
197
- cost : float
198
- Cost per unit of treatment applied.
199
- benefit : float
200
- Benefit (revenue, utility, etc.) per unit increase in the outcome.
201
-
202
- Returns
203
- -------
204
- DecisionReport
205
- Optimal decision, net benefit, CI, confidence, and robustness flag.
206
- """
207
- from ..decision import _decide
208
-
209
- return _decide(self.effect, self.std_err, self.conf_int, self._treatment, self._outcome, cost, benefit)
210
-
211
184
  def refute(self, data: pd.DataFrame):
212
185
  """
213
186
  Run refutation checks against this matching estimation.
@@ -254,9 +227,6 @@ class MatchingResult:
254
227
  outcome=self._outcome,
255
228
  )
256
229
 
257
- def __repr__(self) -> str:
258
- return self.summary()
259
-
260
230
 
261
231
  # ── Estimator ──────────────────────────────────────────────────────────────────
262
232
 
@@ -6,6 +6,7 @@ import statsmodels.formula.api as smf
6
6
  from .._exceptions import IdentificationError
7
7
  from ..dag import DAG
8
8
  from ..refutations._check import Assumption
9
+ from ._base import _StatsmodelsResult
9
10
 
10
11
  OLS_ASSUMPTIONS: list[Assumption] = [
11
12
  Assumption("No unobserved confounders (selection on observables)", testable=False),
@@ -16,7 +17,7 @@ OLS_ASSUMPTIONS: list[Assumption] = [
16
17
  ]
17
18
 
18
19
 
19
- class OLSResult:
20
+ class OLSResult(_StatsmodelsResult):
20
21
  """
21
22
  The result of an OLS causal estimation.
22
23
 
@@ -25,6 +26,8 @@ class OLSResult:
25
26
  effect of controlling for confounders directly.
26
27
  """
27
28
 
29
+ _ASSUMPTIONS = OLS_ASSUMPTIONS
30
+
28
31
  def __init__(
29
32
  self,
30
33
  adjusted_result,
@@ -35,7 +38,7 @@ class OLSResult:
35
38
  dag,
36
39
  n: int,
37
40
  ) -> None:
38
- self._adjusted = adjusted_result
41
+ self._result = adjusted_result
39
42
  self._unadjusted = unadjusted_result
40
43
  self._treatment = treatment
41
44
  self._outcome = outcome
@@ -43,52 +46,21 @@ class OLSResult:
43
46
  self._dag = dag
44
47
  self._n = n
45
48
 
46
- @property
47
- def effect(self) -> float:
48
- """Adjusted point estimate: causal effect of treatment on outcome."""
49
- return float(self._adjusted.params[self._treatment])
50
-
51
49
  @property
52
50
  def unadjusted_effect(self) -> float:
53
51
  """Unadjusted point estimate: naive regression without controlling for confounders."""
54
52
  return float(self._unadjusted.params[self._treatment])
55
53
 
56
- @property
57
- def std_err(self) -> float:
58
- """Standard error of the adjusted treatment effect estimate."""
59
- return float(self._adjusted.bse[self._treatment])
60
-
61
- @property
62
- def conf_int(self) -> tuple[float, float]:
63
- """95% confidence interval for the adjusted treatment effect."""
64
- ci = self._adjusted.conf_int()
65
- return (float(ci.loc[self._treatment, 0]), float(ci.loc[self._treatment, 1]))
66
-
67
- @property
68
- def pvalue(self) -> float:
69
- """p-value for the adjusted treatment effect (``H0: effect = 0``)."""
70
- return float(self._adjusted.pvalues[self._treatment])
71
-
72
54
  @property
73
55
  def adjustment_set(self) -> set[str]:
74
56
  """Variables controlled for to satisfy the backdoor criterion."""
75
57
  return self._adjustment_set
76
58
 
77
- @property
78
- def statsmodels_result(self):
79
- """The underlying adjusted statsmodels result, for full diagnostics."""
80
- return self._adjusted
81
-
82
59
  @property
83
60
  def statsmodels_unadjusted_result(self):
84
61
  """The underlying unadjusted statsmodels result, for full diagnostics."""
85
62
  return self._unadjusted
86
63
 
87
- @property
88
- def assumptions(self) -> list[Assumption]:
89
- """Modelling assumptions required for a causal interpretation."""
90
- return list(OLS_ASSUMPTIONS)
91
-
92
64
  def executive_summary(self) -> str:
93
65
  """Narrative explanation of the method, DAG, assumptions, and result."""
94
66
  from .._explain import explain_ols
@@ -124,35 +96,10 @@ class OLSResult:
124
96
  f" 95% CI : [{lo:.4f}, {hi:.4f}]",
125
97
  f" p-value : {self.pvalue:>10.4f}",
126
98
  f" N : {self._n:>10}",
127
- "",
128
- " Assumptions",
129
- " " + "┄" * 48,
130
99
  ]
131
- for a in OLS_ASSUMPTIONS:
132
- lines.append(f" {a.fmt_tag()} {a.name}")
133
- lines.append("")
100
+ lines += self._assumptions_lines()
134
101
  return "\n".join(lines)
135
102
 
136
- def decide(self, cost: float, benefit: float):
137
- """
138
- Compute a cost-benefit decision analysis from this causal estimate.
139
-
140
- Parameters
141
- ----------
142
- cost : float
143
- Cost per unit of treatment applied.
144
- benefit : float
145
- Benefit (revenue, utility, etc.) per unit increase in the outcome.
146
-
147
- Returns
148
- -------
149
- DecisionReport
150
- Optimal decision, net benefit, CI, confidence, and robustness flag.
151
- """
152
- from ..decision import _decide
153
-
154
- return _decide(self.effect, self.std_err, self.conf_int, self._treatment, self._outcome, cost, benefit)
155
-
156
103
  def refute(self, data: pd.DataFrame):
157
104
  """
158
105
  Run refutation checks against this OLS estimation.
@@ -186,9 +133,6 @@ class OLSResult:
186
133
  outcome=self._outcome,
187
134
  )
188
135
 
189
- def __repr__(self) -> str:
190
- return self.summary()
191
-
192
136
 
193
137
  class OLSObservational:
194
138
  """
@@ -5,6 +5,7 @@ import statsmodels.formula.api as smf
5
5
 
6
6
  from ..dag import DAG
7
7
  from ..refutations._check import Assumption
8
+ from ._base import _StatsmodelsResult
8
9
 
9
10
  RCT_ASSUMPTIONS: list[Assumption] = [
10
11
  Assumption("Random assignment of treatment", testable=False),
@@ -13,7 +14,7 @@ RCT_ASSUMPTIONS: list[Assumption] = [
13
14
  ]
14
15
 
15
16
 
16
- class RCTResult:
17
+ class RCTResult(_StatsmodelsResult):
17
18
  """
18
19
  The result of an RCT causal estimation.
19
20
 
@@ -22,6 +23,8 @@ class RCTResult:
22
23
  equals the difference in mean outcomes between treatment and control.
23
24
  """
24
25
 
26
+ _ASSUMPTIONS = RCT_ASSUMPTIONS
27
+
25
28
  def __init__(
26
29
  self,
27
30
  result,
@@ -36,37 +39,6 @@ class RCTResult:
36
39
  self._dag = dag
37
40
  self._n = n
38
41
 
39
- @property
40
- def effect(self) -> float:
41
- """ATE: average treatment effect (difference in means)."""
42
- return float(self._result.params[self._treatment])
43
-
44
- @property
45
- def std_err(self) -> float:
46
- """Standard error of the ATE estimate."""
47
- return float(self._result.bse[self._treatment])
48
-
49
- @property
50
- def conf_int(self) -> tuple[float, float]:
51
- """95% confidence interval for the ATE."""
52
- ci = self._result.conf_int()
53
- return (float(ci.loc[self._treatment, 0]), float(ci.loc[self._treatment, 1]))
54
-
55
- @property
56
- def pvalue(self) -> float:
57
- """p-value for the ATE (``H0: ATE = 0``)."""
58
- return float(self._result.pvalues[self._treatment])
59
-
60
- @property
61
- def statsmodels_result(self):
62
- """The underlying statsmodels OLS result, for full diagnostics."""
63
- return self._result
64
-
65
- @property
66
- def assumptions(self) -> list[Assumption]:
67
- """Modelling assumptions required for a causal interpretation."""
68
- return list(RCT_ASSUMPTIONS)
69
-
70
42
  def executive_summary(self) -> str:
71
43
  """Narrative explanation of the method, DAG, assumptions, and result."""
72
44
  from .._explain import explain_rct
@@ -87,35 +59,10 @@ class RCTResult:
87
59
  f" 95% CI : [{lo:.4f}, {hi:.4f}]",
88
60
  f" p-value : {self.pvalue:>10.4f}",
89
61
  f" N : {self._n:>10}",
90
- "",
91
- " Assumptions",
92
- " " + "┄" * 48,
93
62
  ]
94
- for a in RCT_ASSUMPTIONS:
95
- lines.append(f" {a.fmt_tag()} {a.name}")
96
- lines.append("")
63
+ lines += self._assumptions_lines()
97
64
  return "\n".join(lines)
98
65
 
99
- def decide(self, cost: float, benefit: float):
100
- """
101
- Compute a cost-benefit decision analysis from this causal estimate.
102
-
103
- Parameters
104
- ----------
105
- cost : float
106
- Cost per unit of treatment applied.
107
- benefit : float
108
- Benefit (revenue, utility, etc.) per unit increase in the outcome.
109
-
110
- Returns
111
- -------
112
- DecisionReport
113
- Optimal decision, net benefit, CI, confidence, and robustness flag.
114
- """
115
- from ..decision import _decide
116
-
117
- return _decide(self.effect, self.std_err, self.conf_int, self._treatment, self._outcome, cost, benefit)
118
-
119
66
  def refute(self, data: pd.DataFrame):
120
67
  """
121
68
  Run refutation checks against this RCT estimation.
@@ -149,9 +96,6 @@ class RCTResult:
149
96
  outcome=self._outcome,
150
97
  )
151
98
 
152
- def __repr__(self) -> str:
153
- return self.summary()
154
-
155
99
 
156
100
  class RCT:
157
101
  """
@@ -5,6 +5,7 @@ import statsmodels.formula.api as smf
5
5
 
6
6
  from ..dag import DAG
7
7
  from ..refutations._check import Assumption
8
+ from ._base import _StatsmodelsResult
8
9
 
9
10
  RDD_ASSUMPTIONS: list[Assumption] = [
10
11
  Assumption(
@@ -23,7 +24,7 @@ RDD_ASSUMPTIONS: list[Assumption] = [
23
24
  ]
24
25
 
25
26
 
26
- class RDDResult:
27
+ class RDDResult(_StatsmodelsResult):
27
28
  """
28
29
  The result of a Regression Discontinuity Design estimation.
29
30
 
@@ -31,6 +32,8 @@ class RDDResult:
31
32
  the cutoff, estimated via local linear regression on both sides of the threshold.
32
33
  """
33
34
 
35
+ _ASSUMPTIONS = RDD_ASSUMPTIONS
36
+
34
37
  def __init__(
35
38
  self,
36
39
  result,
@@ -51,42 +54,11 @@ class RDDResult:
51
54
  self._unadjusted_effect = unadjusted_effect
52
55
  self._dag = dag
53
56
 
54
- @property
55
- def effect(self) -> float:
56
- """LATE at the cutoff: the jump in outcome at the threshold (coefficient on the treatment indicator)."""
57
- return float(self._result.params[self._treatment])
58
-
59
57
  @property
60
58
  def unadjusted_effect(self) -> float:
61
59
  """Naive mean difference: above-cutoff mean minus below-cutoff mean."""
62
60
  return self._unadjusted_effect
63
61
 
64
- @property
65
- def std_err(self) -> float:
66
- """Standard error of the LATE at the cutoff estimate."""
67
- return float(self._result.bse[self._treatment])
68
-
69
- @property
70
- def conf_int(self) -> tuple[float, float]:
71
- """95% confidence interval for the LATE at the cutoff estimate."""
72
- ci = self._result.conf_int()
73
- return (float(ci.loc[self._treatment, 0]), float(ci.loc[self._treatment, 1]))
74
-
75
- @property
76
- def pvalue(self) -> float:
77
- """p-value for the LATE at the cutoff (``H0: LATE at cutoff = 0``)."""
78
- return float(self._result.pvalues[self._treatment])
79
-
80
- @property
81
- def statsmodels_result(self):
82
- """The underlying statsmodels OLS result, for full diagnostics."""
83
- return self._result
84
-
85
- @property
86
- def assumptions(self) -> list[Assumption]:
87
- """Modelling assumptions required for a causal interpretation."""
88
- return list(RDD_ASSUMPTIONS)
89
-
90
62
  @property
91
63
  def cutoff(self) -> float:
92
64
  """The threshold value that determines treatment assignment."""
@@ -131,35 +103,10 @@ class RDDResult:
131
103
  f" 95% CI : [{lo:.4f}, {hi:.4f}]",
132
104
  f" p-value : {self.pvalue:>10.4f}",
133
105
  f" N (in bandwidth) : {self.n_obs:>9}",
134
- "",
135
- " Assumptions",
136
- " " + "\u2504" * 30,
137
106
  ]
138
- for a in RDD_ASSUMPTIONS:
139
- lines.append(f" {a.fmt_tag()} {a.name}")
140
- lines.append("")
107
+ lines += self._assumptions_lines(width=30)
141
108
  return "\n".join(lines)
142
109
 
143
- def decide(self, cost: float, benefit: float):
144
- """
145
- Compute a cost-benefit decision analysis from this causal estimate.
146
-
147
- Parameters
148
- ----------
149
- cost : float
150
- Cost per unit of treatment applied.
151
- benefit : float
152
- Benefit (revenue, utility, etc.) per unit increase in the outcome.
153
-
154
- Returns
155
- -------
156
- DecisionReport
157
- Optimal decision, net benefit, CI, confidence, and robustness flag.
158
- """
159
- from ..decision import _decide
160
-
161
- return _decide(self.effect, self.std_err, self.conf_int, self._treatment, self._outcome, cost, benefit)
162
-
163
110
  def refute(self, data: pd.DataFrame):
164
111
  """
165
112
  Run refutation checks against this RDD estimation.
@@ -210,9 +157,6 @@ class RDDResult:
210
157
  outcome=self._outcome,
211
158
  )
212
159
 
213
- def __repr__(self) -> str:
214
- return self.summary()
215
-
216
160
 
217
161
  class RDD:
218
162
  """
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "formative-ds"
7
- version = "1.2.0"
7
+ version = "1.2.2"
8
8
  description = "A Python library for quantitative reasoning."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,6 +25,7 @@ classifiers = [
25
25
 
26
26
  dependencies = [
27
27
  "numpy>=2,<3",
28
+ "scipy>=1.11,<2",
28
29
  "statsmodels>=0.14,<0.15",
29
30
  "pandas>=2.1,<4",
30
31
  ]
@@ -42,7 +43,7 @@ docs = ["sphinx>=7,<10", "sphinx-rtd-theme>=2,<4", "sphinx-copybutton>=0.5,<0.6"
42
43
  packages = ["formative"]
43
44
 
44
45
  [tool.bumpversion]
45
- current_version = "1.2.0"
46
+ current_version = "1.2.2"
46
47
  commit = true
47
48
  tag = true
48
49
  tag_name = "v{new_version}"
@@ -303,13 +303,15 @@ wheels = [
303
303
 
304
304
  [[package]]
305
305
  name = "formative-ds"
306
- version = "1.1.1"
306
+ version = "1.2.1"
307
307
  source = { editable = "." }
308
308
  dependencies = [
309
309
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
310
310
  { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
311
311
  { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
312
312
  { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
313
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
314
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
313
315
  { name = "statsmodels" },
314
316
  ]
315
317
 
@@ -334,6 +336,7 @@ docs = [
334
336
  requires-dist = [
335
337
  { name = "numpy", specifier = ">=2,<3" },
336
338
  { name = "pandas", specifier = ">=2.1,<4" },
339
+ { name = "scipy", specifier = ">=1.11,<2" },
337
340
  { name = "statsmodels", specifier = ">=0.14,<0.15" },
338
341
  ]
339
342
 
@@ -352,11 +355,11 @@ docs = [
352
355
 
353
356
  [[package]]
354
357
  name = "idna"
355
- version = "3.11"
358
+ version = "3.15"
356
359
  source = { registry = "https://pypi.org/simple" }
357
- sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
360
+ sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
358
361
  wheels = [
359
- { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
362
+ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
360
363
  ]
361
364
 
362
365
  [[package]]
@@ -797,16 +800,16 @@ wheels = [
797
800
 
798
801
  [[package]]
799
802
  name = "pygments"
800
- version = "2.19.2"
803
+ version = "2.20.0"
801
804
  source = { registry = "https://pypi.org/simple" }
802
- sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
805
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
803
806
  wheels = [
804
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
807
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
805
808
  ]
806
809
 
807
810
  [[package]]
808
811
  name = "pytest"
809
- version = "9.0.2"
812
+ version = "9.0.3"
810
813
  source = { registry = "https://pypi.org/simple" }
811
814
  dependencies = [
812
815
  { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -817,9 +820,9 @@ dependencies = [
817
820
  { name = "pygments" },
818
821
  { name = "tomli", marker = "python_full_version < '3.11'" },
819
822
  ]
820
- sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
823
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
821
824
  wheels = [
822
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
825
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
823
826
  ]
824
827
 
825
828
  [[package]]
@@ -1438,9 +1441,9 @@ wheels = [
1438
1441
 
1439
1442
  [[package]]
1440
1443
  name = "urllib3"
1441
- version = "2.6.3"
1444
+ version = "2.7.0"
1442
1445
  source = { registry = "https://pypi.org/simple" }
1443
- sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
1446
+ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
1444
1447
  wheels = [
1445
- { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
1448
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
1446
1449
  ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes