tseffects 0.4.1__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.
tseffects/__init__.py ADDED
@@ -0,0 +1,120 @@
1
+ r"""Dynamic effects from single-equation time series models, with interactions.
2
+
3
+ Autoregressive distributed lag (ADL) models and their reparameterized
4
+ equivalent, the Generalized Error-Correction Model (GECM), are easy to
5
+ estimate. What is harder is getting a rich set of dynamic inferences back out
6
+ of them: the coefficients alone do not tell you how a shock to x moves y three
7
+ periods later, nor what happens when the shock is permanent rather than
8
+ transitory, nor what happens when the model was estimated in differences but
9
+ you want the answer in levels.
10
+
11
+ This package recovers those inferences. Everything is built from the Impulse
12
+ Response Function and the Step Response Function, generalized into one family
13
+ -- the General Dynamic Response Function -- indexed by the order of the shock
14
+ history :math:`h` and the orders of differencing :math:`d_x` and :math:`d_y`:
15
+
16
+ .. math::
17
+
18
+ \mathrm{GDRF}_{(s,\,h,\,d_y,\,d_x)}
19
+ = \sum_{j=0}^{s} w_j(h, d_x, d_y)\, \xi_{s-j},
20
+
21
+ where :math:`\xi_s` is the period-:math:`s` impulse response and the weights
22
+ are generalized binomial coefficients. Arbitrary shock histories are handled
23
+ too, since any history decomposes into a sum of dated impulses.
24
+
25
+ Every quantity is produced as an algebraic *formula* in the model's coefficient
26
+ names first, and only then evaluated by the delta method. That is what lets one
27
+ standard-error machinery cover impulse responses, cumulated responses, long-run
28
+ multipliers, fitted trajectories and conditional effects alike -- and it means
29
+ you can read the formula behind any number the package reports.
30
+
31
+ Getting started
32
+ ---------------
33
+ >>> import statsmodels.formula.api as smf
34
+ >>> from tseffects import load_toy_data, gdrf_adl
35
+ >>> data = load_toy_data()
36
+ >>> fit = smf.ols("y ~ l_1_y + x + l_1_x", data=data).fit()
37
+ >>> out = gdrf_adl(fit,
38
+ ... x_vrbl={"x": 0, "l_1_x": 1},
39
+ ... y_vrbl={"l_1_y": 1},
40
+ ... shock_history="pulse", s_limit=10)
41
+ >>> out.estimates.head(3).round(4)
42
+ Period GDRF SE Lower Upper
43
+ 0 0 0.6384 0.2608 0.1273 1.1496
44
+ 1 1 0.3918 0.3184 -0.2322 1.0158
45
+ 2 2 0.2987 0.2475 -0.1864 0.7839
46
+
47
+ ``out.plot()`` draws it, and ``out.formulae`` gives the algebra behind each row.
48
+
49
+ This is a Python port of the R package **tseffects** by Soren Jordan, Garrett N.
50
+ Vande Kamp and Reshi Rajan. The method is documented in Warner, Vande Kamp and
51
+ Jordan, "Conditional Relationships in Dynamic Models", *Political Science
52
+ Research and Methods* (2026),
53
+ `doi:10.1017/psrm.2026.10087 <https://doi.org/10.1017/psrm.2026.10087>`_, and in
54
+ Vande Kamp and Jordan, "The Necessity of Moving Averages in Dynamic Linear
55
+ Regression Models", *American Journal of Political Science* 69(1): 176-193
56
+ (2025).
57
+ """
58
+
59
+ from ._delta import BatchDelta, DeltaFn, DeltaResult, delta_method
60
+ from ._vcov import VCOV_TYPES, vcov_hc
61
+ from .baselines import yhat_calculator
62
+ from .calculators import (
63
+ Formulae,
64
+ convolution_calculator,
65
+ gecm_to_adl,
66
+ general_calculator,
67
+ pulse_calculator,
68
+ shock_weights,
69
+ )
70
+ from .datasets import load_approval, load_toy_data
71
+ from .gdrf import adl_effects, gdrf_adl, gdrf_gecm, gecm_effects
72
+ from .interact import combine_interaction_terms, interact_adl
73
+ from .plots import (
74
+ OKABE_ITO,
75
+ apply_theme,
76
+ plot_effects,
77
+ plot_gdrf,
78
+ plot_interact_heatmap,
79
+ plot_interact_lines,
80
+ )
81
+ from .results import EffectResult
82
+
83
+ __version__ = "0.4.1"
84
+
85
+ __all__ = [
86
+ # Main entry points
87
+ "gdrf_adl",
88
+ "adl_effects",
89
+ "gdrf_gecm",
90
+ "gecm_effects",
91
+ "interact_adl",
92
+ # Symbolic building blocks
93
+ "pulse_calculator",
94
+ "general_calculator",
95
+ "convolution_calculator",
96
+ "gecm_to_adl",
97
+ "yhat_calculator",
98
+ "combine_interaction_terms",
99
+ "shock_weights",
100
+ "Formulae",
101
+ # Inference
102
+ "delta_method",
103
+ "DeltaFn",
104
+ "BatchDelta",
105
+ "DeltaResult",
106
+ "vcov_hc",
107
+ "VCOV_TYPES",
108
+ # Results and plots
109
+ "EffectResult",
110
+ "plot_effects",
111
+ "plot_gdrf",
112
+ "plot_interact_lines",
113
+ "plot_interact_heatmap",
114
+ "apply_theme",
115
+ "OKABE_ITO",
116
+ # Data
117
+ "load_toy_data",
118
+ "load_approval",
119
+ "__version__",
120
+ ]
tseffects/_checks.py ADDED
@@ -0,0 +1,445 @@
1
+ """Argument validation.
2
+
3
+ These mirror ``GDRF.dummy.checks``, ``adl.dummy.checks`` and
4
+ ``gecm.dummy.checks`` in the R package, including which situations warn and
5
+ which stop, so a specification that R rejects is rejected here with the same
6
+ reasoning.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+
13
+ import numpy as np
14
+
15
+ from ._vcov import VCOV_TYPES
16
+
17
+ EFFECT_TYPES = ("discrete", "fitted")
18
+ INFERENCES = ("levels", "differences")
19
+
20
+
21
+ def _effect_label(kind: str) -> str:
22
+ return "dynamic conditional effect" if kind == "interact" else "GDRF"
23
+
24
+
25
+ def check_effect_type(effect_type: str) -> None:
26
+ if effect_type not in EFFECT_TYPES:
27
+ raise ValueError(
28
+ "Invalid effect_type. effect_type must be one of 'discrete' or 'fitted'"
29
+ )
30
+
31
+
32
+ def check_se_type(se_type: str) -> None:
33
+ if se_type not in VCOV_TYPES:
34
+ raise ValueError(
35
+ f"Invalid se_type {se_type!r}. se_type must be one of: "
36
+ + ", ".join(VCOV_TYPES)
37
+ )
38
+
39
+
40
+ def check_integer(value, name: str) -> int:
41
+ if value is None:
42
+ raise ValueError(f"{name} must be specified")
43
+ if isinstance(value, bool) or not isinstance(value, (int, np.integer, float)):
44
+ raise ValueError(f"{name} must be an integer")
45
+ if float(value) % 1 != 0:
46
+ raise ValueError(f"{name} must be an integer")
47
+ return int(value)
48
+
49
+
50
+ def check_gdrf_arguments(
51
+ effect_type: str,
52
+ prediction_values,
53
+ baseline_y,
54
+ baseline_y_se,
55
+ shock_size,
56
+ d_y: int,
57
+ inferences_y: str,
58
+ kind: str = "GDRF",
59
+ ) -> None:
60
+ """The shared baseline/shock-size checks for anything producing a GDRF."""
61
+ label = _effect_label(kind)
62
+ check_effect_type(effect_type)
63
+
64
+ if isinstance(shock_size, bool) or not isinstance(
65
+ shock_size, (int, float, np.integer, np.floating)
66
+ ):
67
+ raise ValueError("shock_size must be numeric")
68
+ if np.ndim(shock_size) != 0:
69
+ raise ValueError(
70
+ "shock_size should be a single value by which to scale the entire "
71
+ "shock_history"
72
+ )
73
+
74
+ if effect_type == "discrete":
75
+ if prediction_values is not None:
76
+ raise ValueError(
77
+ f"Do not supply prediction_values when effect_type = 'discrete'. "
78
+ f"The {label} is the discrete effect, which takes no predicted values"
79
+ )
80
+ if baseline_y is not None and baseline_y != 0:
81
+ raise ValueError(
82
+ f"Do not supply baseline_y when effect_type = 'discrete'. The "
83
+ f"{label} is the discrete effect, which is always compared to 0"
84
+ )
85
+ if baseline_y_se is not None and baseline_y_se != 0:
86
+ raise ValueError(
87
+ f"Do not supply baseline_y_se when effect_type = 'discrete'. The "
88
+ f"{label} is the discrete effect, which has its own standard errors"
89
+ )
90
+ return
91
+
92
+ # Everything below is effect_type == "fitted".
93
+ if prediction_values is None and baseline_y is None:
94
+ raise ValueError(
95
+ "You must provide either a baseline_y value or prediction_values (to "
96
+ "calculate a steady state). If unsure, set baseline_y = 0 (no assumed "
97
+ "starting value for y)"
98
+ )
99
+ if prediction_values is not None and not isinstance(prediction_values, dict):
100
+ raise ValueError(
101
+ "prediction_values must be a mapping of {variable: value}. Be sure to "
102
+ "set any differenced variables to 0"
103
+ )
104
+ if prediction_values is not None and baseline_y_se not in (0, 0.0):
105
+ raise ValueError(
106
+ "If using prediction_values, leave baseline_y_se at 0 and let the model "
107
+ "supply model-based uncertainty around the steady state"
108
+ )
109
+ if baseline_y is not None:
110
+ if isinstance(baseline_y, bool) or not isinstance(
111
+ baseline_y, (int, float, np.integer, np.floating)
112
+ ):
113
+ raise ValueError("If providing a baseline_y, it must be numeric")
114
+ if np.ndim(baseline_y) != 0:
115
+ raise ValueError("Only provide a single baseline_y value")
116
+ if baseline_y_se is not None:
117
+ if isinstance(baseline_y_se, bool) or not isinstance(
118
+ baseline_y_se, (int, float, np.integer, np.floating)
119
+ ):
120
+ raise ValueError("If providing a baseline_y_se, it must be numeric")
121
+ if np.ndim(baseline_y_se) != 0:
122
+ raise ValueError("Only provide a single baseline_y_se value")
123
+
124
+ if d_y == 0:
125
+ if baseline_y is not None:
126
+ if prediction_values is not None:
127
+ warnings.warn(
128
+ "Both baseline_y and prediction_values supplied; baseline_y "
129
+ "takes precedence and prediction_values will be ignored",
130
+ stacklevel=3,
131
+ )
132
+ else:
133
+ warnings.warn(
134
+ "If any differenced variables are included in the model, ensure "
135
+ "they are set to 0 in prediction_values for a meaningful "
136
+ "steady-state prediction. Multiple lags of the same variable "
137
+ "should also be set to the same value",
138
+ stacklevel=3,
139
+ )
140
+ elif d_y > 0:
141
+ if prediction_values is not None:
142
+ warnings.warn(
143
+ "prediction_values is ignored when d_y > 0: there is no "
144
+ "steady-state prediction for y",
145
+ stacklevel=3,
146
+ )
147
+ if inferences_y == "differences":
148
+ if baseline_y is not None and baseline_y != 0:
149
+ warnings.warn(
150
+ "Assuming anything other than baseline_y = 0 when "
151
+ "inferences_y = 'differences' suggests the model is unstable "
152
+ "(y is always changing). Using 0 as baseline_y",
153
+ stacklevel=3,
154
+ )
155
+ if shock_size == 1:
156
+ warnings.warn(
157
+ "effect_type = 'fitted' with inferences_y = 'differences' and "
158
+ "shock_size = 1 is identical to effect_type = 'discrete'; "
159
+ "returning formulae unchanged",
160
+ stacklevel=3,
161
+ )
162
+ elif inferences_y == "levels" and baseline_y is None:
163
+ raise ValueError(
164
+ "There is no baseline for y if effect_type = 'fitted' and d_y > 0. "
165
+ "Specify a baseline through baseline_y (with optional uncertainty "
166
+ "through baseline_y_se). If you just want to observe changes with "
167
+ "no meaningful baseline, use baseline_y = 0 and baseline_y_se = 0"
168
+ )
169
+
170
+
171
+ def check_adl_arguments(
172
+ model_info,
173
+ x_vrbl,
174
+ y_vrbl,
175
+ z_vrbl=None,
176
+ x_z_vrbl=None,
177
+ d_x=None,
178
+ d_y=None,
179
+ inferences_x=None,
180
+ inferences_y=None,
181
+ se_type: str = "const",
182
+ kind: str = "GDRF",
183
+ ) -> None:
184
+ """Checks specific to an ADL specification."""
185
+ label = _effect_label(kind)
186
+ check_se_type(se_type)
187
+
188
+ if x_vrbl is None:
189
+ raise ValueError(
190
+ f"Variables in {label} terms (x and possibly y) must be specified "
191
+ "through x_vrbl and possibly y_vrbl"
192
+ )
193
+ if y_vrbl is None:
194
+ warnings.warn(
195
+ f"No y_vrbl in {label} terms implies a static or finite dynamics "
196
+ "model: are you sure you want this?",
197
+ stacklevel=3,
198
+ )
199
+
200
+ model_info.require(x_vrbl, "x_vrbl")
201
+ model_info.require(y_vrbl, "y_vrbl")
202
+
203
+ if kind == "GDRF":
204
+ d_x = check_integer(d_x, "d_x")
205
+ d_y = check_integer(d_y, "d_y")
206
+ if inferences_x not in INFERENCES:
207
+ raise ValueError(
208
+ "Invalid inferences_x. The shock_history for x must be either "
209
+ "'levels' or 'differences'"
210
+ )
211
+ if inferences_y not in INFERENCES:
212
+ raise ValueError(
213
+ "Invalid inferences_y. The response function for y must be either "
214
+ "'levels' or 'differences'"
215
+ )
216
+ if inferences_x == "differences" and d_x == 0:
217
+ raise ValueError(
218
+ "Invalid inferences_x. The shock_history for x cannot be in a "
219
+ "higher order of differencing (d_x) than the original independent "
220
+ "variable"
221
+ )
222
+ if inferences_y == "differences" and d_y == 0:
223
+ raise ValueError(
224
+ "Invalid inferences_y. The response function for y cannot be in a "
225
+ "higher order of differencing (d_y) than the original dependent "
226
+ "variable"
227
+ )
228
+
229
+ if kind == "interact":
230
+ if z_vrbl is None or x_z_vrbl is None:
231
+ raise ValueError(
232
+ "Variables in the interaction term (x, z and x:z) must be "
233
+ "specified through x_vrbl, z_vrbl and x_z_vrbl"
234
+ )
235
+ model_info.require(z_vrbl, "z_vrbl")
236
+ model_info.require(x_z_vrbl, "x_z_vrbl")
237
+
238
+
239
+ def check_gecm_arguments(
240
+ model_info,
241
+ x_vrbl,
242
+ y_vrbl,
243
+ x_d_vrbl,
244
+ y_d_vrbl,
245
+ x_vrbl_d_x,
246
+ y_vrbl_d_y,
247
+ x_d_vrbl_d_x,
248
+ y_d_vrbl_d_y,
249
+ inferences_x: str,
250
+ inferences_y: str,
251
+ se_type: str = "const",
252
+ kind: str = "GDRF",
253
+ ) -> None:
254
+ """Checks specific to a GECM specification."""
255
+ label = _effect_label(kind)
256
+ check_se_type(se_type)
257
+
258
+ if x_vrbl is None or y_vrbl is None or x_d_vrbl is None:
259
+ raise ValueError(
260
+ f"Variables in {label} terms (x and y) and lagged differences must be "
261
+ "specified through x_vrbl, y_vrbl, x_d_vrbl and (possibly) y_d_vrbl "
262
+ "for a GECM"
263
+ )
264
+
265
+ x_vrbl_d_x = check_integer(x_vrbl_d_x, "x_vrbl_d_x")
266
+ y_vrbl_d_y = check_integer(y_vrbl_d_y, "y_vrbl_d_y")
267
+ x_d_vrbl_d_x = check_integer(x_d_vrbl_d_x, "x_d_vrbl_d_x")
268
+ y_d_vrbl_d_y = check_integer(y_d_vrbl_d_y, "y_d_vrbl_d_y")
269
+
270
+ if x_d_vrbl_d_x - x_vrbl_d_x != 1:
271
+ raise ValueError(
272
+ "In a GECM, the variable in differences should be one order of "
273
+ "differencing from the variable in levels. Check the order of "
274
+ "differencing of x"
275
+ )
276
+ if y_d_vrbl is not None and y_d_vrbl_d_y - y_vrbl_d_y != 1:
277
+ raise ValueError(
278
+ "In a GECM, the variable in differences should be one order of "
279
+ "differencing from the variable in levels. Check the order of "
280
+ "differencing of y"
281
+ )
282
+
283
+ if len(x_vrbl) != 1 or len(y_vrbl) != 1:
284
+ raise ValueError(
285
+ "In a GECM, include only the first lag of the variable in levels "
286
+ "(x_vrbl and y_vrbl)"
287
+ )
288
+ if list(x_vrbl.values())[0] != 1 or list(y_vrbl.values())[0] != 1:
289
+ raise ValueError(
290
+ "In a GECM, include only the first lag of the variable in levels "
291
+ "(x_vrbl and y_vrbl)"
292
+ )
293
+
294
+ if inferences_x != "levels" or inferences_y != "levels":
295
+ raise ValueError(
296
+ f"In a GECM, inferences regarding the {label} of x on y are "
297
+ "automatically recovered in levels"
298
+ )
299
+
300
+ model_info.require(x_vrbl, "x_vrbl")
301
+ model_info.require(y_vrbl, "y_vrbl")
302
+ model_info.require(x_d_vrbl, "x_d_vrbl")
303
+ model_info.require(y_d_vrbl, "y_d_vrbl")
304
+
305
+
306
+ def resolve_shock_history(shock_history, s_limit: int, cumulative: bool) -> dict:
307
+ """Validate the shock history and convert a word to its integer order.
308
+
309
+ ``"pulse"`` / ``"impulse"`` is :math:`h = -1` and ``"step"`` is
310
+ :math:`h = 0`; an integer is taken as :math:`h` directly; a sequence of
311
+ length ``s_limit + 1`` is a custom history. When ``cumulative`` is set, a
312
+ custom history is cumulated and an integer order is incremented by one.
313
+
314
+ Returns
315
+ -------
316
+ dict
317
+ ``h_order`` (used for the calculation), ``h_order_plot`` (the order the
318
+ user asked for, used for axis labels) and ``shock_history``.
319
+ """
320
+ if shock_history is None:
321
+ raise ValueError("shock_history must be specified")
322
+
323
+ custom = None
324
+ h_order = None
325
+
326
+ if isinstance(shock_history, str):
327
+ if shock_history in ("pulse", "impulse"):
328
+ h_order = -1
329
+ elif shock_history == "step":
330
+ h_order = 0
331
+ else:
332
+ raise ValueError(
333
+ "Invalid shock_history. shock_history must be one of 'pulse', "
334
+ "'impulse' or 'step', a sequence for a custom shock history, or "
335
+ "an integer h representing the order of the GDRF"
336
+ )
337
+ elif isinstance(shock_history, bool):
338
+ raise ValueError("Invalid shock_history: booleans are not shock histories")
339
+ elif isinstance(shock_history, (int, np.integer)):
340
+ h_order = int(shock_history)
341
+ elif isinstance(shock_history, (float, np.floating)):
342
+ if float(shock_history) % 1 != 0:
343
+ raise ValueError(
344
+ "Invalid shock_history. A single-value shock_history is the "
345
+ "integer order h"
346
+ )
347
+ h_order = int(shock_history)
348
+ else:
349
+ custom = np.asarray(shock_history, dtype=float).ravel()
350
+ if custom.size == 1:
351
+ h_order, custom = int(custom[0]), None
352
+ elif custom.size != s_limit + 1:
353
+ raise ValueError(
354
+ f"Invalid shock_history. A custom shock history must be of length "
355
+ f"s_limit + 1 ({s_limit + 1}). The supplied shock history has "
356
+ f"length {custom.size}"
357
+ )
358
+
359
+ h_order_plot = h_order
360
+ if cumulative:
361
+ if custom is not None:
362
+ custom = np.cumsum(custom)
363
+ else:
364
+ h_order = h_order + 1
365
+
366
+ return {
367
+ "h_order": h_order,
368
+ "h_order_plot": h_order_plot,
369
+ "shock_history": custom if custom is not None else h_order_plot,
370
+ "custom": custom,
371
+ }
372
+
373
+
374
+ def adjust_inferences(inferences_y: str, inferences_x: str, d_y: int, d_x: int) -> dict:
375
+ """Split the orders of differencing into calculation and labelling parts.
376
+
377
+ Asking for inferences in the *differences* of a variable means the
378
+ calculation needs no unwinding for it, but the plot label still has to say
379
+ the response is in differences -- so each order is carried in two places.
380
+ """
381
+ if inferences_y == "differences":
382
+ calc_d_y, plot_d_y = 0, d_y
383
+ else:
384
+ calc_d_y, plot_d_y = d_y, 0
385
+ if inferences_x == "differences":
386
+ calc_d_x, plot_d_x = 0, d_x
387
+ else:
388
+ calc_d_x, plot_d_x = d_x, 0
389
+ return {
390
+ "calc_d_x": calc_d_x,
391
+ "calc_d_y": calc_d_y,
392
+ "plot_d_x": plot_d_x,
393
+ "plot_d_y": plot_d_y,
394
+ }
395
+
396
+
397
+ def lrm_exists(
398
+ custom_history,
399
+ h_order_plot,
400
+ cumulative: bool,
401
+ calc_d_x: int,
402
+ calc_d_y: int,
403
+ effect_type: str,
404
+ return_lrm: bool,
405
+ ) -> bool:
406
+ r"""Whether a Long-Run Multiplier is well defined and should be reported.
407
+
408
+ The LRM exists when :math:`h - d_x + d_y = 0`: the effects then settle on a
409
+ finite non-zero value. If that expression is negative the effects decay to
410
+ zero, and if it is positive they diverge, so there is nothing to report
411
+ either way. Fitted values are excluded entirely, since they are
412
+ predictions rather than effects.
413
+ """
414
+ if not return_lrm or effect_type == "fitted":
415
+ return False
416
+
417
+ if custom_history is not None:
418
+ if not cumulative:
419
+ # A custom history without cumulation has no limiting value to read.
420
+ return False
421
+ warnings.warn(
422
+ "The LRM condition (h - d_x + d_y = 0) holds for integer h but is "
423
+ "not guaranteed to hold for a custom shock history. Interpret the "
424
+ "LRM with caution",
425
+ stacklevel=3,
426
+ )
427
+ return True
428
+
429
+ h = int(h_order_plot) + (1 if cumulative else 0)
430
+ condition = h - calc_d_x + calc_d_y
431
+ if condition == 0:
432
+ return True
433
+ if condition < 0:
434
+ warnings.warn(
435
+ "return_lrm = True but the effects decay to 0 as s -> infinity. No "
436
+ "LRM is returned",
437
+ stacklevel=3,
438
+ )
439
+ else:
440
+ warnings.warn(
441
+ "return_lrm = True but the effects diverge to +/-infinity as "
442
+ "s -> infinity. No LRM is returned",
443
+ stacklevel=3,
444
+ )
445
+ return False