modelflowib 2.73__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.
- modelBLfunk.py +180 -0
- model_Excel.py +332 -0
- model_cvx.py +139 -0
- model_dynare.py +173 -0
- model_financial_stability.py +88 -0
- model_latex.py +497 -0
- model_latex_class.py +808 -0
- model_parquet_mixin.py +424 -0
- modelclass.py +9828 -0
- modelconstruct.py +1496 -0
- modelconstruct_estimation.py +2872 -0
- modeldash.py +265 -0
- modeldashboot.py +202 -0
- modeldashsidebar.py +456 -0
- modeldekom.py +651 -0
- modeldiff.py +561 -0
- modeldisplay.py +550 -0
- modelestimation.py +1776 -0
- modelestimator_new.py +2613 -0
- modelflowib-2.73.dist-info/METADATA +156 -0
- modelflowib-2.73.dist-info/RECORD +44 -0
- modelflowib-2.73.dist-info/WHEEL +5 -0
- modelflowib-2.73.dist-info/licenses/license.md +10 -0
- modelflowib-2.73.dist-info/top_level.txt +39 -0
- modelgrab.py +318 -0
- modelgrabgdx.py +584 -0
- modelgrabwf2.py +1107 -0
- modelhelp.py +543 -0
- modelhtml.py +606 -0
- modelinvert.py +250 -0
- modeljupyter.py +824 -0
- modeljupytermagic.py +813 -0
- modelmacrograb.py +98 -0
- modelmanipulation.py +1461 -0
- modelmf.py +349 -0
- modelnet.py +114 -0
- modelnewton.py +2178 -0
- modelnormalize.py +430 -0
- modelpattern.py +428 -0
- modelreport.py +2187 -0
- modeluserfunk.py +97 -0
- modelvis.py +1038 -0
- modelwidget.py +718 -0
- modelwidget_input.py +1933 -0
modelestimation.py
ADDED
|
@@ -0,0 +1,1776 @@
|
|
|
1
|
+
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
modelestimation.py
|
|
5
|
+
|
|
6
|
+
High-level helpers for estimating econometric equations and exporting rich
|
|
7
|
+
reports for ModelFlow/MFMod workflows.
|
|
8
|
+
|
|
9
|
+
This module provides:
|
|
10
|
+
|
|
11
|
+
- Parameter-prefix–aware parsing of EViews-style equations (e.g. `C(1)`) to a
|
|
12
|
+
normalized `{prefix}__n` form used by mfcalc / ModelFlow; the default prefix
|
|
13
|
+
is `"C"` but can be changed per-estimation via `est_param`.
|
|
14
|
+
- OLS and NLS estimation wrappers (Statsmodels and LMFIT / EViews backends).
|
|
15
|
+
- A structured `LSResult` wrapper that builds compact HTML reports, including a
|
|
16
|
+
responsive Actual vs Fitted plot.
|
|
17
|
+
- Utilities to export multiple models into a single interactive HTML document.
|
|
18
|
+
- A lightweight container (`EqContainer`) to stitch multiple equations into a
|
|
19
|
+
ModelFlow model and to initialize add-factors.
|
|
20
|
+
|
|
21
|
+
Notes
|
|
22
|
+
-----
|
|
23
|
+
This code assumes availability of the ModelFlow ecosystem:
|
|
24
|
+
|
|
25
|
+
- `modelclass.model`
|
|
26
|
+
- `modelnormalize` (imported as `nz`), notably `normal()` and `endovar()`
|
|
27
|
+
- DataFrames with the time dimension located in the index
|
|
28
|
+
|
|
29
|
+
Author
|
|
30
|
+
------
|
|
31
|
+
ibhan
|
|
32
|
+
Created
|
|
33
|
+
-------
|
|
34
|
+
2025-05-01
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from functools import cached_property, reduce
|
|
41
|
+
from io import BytesIO
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Dict, List, Set, Union, Optional
|
|
44
|
+
from typing import Callable, Any
|
|
45
|
+
|
|
46
|
+
import ast
|
|
47
|
+
import base64
|
|
48
|
+
import matplotlib.pyplot as plt
|
|
49
|
+
import pandas as pd
|
|
50
|
+
import re
|
|
51
|
+
import statsmodels.api as sm
|
|
52
|
+
import tempfile
|
|
53
|
+
import webbrowser
|
|
54
|
+
|
|
55
|
+
from IPython.display import display, HTML
|
|
56
|
+
from lmfit import Parameters, minimize
|
|
57
|
+
from matplotlib.gridspec import GridSpec
|
|
58
|
+
|
|
59
|
+
from modelclass import model
|
|
60
|
+
import modelnormalize as nz
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Small UI constants (used in reports)
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
WIDTH = "100%"
|
|
67
|
+
HEIGHT = "400px"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Simple "omodel" shell to carry variable descriptions if provided
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class DummyOModel:
|
|
76
|
+
"""
|
|
77
|
+
Minimal shim used to carry variable descriptions in places where a full
|
|
78
|
+
ModelFlow model instance is not yet available.
|
|
79
|
+
|
|
80
|
+
Attributes
|
|
81
|
+
----------
|
|
82
|
+
var_description : dict
|
|
83
|
+
Mapping from variable name -> human-readable description. Defaults to
|
|
84
|
+
`model.defsub({})`, which conveniently returns a defaultdict-like
|
|
85
|
+
object with empty-string fallback.
|
|
86
|
+
"""
|
|
87
|
+
var_description: dict = field(default_factory=model.defsub)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def dummy_omodel() -> DummyOModel:
|
|
91
|
+
"""Return a new :class:`DummyOModel`."""
|
|
92
|
+
return DummyOModel()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Base equation holder
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Eq_parent:
|
|
101
|
+
"""
|
|
102
|
+
Common base for estimation classes (OLS / NLS) and Eq convenience wrapper.
|
|
103
|
+
|
|
104
|
+
The class normalizes an original equation string, builds the minimal
|
|
105
|
+
mfcalc scaffolding (actual, fitted, residuals), and discovers variables.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
org_eq : str
|
|
110
|
+
Original equation (EViews-style is accepted, e.g. ``Y = C(1) + C(2)*X``).
|
|
111
|
+
smpl : tuple[int, int], default (2002, 2018)
|
|
112
|
+
Estimation sample in the time index (inclusive).
|
|
113
|
+
input_df : pandas.DataFrame
|
|
114
|
+
Source data with time in the index. Only variables referenced by the
|
|
115
|
+
equation are extracted to a working dataframe.
|
|
116
|
+
est_param : str, default "C"
|
|
117
|
+
Parameter prefix used for estimation. For example, setting `"B"` will
|
|
118
|
+
convert `C(1)` and `B(1)` to `B__1` in the normalized form.
|
|
119
|
+
caption : str, default "Estimation of "
|
|
120
|
+
Human readable caption used in exports.
|
|
121
|
+
var_description : dict, optional
|
|
122
|
+
Variable description mapping. If provided, it will override `omodel`
|
|
123
|
+
with a local :class:`DummyOModel` carrying these descriptions.
|
|
124
|
+
coef_dict : dict[str, float], optional
|
|
125
|
+
Optional initial substitution for coefficient placeholders before
|
|
126
|
+
parsing (e.g., `{ "C__2": 0.3 }`).
|
|
127
|
+
frml_name : str, default ""
|
|
128
|
+
ModelFlow/Modelflow FRML header to use when emitting normalized code.
|
|
129
|
+
add_add_factor, make_fixable, make_fitted : bool
|
|
130
|
+
Flags forwarded to ModelFlow normalization for add-factor handling.
|
|
131
|
+
|
|
132
|
+
Attributes
|
|
133
|
+
----------
|
|
134
|
+
org_eq : str
|
|
135
|
+
Canonicalized (upper-cased, spacing-normalized) equation.
|
|
136
|
+
org_eq_clean : str
|
|
137
|
+
Equation with any initial numeric substitutions applied.
|
|
138
|
+
lhs_actual_eq, rhs_fit_eq, residual_eq : str
|
|
139
|
+
mfcalc-compatible helper equations (`actual`, `fitted`, `residuals`).
|
|
140
|
+
endo_var : str
|
|
141
|
+
The endogenous (LHS) variable name.
|
|
142
|
+
eq_var_df : pandas.DataFrame
|
|
143
|
+
Working dataframe with *only* the variables referenced by the equation.
|
|
144
|
+
estimation_df : pandas.DataFrame
|
|
145
|
+
Default estimation dataset (equal to `eq_var_df` in the base class).
|
|
146
|
+
"""
|
|
147
|
+
org_eq: str = ""
|
|
148
|
+
smpl: tuple = (2002, 2018)
|
|
149
|
+
input_df: Optional[pd.DataFrame] = None
|
|
150
|
+
est_param: str = "C"
|
|
151
|
+
|
|
152
|
+
caption: str = "Estimation of "
|
|
153
|
+
var_description: dict = field(default_factory=dict)
|
|
154
|
+
omodel: DummyOModel = field(default_factory=dummy_omodel)
|
|
155
|
+
coef_dict: Dict[str, Union[int, float]] = field(default_factory=dict)
|
|
156
|
+
frml_name: str = ""
|
|
157
|
+
add_add_factor: bool = False
|
|
158
|
+
make_fixable: bool = False
|
|
159
|
+
make_fitted: bool = False
|
|
160
|
+
|
|
161
|
+
mfresult: any = field(init=False)
|
|
162
|
+
|
|
163
|
+
def __post_init__(self) -> None:
|
|
164
|
+
# Convert parameter tokens to the chosen prefix and sanitize helpers
|
|
165
|
+
eq = replace_c_params(self.org_eq.upper(), est_param=self.est_param)
|
|
166
|
+
eq = eq.replace("@ABS(", "ABS(")
|
|
167
|
+
if self.var_description:
|
|
168
|
+
# Prefer a local description carrier when explicit descriptions are provided
|
|
169
|
+
self.omodel = DummyOModel(var_description=model.defsub(self.var_description))
|
|
170
|
+
self.org_eq = " ".join(eq.strip().split()).upper()
|
|
171
|
+
self.org_eq_clean = expand_equation_with_coefficients(self.org_eq, self.coef_dict)
|
|
172
|
+
|
|
173
|
+
# Setup the mfcalc helper equations and variable discovery
|
|
174
|
+
lhs_expression, rhs_expression = self.org_eq_clean.split("=", 1)
|
|
175
|
+
|
|
176
|
+
self.lhs_actual_eq = nz.normal(f"actual = {lhs_expression}", add_add_factor=False).normalized
|
|
177
|
+
self.rhs_fit_eq = nz.normal(f"fitted = {rhs_expression}", add_add_factor=False).normalized
|
|
178
|
+
self.residual_eq = nz.normal(f"residuals = ({lhs_expression}) - ({rhs_expression})",
|
|
179
|
+
add_add_factor=False).normalized
|
|
180
|
+
self.endo_var = nz.endovar(lhs_expression)
|
|
181
|
+
|
|
182
|
+
# Build a working dataframe containing all referenced variables
|
|
183
|
+
try:
|
|
184
|
+
self.eq_var_df = self.mdummy.insertModelVar(self.input_df).loc[:, self.varname_all]
|
|
185
|
+
self.estimation_df = self.eq_var_df
|
|
186
|
+
except Exception:
|
|
187
|
+
# Let specialized subclasses finalize their own data if needed
|
|
188
|
+
...
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def mdummy(self):
|
|
192
|
+
"""
|
|
193
|
+
Build a small temporary ModelFlow model for collecting *all* variable
|
|
194
|
+
names referenced in the actual/fitted/residual helper equations.
|
|
195
|
+
"""
|
|
196
|
+
fdummy = "\n".join([self.lhs_actual_eq, self.rhs_fit_eq, self.residual_eq])
|
|
197
|
+
return model(fdummy)
|
|
198
|
+
|
|
199
|
+
@cached_property
|
|
200
|
+
def varname_all(self) -> List[str]:
|
|
201
|
+
"""Sorted list of *all* variable names referenced by this estimation."""
|
|
202
|
+
return sorted(self.mdummy.allvar_set)
|
|
203
|
+
|
|
204
|
+
@cached_property
|
|
205
|
+
def c_params(self) -> List[str]:
|
|
206
|
+
"""
|
|
207
|
+
Sorted list of parameter placeholders used by this estimation for the
|
|
208
|
+
selected `est_param` prefix, e.g. `['C__1', 'C__2', ...]`.
|
|
209
|
+
"""
|
|
210
|
+
prefix = f"{self.est_param}__"
|
|
211
|
+
return sorted([v for v in self.varname_all if v.startswith(prefix)],
|
|
212
|
+
key=lambda x: int(x.split(prefix)[1]))
|
|
213
|
+
|
|
214
|
+
@cached_property
|
|
215
|
+
def eq__var(self) -> List[str]:
|
|
216
|
+
"""
|
|
217
|
+
Sorted list of *data* variables used in the equation, i.e. all names
|
|
218
|
+
except the parameter placeholders and the helper slots.
|
|
219
|
+
"""
|
|
220
|
+
prefix = f"{self.est_param}__"
|
|
221
|
+
return sorted([v for v in self.varname_all
|
|
222
|
+
if not (v.startswith(prefix) or v in {"ACTUAL", "FITTED", "RESIDUALS"})])
|
|
223
|
+
|
|
224
|
+
# Operator sugar for collecting equations into containers
|
|
225
|
+
def __add__(self, other) -> "EqContainer":
|
|
226
|
+
if isinstance(other, EqContainer):
|
|
227
|
+
return EqContainer(self.equations + other.equations)
|
|
228
|
+
elif isinstance(other, Eq_parent):
|
|
229
|
+
return EqContainer([self] + [other])
|
|
230
|
+
elif isinstance(other, str):
|
|
231
|
+
return EqContainer([self] + process_string_eq(other))
|
|
232
|
+
else:
|
|
233
|
+
raise TypeError(f"Cannot add object of type {type(other).__name__} to EqContainer.")
|
|
234
|
+
|
|
235
|
+
def __iadd__(self, other) -> "EqContainer":
|
|
236
|
+
if isinstance(other, EqContainer):
|
|
237
|
+
self.equations.extend(other.equations)
|
|
238
|
+
elif other.__class__.__name__[:12] == "Estimate_nls":
|
|
239
|
+
self.equations.append(other)
|
|
240
|
+
else:
|
|
241
|
+
raise TypeError(f"Cannot add object of type {type(other).__name__} to EqContainer.")
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
def __radd__(self, other) -> "EqContainer":
|
|
245
|
+
if isinstance(other, str):
|
|
246
|
+
return EqContainer(process_string_eq(other) + [self])
|
|
247
|
+
else:
|
|
248
|
+
raise TypeError(f"Cannot add object of type {type(other).__name__} to EqContainer.")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Small convenience wrapper for a single linear identity equation
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class Eq(Eq_parent):
|
|
257
|
+
"""
|
|
258
|
+
Light wrapper over :class:`Eq_parent` for identity/aux equations.
|
|
259
|
+
|
|
260
|
+
If a multi-line string is passed to the constructor, each non-empty line is
|
|
261
|
+
split into a separate :class:`Eq` and returned inside an :class:`EqContainer`.
|
|
262
|
+
"""
|
|
263
|
+
frml_name: str = "<IDENT>"
|
|
264
|
+
|
|
265
|
+
def __new__(cls, org_eq: str, *args, **kwargs):
|
|
266
|
+
# If multi-line input, create a container of per-line equations
|
|
267
|
+
lines = [line.strip() for line in org_eq.strip().splitlines() if line.strip()]
|
|
268
|
+
if len(lines) > 1:
|
|
269
|
+
return EqContainer([cls(line, *args, **kwargs) for line in lines])
|
|
270
|
+
return super().__new__(cls)
|
|
271
|
+
|
|
272
|
+
def __init__(self, org_eq: str, **kwargs):
|
|
273
|
+
# Ensure a default FRML header
|
|
274
|
+
if "frml_name" not in kwargs:
|
|
275
|
+
kwargs["frml_name"] = "<IDENT>"
|
|
276
|
+
super().__init__(org_eq=org_eq, **kwargs)
|
|
277
|
+
|
|
278
|
+
def __post_init__(self) -> None:
|
|
279
|
+
super().__post_init__()
|
|
280
|
+
# No estimation yet; the "unlinked" version equals the clean form
|
|
281
|
+
self.org_eq_unlinked = self.org_eq_clean
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Equation parsing to mfcalc lines + validations
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
@dataclass
|
|
289
|
+
class EquationParse:
|
|
290
|
+
"""
|
|
291
|
+
Parse and validate an equation into mfcalc-friendly sub-equations.
|
|
292
|
+
|
|
293
|
+
This helper parses an equation string into an AST, extracts parameterized
|
|
294
|
+
terms such as ``{est_param}__n * expr``, validates that parameter tokens are
|
|
295
|
+
not nested incorrectly (e.g., no leading unary minus), and emits a list of
|
|
296
|
+
mfcalc code lines that compute:
|
|
297
|
+
|
|
298
|
+
- ``lhs = <LHS of the original equation>``
|
|
299
|
+
- optional constant (``{est_param}__1 = 1.0`` if present and `const=True`)
|
|
300
|
+
- special ECM term (if `ecm=True` and parameter #2 exists)
|
|
301
|
+
- one line per parameterized regressor: ``c__<n> = <expr>``
|
|
302
|
+
- the full right-hand side as ``lhs_org_eq = <RHS source>``
|
|
303
|
+
|
|
304
|
+
Parameters
|
|
305
|
+
----------
|
|
306
|
+
org_eq_clean : str
|
|
307
|
+
Canonicalized equation string with placeholders in ``{est_param}__n`` form.
|
|
308
|
+
ecm : bool, default True
|
|
309
|
+
If True, enforce that the LHS is an ECM-style difference like ``DLOG(VAR)``.
|
|
310
|
+
const : bool, default True
|
|
311
|
+
If True and ``{est_param}__1`` appears in the equation, add a line
|
|
312
|
+
``{est_param}__1 = 1.0`` to the mfcalc lines.
|
|
313
|
+
est_param : str, default "C"
|
|
314
|
+
The parameter prefix (e.g., "C", "B").
|
|
315
|
+
function_vars : list[str], default ['LOG', 'DLOG']
|
|
316
|
+
Function names to be treated as operators (thus not part of the data var set).
|
|
317
|
+
|
|
318
|
+
Attributes
|
|
319
|
+
----------
|
|
320
|
+
mfcalc_code : list[str]
|
|
321
|
+
Upper-cased mfcalc code lines generated from the equation.
|
|
322
|
+
lhs_raw, rhs_raw : str
|
|
323
|
+
Raw textual LHS and RHS parts of the original equation.
|
|
324
|
+
lhs_ast, rhs_ast : ast.AST
|
|
325
|
+
Parsed ASTs of the LHS and RHS.
|
|
326
|
+
endo_var : str
|
|
327
|
+
Endogenous variable name inferred from the LHS.
|
|
328
|
+
term_dict : dict
|
|
329
|
+
Mapping of symbolic names (e.g., 'LHS', 'C__1', 'C__2', 'C__n') to expressions.
|
|
330
|
+
used_vars : set[str]
|
|
331
|
+
Set of variable names used across LHS & RHS (excluding function operators).
|
|
332
|
+
|
|
333
|
+
Raises
|
|
334
|
+
------
|
|
335
|
+
ValueError
|
|
336
|
+
If ECM is required but not satisfied on the LHS, or if parameter tokens
|
|
337
|
+
are used in disallowed ways (e.g., nested or with a leading minus).
|
|
338
|
+
"""
|
|
339
|
+
org_eq_clean: str
|
|
340
|
+
ecm: bool = True
|
|
341
|
+
const: bool = True
|
|
342
|
+
est_param: str = "C"
|
|
343
|
+
function_vars: List[str] = field(default_factory=lambda: ["LOG", "DLOG"])
|
|
344
|
+
|
|
345
|
+
org_eq_unlinked: str = field(init=False)
|
|
346
|
+
mfcalc_code: List[str] = field(init=False)
|
|
347
|
+
rhs_ast: ast.AST = field(init=False)
|
|
348
|
+
lhs_ast: ast.AST = field(init=False)
|
|
349
|
+
endo_var: str = ""
|
|
350
|
+
|
|
351
|
+
def __post_init__(self) -> None:
|
|
352
|
+
self.param_regex = rf"{re.escape(self.est_param)}__(\d+)"
|
|
353
|
+
(self.mfcalc_code,
|
|
354
|
+
self.lhs_raw,
|
|
355
|
+
self.lhs_ast,
|
|
356
|
+
self.rhs_raw,
|
|
357
|
+
self.rhs_ast,
|
|
358
|
+
self.endo_var) = self._parse_equation(self.org_eq_clean)
|
|
359
|
+
self.term_dict = {k.strip(): v.strip() for k, v in (l.split("=", 1)
|
|
360
|
+
for l in self.mfcalc_code)}
|
|
361
|
+
self.used_vars = self._extract_variable_names_from_ast(self.rhs_ast, self.lhs_ast)
|
|
362
|
+
|
|
363
|
+
def expand_equation_with_coefficients(self, eq: str, coef_dict: Dict[str, float]) -> str:
|
|
364
|
+
"""
|
|
365
|
+
Replace ``{est_param}__n`` placeholders by numeric values from `coef_dict`.
|
|
366
|
+
|
|
367
|
+
Parameters
|
|
368
|
+
----------
|
|
369
|
+
eq : str
|
|
370
|
+
Equation string.
|
|
371
|
+
coef_dict : dict[str, float]
|
|
372
|
+
Mapping from placeholder (e.g., ``'C__2'``) to numeric value.
|
|
373
|
+
|
|
374
|
+
Returns
|
|
375
|
+
-------
|
|
376
|
+
str
|
|
377
|
+
Equation with numeric substitutions applied.
|
|
378
|
+
"""
|
|
379
|
+
for k, v in coef_dict.items():
|
|
380
|
+
eq = re.sub(rf"\b{re.escape(k)}\b", str(v), eq)
|
|
381
|
+
return eq
|
|
382
|
+
|
|
383
|
+
def _parse_equation(self, eq: str):
|
|
384
|
+
"""Split into LHS/RHS, parse ASTs, and emit mfcalc lines + derived info."""
|
|
385
|
+
lhs_raw, rhs_raw = eq.strip().split("=", 1)
|
|
386
|
+
lhs_var, endo_var = self._sanitize_lhs(lhs_raw.strip())
|
|
387
|
+
|
|
388
|
+
tree = ast.parse(f"{lhs_var} = {rhs_raw}", mode="exec")
|
|
389
|
+
rhs_ast = ast.parse(rhs_raw.strip(), mode="eval")
|
|
390
|
+
lhs_ast = ast.parse(lhs_raw.strip(), mode="eval")
|
|
391
|
+
lines = self._extract_subterms_from_ast(tree, lhs_raw, rhs_ast)
|
|
392
|
+
|
|
393
|
+
return lines, lhs_raw, lhs_ast, lhs_raw, rhs_ast, endo_var
|
|
394
|
+
|
|
395
|
+
def _sanitize_lhs(self, lhs: str):
|
|
396
|
+
"""Validate ECM restrictions (if enabled) and return a placeholder name + endo var."""
|
|
397
|
+
if self.ecm:
|
|
398
|
+
match = re.match(r"\s*DLOG\((\w+)\)", lhs)
|
|
399
|
+
if not match:
|
|
400
|
+
raise ValueError("LHS must be of the form DLOG(VAR)")
|
|
401
|
+
return "lhs", nz.endovar(lhs)
|
|
402
|
+
|
|
403
|
+
def _extract_subterms_from_ast(self, tree, lhs_raw, rhs_ast) -> List[str]:
|
|
404
|
+
"""
|
|
405
|
+
Walk the RHS AST to collect allowed parameter patterns and build mfcalc lines.
|
|
406
|
+
|
|
407
|
+
Disallows:
|
|
408
|
+
- leading unary minus before a parameter (e.g., ``- C__2 * X``)
|
|
409
|
+
- nested/multiplicative usage where the parameter is not the left-hand side
|
|
410
|
+
of a top-level multiplication or part of a top-level +/- chain
|
|
411
|
+
"""
|
|
412
|
+
rhs_expr = tree.body[0].value
|
|
413
|
+
subexprs = [f"LHS = {lhs_raw}"]
|
|
414
|
+
model_expr = f"LHS_ORG_EQ = {self._ast_to_source(rhs_expr)}"
|
|
415
|
+
|
|
416
|
+
c_terms = {}
|
|
417
|
+
|
|
418
|
+
# Annotate parents for quick ancestry checks
|
|
419
|
+
def annotate_parents(node, parent=None):
|
|
420
|
+
for child in ast.iter_child_nodes(node):
|
|
421
|
+
child._parent = node
|
|
422
|
+
annotate_parents(child, node)
|
|
423
|
+
|
|
424
|
+
annotate_parents(rhs_expr)
|
|
425
|
+
|
|
426
|
+
# Helpers to detect parameter usage
|
|
427
|
+
def get_param_number(node):
|
|
428
|
+
pattern = self.param_regex
|
|
429
|
+
if isinstance(node, ast.Name):
|
|
430
|
+
match = re.match(pattern, node.id)
|
|
431
|
+
if match:
|
|
432
|
+
return int(match.group(1))
|
|
433
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Name):
|
|
434
|
+
match = re.match(pattern, node.operand.id)
|
|
435
|
+
if match:
|
|
436
|
+
raise ValueError(f"Invalid usage: coefficient {match.group(0)} is preceded by a minus sign.")
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
def extract_binop_param_expr(node):
|
|
440
|
+
# Recognize "{est_param}__n * <expr>"
|
|
441
|
+
if not isinstance(node, ast.BinOp) or not isinstance(node.op, ast.Mult):
|
|
442
|
+
return None, None
|
|
443
|
+
left = node.left
|
|
444
|
+
param = get_param_number(left)
|
|
445
|
+
if param is not None:
|
|
446
|
+
return param, node.right
|
|
447
|
+
return None, None
|
|
448
|
+
|
|
449
|
+
# Validate parameter tokens appear only at allowed levels
|
|
450
|
+
rhs_root = rhs_ast.body
|
|
451
|
+
annotate_parents(rhs_root)
|
|
452
|
+
|
|
453
|
+
for node in ast.walk(rhs_root):
|
|
454
|
+
if isinstance(node, ast.Name):
|
|
455
|
+
match = re.match(self.param_regex, node.id)
|
|
456
|
+
if match:
|
|
457
|
+
parent = getattr(node, "_parent", None)
|
|
458
|
+
|
|
459
|
+
# Case 1: used alone at root
|
|
460
|
+
if parent is rhs_root:
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
# Case 2: as the left side of multiplication
|
|
464
|
+
if (isinstance(parent, ast.BinOp)
|
|
465
|
+
and isinstance(parent.op, ast.Mult)
|
|
466
|
+
and parent.left is node):
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
# Case 3: part of a top-level +/- chain
|
|
470
|
+
valid = False
|
|
471
|
+
cur = parent
|
|
472
|
+
while isinstance(cur, ast.BinOp):
|
|
473
|
+
if isinstance(cur.op, (ast.Add, ast.Sub)):
|
|
474
|
+
valid = True
|
|
475
|
+
cur = getattr(cur, "_parent", None)
|
|
476
|
+
else:
|
|
477
|
+
break
|
|
478
|
+
if valid:
|
|
479
|
+
continue
|
|
480
|
+
|
|
481
|
+
raise ValueError(f"Invalid nesting: coefficient {node.id} must appear only at top level.")
|
|
482
|
+
|
|
483
|
+
# Disallow "- {est_param}__n * expr" by inspecting right side of subtraction
|
|
484
|
+
for node in ast.walk(rhs_expr):
|
|
485
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Sub):
|
|
486
|
+
right = node.right
|
|
487
|
+
if isinstance(right, ast.BinOp) and isinstance(right.op, ast.Mult):
|
|
488
|
+
if isinstance(right.left, ast.Name):
|
|
489
|
+
if re.match(self.param_regex, right.left.id):
|
|
490
|
+
num = re.match(self.param_regex, right.left.id).group(1)
|
|
491
|
+
raise ValueError(f"Invalid usage: coefficient {self.est_param}__{num} is preceded by a minus sign.")
|
|
492
|
+
|
|
493
|
+
# Collect "{est_param}__n * expr" terms
|
|
494
|
+
for node in ast.walk(rhs_expr):
|
|
495
|
+
if isinstance(node, ast.BinOp):
|
|
496
|
+
param, expr = extract_binop_param_expr(node)
|
|
497
|
+
if param is not None:
|
|
498
|
+
c_terms[param] = expr
|
|
499
|
+
|
|
500
|
+
# Optional constant
|
|
501
|
+
try:
|
|
502
|
+
if f"{self.est_param}__1" in self.org_eq_clean and self.const:
|
|
503
|
+
subexprs.append(f"{self.est_param}__1 = 1.0")
|
|
504
|
+
except Exception:
|
|
505
|
+
...
|
|
506
|
+
|
|
507
|
+
# ECM special-case
|
|
508
|
+
if 2 in c_terms and self.ecm:
|
|
509
|
+
subexprs.append(f"EC_TERM = {self._ast_to_source(c_terms[2])}")
|
|
510
|
+
|
|
511
|
+
# Emit the remaining parameterized regressors
|
|
512
|
+
for param in sorted(k for k in c_terms if (k != 2 if self.ecm else True)):
|
|
513
|
+
safe_name = f"c__{param}"
|
|
514
|
+
subexprs.append(f"{safe_name} = {self._ast_to_source(c_terms[param])}")
|
|
515
|
+
|
|
516
|
+
subexprs.append(model_expr)
|
|
517
|
+
subexprs = [l.upper() for l in subexprs]
|
|
518
|
+
return subexprs
|
|
519
|
+
|
|
520
|
+
def _ast_to_source(self, node) -> str:
|
|
521
|
+
"""Convert an AST node back to a source string (uses `ast.unparse` if available)."""
|
|
522
|
+
return ast.unparse(node) if hasattr(ast, "unparse") else compile(ast.Expression(body=node), "", "eval").co_consts[0]
|
|
523
|
+
|
|
524
|
+
def _extract_variable_names_from_ast(self, rhs_ast: ast.AST, lhs_ast: ast.AST) -> Set[str]:
|
|
525
|
+
"""
|
|
526
|
+
Collect all variable names referenced in LHS and RHS (excluding function operators).
|
|
527
|
+
"""
|
|
528
|
+
vars_used: Set[str] = set()
|
|
529
|
+
|
|
530
|
+
class VarCollector(ast.NodeVisitor):
|
|
531
|
+
def __init__(self, function_vars):
|
|
532
|
+
self.function_vars = set(function_vars)
|
|
533
|
+
|
|
534
|
+
def visit_Call(self, node):
|
|
535
|
+
if isinstance(node.func, ast.Name):
|
|
536
|
+
func_name = node.func.id
|
|
537
|
+
# Function *names* themselves are not variables
|
|
538
|
+
if func_name not in self.function_vars:
|
|
539
|
+
vars_used.add(func_name)
|
|
540
|
+
for arg in node.args:
|
|
541
|
+
self.visit(arg)
|
|
542
|
+
|
|
543
|
+
def visit_Name(self, node):
|
|
544
|
+
if node.id not in self.function_vars:
|
|
545
|
+
vars_used.add(node.id)
|
|
546
|
+
|
|
547
|
+
VarCollector(self.function_vars).visit(rhs_ast)
|
|
548
|
+
VarCollector(self.function_vars).visit(lhs_ast)
|
|
549
|
+
return vars_used
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ---------------------------------------------------------------------------
|
|
553
|
+
# OLS estimation wrapper
|
|
554
|
+
# ---------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
@dataclass
|
|
557
|
+
class Estimate_ols:
|
|
558
|
+
"""
|
|
559
|
+
Ordinary Least Squares estimation wrapper.
|
|
560
|
+
|
|
561
|
+
Orchestrates:
|
|
562
|
+
- parameter-prefix normalization,
|
|
563
|
+
- variable discovery via :class:`EquationParse`,
|
|
564
|
+
- construction of the estimation dataframe (by running mfcalc lines),
|
|
565
|
+
- Statsmodels OLS estimation,
|
|
566
|
+
- mapping of estimated coefficients back to ``{est_param}__n`` tokens,
|
|
567
|
+
- rich result packaging via :class:`LSResult`.
|
|
568
|
+
|
|
569
|
+
Parameters
|
|
570
|
+
----------
|
|
571
|
+
org_eq : str
|
|
572
|
+
Original equation in EViews- or prefix-style (e.g., ``Y = C(1) + C(2)*X`` or ``Y = B(1) + B(2)*X``).
|
|
573
|
+
input_df : pandas.DataFrame
|
|
574
|
+
Source data with time in the index.
|
|
575
|
+
smpl : tuple[int, int], default (2002, 2018)
|
|
576
|
+
Estimation sample (inclusive).
|
|
577
|
+
caption : str, default "Estimation of "
|
|
578
|
+
Caption for exports.
|
|
579
|
+
fit_kws : dict, optional
|
|
580
|
+
Forwarded to Statsmodels `fit()` if needed (currently unused here).
|
|
581
|
+
coef_dict : dict[str, float], optional
|
|
582
|
+
Optional numeric substitutions before parsing.
|
|
583
|
+
omodel : DummyOModel, optional
|
|
584
|
+
Variable description carrier.
|
|
585
|
+
method : str, default "OLS"
|
|
586
|
+
Informational tag.
|
|
587
|
+
est_param : str, default "C"
|
|
588
|
+
Parameter prefix, e.g. "C", "B", ...
|
|
589
|
+
|
|
590
|
+
Attributes
|
|
591
|
+
----------
|
|
592
|
+
regression_model : Callable[[], statsmodels.regression.linear_model.RegressionResultsWrapper]
|
|
593
|
+
A zero-arg callable that returns the fitted statsmodels result (so API mimics your previous pattern).
|
|
594
|
+
coef_estimate_dict : dict[str, float]
|
|
595
|
+
Mapping ``{est_param}__n -> value`` for all estimated coefficients.
|
|
596
|
+
org_eq_unlinked : str
|
|
597
|
+
Equation with estimated numeric values substituted in-place.
|
|
598
|
+
mfresult : LSResult
|
|
599
|
+
Rich result wrapper with HTML output utilities.
|
|
600
|
+
"""
|
|
601
|
+
org_eq: str = ""
|
|
602
|
+
input_df: Optional[pd.DataFrame] = None
|
|
603
|
+
smpl: tuple = (2002, 2018)
|
|
604
|
+
caption: str = "Estimation of "
|
|
605
|
+
fit_kws: dict = field(default_factory=dict)
|
|
606
|
+
coef_dict: Dict[str, Union[int, float]] = field(default_factory=dict)
|
|
607
|
+
omodel: DummyOModel = field(default_factory=dummy_omodel)
|
|
608
|
+
method: str = "OLS"
|
|
609
|
+
est_param: str = "C"
|
|
610
|
+
|
|
611
|
+
regression_model: any = field(init=False)
|
|
612
|
+
mfresult: any = field(init=False)
|
|
613
|
+
estimation_df: pd.DataFrame = field(init=False)
|
|
614
|
+
eq_var_df: pd.DataFrame = field(init=False)
|
|
615
|
+
coef_estimate_dict: Dict[str, float] = field(init=False)
|
|
616
|
+
org_eq_unlinked: str = field(init=False)
|
|
617
|
+
coef_ser: pd.Series = field(init=False)
|
|
618
|
+
|
|
619
|
+
# From EquationParse
|
|
620
|
+
mfcalc_code: List[str] = field(init=False)
|
|
621
|
+
|
|
622
|
+
def __post_init__(self) -> None:
|
|
623
|
+
self.ecm = False
|
|
624
|
+
start, end = self.smpl
|
|
625
|
+
|
|
626
|
+
# 1) Normalize parameters to {est_param}__n
|
|
627
|
+
eq = replace_c_params(self.org_eq, est_param=self.est_param)
|
|
628
|
+
|
|
629
|
+
# 2) Canonicalize + apply any initial numeric substitutions
|
|
630
|
+
self.org_eq = " ".join(eq.strip().split()).upper()
|
|
631
|
+
self.org_eq_clean = expand_equation_with_coefficients(self.org_eq, self.coef_dict)
|
|
632
|
+
|
|
633
|
+
# 3) Build helper equations and collect endo var
|
|
634
|
+
lhs_expression, rhs_expression = self.org_eq_clean.split("=", 1)
|
|
635
|
+
self.lhs_actual_eq = nz.normal(f"actual = {lhs_expression}", add_add_factor=False).normalized
|
|
636
|
+
self.rhs_fit_eq = nz.normal(f"fitted = {rhs_expression}", add_add_factor=False).normalized
|
|
637
|
+
self.residual_eq = nz.normal(f"residuals = ({lhs_expression}) - ({rhs_expression})",
|
|
638
|
+
add_add_factor=False).normalized
|
|
639
|
+
self.endo_var = nz.endovar(lhs_expression)
|
|
640
|
+
|
|
641
|
+
# 4) Parse and collect variables
|
|
642
|
+
parser = EquationParse(org_eq_clean=self.org_eq_clean, ecm=self.ecm, est_param=self.est_param)
|
|
643
|
+
self.mfcalc_code = parser.mfcalc_code
|
|
644
|
+
|
|
645
|
+
# Restrict to used variables (intersection with DataFrame columns)
|
|
646
|
+
self.eq_var_df = self.input_df[list(parser.used_vars & set(self.input_df.columns))]
|
|
647
|
+
|
|
648
|
+
# 5) Run mfcalc lines to build LHS / regressors (drop original inputs)
|
|
649
|
+
self.estimation_df = self._run_mfcalc().loc[start:end, :]
|
|
650
|
+
|
|
651
|
+
# 6) Estimate OLS
|
|
652
|
+
self.regression_model = self.estimate() # returns callable .fit
|
|
653
|
+
|
|
654
|
+
# 7) Map statsmodels param names back to {est_param}__n
|
|
655
|
+
self.org_coef_estimate_dict = self.regression_model().params.to_dict()
|
|
656
|
+
mapped: Dict[str, float] = {}
|
|
657
|
+
for k, v in self.org_coef_estimate_dict.items():
|
|
658
|
+
# Prefer extracting the numeric id after "c__<n>" created by EquationParse
|
|
659
|
+
m = re.search(r"\bc__([0-9]+)\b", k, flags=re.IGNORECASE)
|
|
660
|
+
if m:
|
|
661
|
+
mapped[f"{self.est_param}__{m.group(1)}"] = v
|
|
662
|
+
continue
|
|
663
|
+
# Otherwise try direct appearance of {est_param}__n
|
|
664
|
+
m2 = re.search(rf"\b{re.escape(self.est_param)}__([0-9]+)\b", k, flags=re.IGNORECASE)
|
|
665
|
+
if m2:
|
|
666
|
+
mapped[f"{self.est_param}__{m2.group(1)}"] = v
|
|
667
|
+
self.coef_estimate_dict = mapped
|
|
668
|
+
|
|
669
|
+
# 8) Fill the working df with the estimated parameter values (useful for A/F & residuals)
|
|
670
|
+
c_values = [self.coef_estimate_dict[p] for p in self.c_params]
|
|
671
|
+
self.eq_var_df.loc[:, self.c_params] = c_values
|
|
672
|
+
|
|
673
|
+
# 9) Create the "unlinked" (expanded) equation with numeric coefficients
|
|
674
|
+
self.org_eq_unlinked = expand_equation_with_coefficients(
|
|
675
|
+
self.org_eq_clean,
|
|
676
|
+
coefficients=self.coef_estimate_dict,
|
|
677
|
+
decimals=10
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
# 10) On-demand copies of A/F and residuals from the statsmodels fit
|
|
681
|
+
self.af_df_from_estimation = pd.DataFrame({
|
|
682
|
+
"Actual": self.regression_model().model.endog,
|
|
683
|
+
"Fitted": self.regression_model().fittedvalues
|
|
684
|
+
}, index=self.regression_model().model.data.row_labels)
|
|
685
|
+
|
|
686
|
+
self.residuals_df_from_estimation = pd.DataFrame({
|
|
687
|
+
"Residuals": self.regression_model().resid
|
|
688
|
+
}, index=self.regression_model().model.data.row_labels)
|
|
689
|
+
|
|
690
|
+
# 11) Wrap for HTML report / convenience
|
|
691
|
+
self.mfresult = LSResult(self)
|
|
692
|
+
self.coef_ser = pd.Series(self.coef_estimate_dict, name=self.caption)
|
|
693
|
+
|
|
694
|
+
def _run_mfcalc(self) -> pd.DataFrame:
|
|
695
|
+
"""
|
|
696
|
+
Execute the generated mfcalc lines to compute `LHS` and regressors.
|
|
697
|
+
|
|
698
|
+
Returns
|
|
699
|
+
-------
|
|
700
|
+
pandas.DataFrame
|
|
701
|
+
A dataframe containing columns produced by the mfcalc lines
|
|
702
|
+
(with the original inputs dropped).
|
|
703
|
+
"""
|
|
704
|
+
start, end = self.smpl
|
|
705
|
+
ibs_df = self.eq_var_df.copy()
|
|
706
|
+
to_drop = ibs_df.columns
|
|
707
|
+
for eq in self.mfcalc_code[:-1]:
|
|
708
|
+
try:
|
|
709
|
+
ibs_df = ibs_df.mfcalc(f"<{start},{end}> {eq}")
|
|
710
|
+
except Exception as e:
|
|
711
|
+
print(f"Eq fail {e} {eq}")
|
|
712
|
+
return ibs_df.drop(columns=to_drop)
|
|
713
|
+
|
|
714
|
+
def estimate(self, include_const: bool = False):
|
|
715
|
+
"""
|
|
716
|
+
Construct and return a zero-arg callable that yields the fitted OLS result.
|
|
717
|
+
|
|
718
|
+
Parameters
|
|
719
|
+
----------
|
|
720
|
+
include_const : bool, default False
|
|
721
|
+
If True, add an explicit constant column to the regressors `X`.
|
|
722
|
+
|
|
723
|
+
Returns
|
|
724
|
+
-------
|
|
725
|
+
Callable[[], statsmodels.regression.linear_model.RegressionResultsWrapper]
|
|
726
|
+
A callable that, when invoked, returns the fitted results object.
|
|
727
|
+
"""
|
|
728
|
+
df = self.estimation_df
|
|
729
|
+
y = df["LHS"]
|
|
730
|
+
X = df.drop(columns=["LHS"])
|
|
731
|
+
if include_const:
|
|
732
|
+
X = sm.add_constant(X)
|
|
733
|
+
return sm.OLS(y, X).fit
|
|
734
|
+
|
|
735
|
+
@cached_property
|
|
736
|
+
def estimation_smpl(self) -> tuple:
|
|
737
|
+
"""Return the (start, end) estimation sample used by the model."""
|
|
738
|
+
start, end = self.smpl
|
|
739
|
+
return start, end
|
|
740
|
+
|
|
741
|
+
@property
|
|
742
|
+
def af_df(self) -> pd.DataFrame:
|
|
743
|
+
"""
|
|
744
|
+
Compute Actual and Fitted series using the mfcalc helper equations.
|
|
745
|
+
|
|
746
|
+
Returns
|
|
747
|
+
-------
|
|
748
|
+
pandas.DataFrame with columns ["Actual", "Fitted"] over the sample.
|
|
749
|
+
"""
|
|
750
|
+
start, end = self.smpl
|
|
751
|
+
res = self.eq_var_df.mfcalc(f"<{start},{end}> {self.rhs_fit_eq}")
|
|
752
|
+
res = res.mfcalc(f"<{start},{end}> {self.lhs_actual_eq}")
|
|
753
|
+
return res.loc[start:end, ["ACTUAL", "FITTED"]].rename(columns=lambda s: s.lower().capitalize())
|
|
754
|
+
|
|
755
|
+
@property
|
|
756
|
+
def residuals_df(self) -> pd.DataFrame:
|
|
757
|
+
"""
|
|
758
|
+
Compute residuals using the mfcalc helper equation.
|
|
759
|
+
|
|
760
|
+
Returns
|
|
761
|
+
-------
|
|
762
|
+
pandas.DataFrame with column ["Residuals"] over the sample.
|
|
763
|
+
"""
|
|
764
|
+
start, end = self.smpl
|
|
765
|
+
res = self.eq_var_df.mfcalc(f"<{start},{end}> {self.residual_eq}")
|
|
766
|
+
return res.loc[start:end, ["RESIDUALS"]].rename(columns=lambda s: s.lower().capitalize())
|
|
767
|
+
|
|
768
|
+
@cached_property
|
|
769
|
+
def mdummy(self):
|
|
770
|
+
"""Dummy ModelFlow model used for variable discovery (see :meth:`Eq_parent.mdummy`)."""
|
|
771
|
+
fdummy = "\n".join([self.lhs_actual_eq, self.rhs_fit_eq, self.residual_eq])
|
|
772
|
+
return model(fdummy)
|
|
773
|
+
|
|
774
|
+
@cached_property
|
|
775
|
+
def varname_all(self) -> List[str]:
|
|
776
|
+
"""Sorted list of all variable names referenced by this estimation."""
|
|
777
|
+
return sorted(self.mdummy.allvar_set)
|
|
778
|
+
|
|
779
|
+
@cached_property
|
|
780
|
+
def c_params(self) -> List[str]:
|
|
781
|
+
"""Sorted list of parameter placeholders for the chosen prefix."""
|
|
782
|
+
prefix = f"{self.est_param}__"
|
|
783
|
+
return sorted([v for v in self.varname_all if v.startswith(prefix)],
|
|
784
|
+
key=lambda x: int(x.split(prefix)[1]))
|
|
785
|
+
|
|
786
|
+
def _repr_html_(self):
|
|
787
|
+
return self.mfresult.get_html_report(plot_format="svg")
|
|
788
|
+
|
|
789
|
+
def get_html_report(self, plot_format="svg"):
|
|
790
|
+
return self.mfresult.get_html_report(plot_format=plot_format)
|
|
791
|
+
|
|
792
|
+
# ---------------------------------------------------------------------------
|
|
793
|
+
# Nonlinear Least Squares estimation wrapper
|
|
794
|
+
# ---------------------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
@dataclass
|
|
797
|
+
class Estimate_nls(Eq_parent):
|
|
798
|
+
"""
|
|
799
|
+
Nonlinear Least Squares estimation wrapper (LMFIT or EViews backend).
|
|
800
|
+
|
|
801
|
+
This class handles nonlinear structure in coefficients or regressors. By
|
|
802
|
+
default, parameters are named with `est_param` (default 'C'). When using
|
|
803
|
+
the EViews backend, parameters are restored to ``C(n)`` syntax internally.
|
|
804
|
+
|
|
805
|
+
Parameters
|
|
806
|
+
----------
|
|
807
|
+
caption, fit_kws, default_params, method, solver, frml_name
|
|
808
|
+
See attributes below.
|
|
809
|
+
add_add_factor, make_fixable
|
|
810
|
+
Forwarded to normalization when building FRMLs.
|
|
811
|
+
|
|
812
|
+
Attributes
|
|
813
|
+
----------
|
|
814
|
+
method : str, default "least_squares"
|
|
815
|
+
LMFIT minimizer method (passed to :func:`lmfit.minimize`).
|
|
816
|
+
solver : str, default "lmfit"
|
|
817
|
+
Either `"lmfit"` or `"eviews"`.
|
|
818
|
+
frml_name : str, default "<STOC,DAMP>"
|
|
819
|
+
FRML header for normalized equations in containers.
|
|
820
|
+
default_params : dict
|
|
821
|
+
Optional initialization per parameter name for LMFIT (e.g., bounds).
|
|
822
|
+
regression_model : Any
|
|
823
|
+
Either an `lmfit.ModelResult` (for `"lmfit"`) or raw EViews spool text.
|
|
824
|
+
coef_estimate_dict : dict[str, float]
|
|
825
|
+
Estimated parameter values mapped to ``{est_param}__n`` tokens.
|
|
826
|
+
"""
|
|
827
|
+
caption: str = "Estimation of "
|
|
828
|
+
fit_kws: dict = field(default_factory=dict)
|
|
829
|
+
default_params: dict = field(default_factory=dict)
|
|
830
|
+
method: str = "least_squares"
|
|
831
|
+
solver: str = "lmfit"
|
|
832
|
+
frml_name: str = "<STOC,DAMP>"
|
|
833
|
+
|
|
834
|
+
add_add_factor: bool = True
|
|
835
|
+
make_fixable: bool = True
|
|
836
|
+
|
|
837
|
+
estimation_df: pd.DataFrame = field(init=False)
|
|
838
|
+
eq_var_df: pd.DataFrame = field(init=False)
|
|
839
|
+
omodel: DummyOModel = field(default_factory=dummy_omodel)
|
|
840
|
+
|
|
841
|
+
est_param: str = "C"
|
|
842
|
+
|
|
843
|
+
def __post_init__(self) -> None:
|
|
844
|
+
# Normalize parameter tokens up-front
|
|
845
|
+
self.org_eq = replace_c_params(self.org_eq, est_param=self.est_param)
|
|
846
|
+
super().__post_init__()
|
|
847
|
+
|
|
848
|
+
# Choose backend
|
|
849
|
+
match self.solver:
|
|
850
|
+
case "lmfit":
|
|
851
|
+
self.regression_model, self.coef_estimate_dict = self.estimate()
|
|
852
|
+
case "eviews":
|
|
853
|
+
self.regression_model, self.coef_estimate_dict = self.estimate_eviews()
|
|
854
|
+
case _:
|
|
855
|
+
raise Exception("lmfit or eviews is allowed")
|
|
856
|
+
|
|
857
|
+
# Determine usable sample based on non-missing residuals
|
|
858
|
+
_ = self.estimation_smpl
|
|
859
|
+
|
|
860
|
+
# Populate parameter columns for downstream A/F & residuals helpers
|
|
861
|
+
c_values = [self.coef_estimate_dict[p] for p in self.c_params]
|
|
862
|
+
self.eq_var_df.loc[:, self.c_params] = c_values
|
|
863
|
+
|
|
864
|
+
# Expanded numeric equation string
|
|
865
|
+
self.org_eq_unlinked = expand_equation_with_coefficients(
|
|
866
|
+
self.org_eq_clean, coefficients=self.coef_estimate_dict, decimals=10
|
|
867
|
+
)
|
|
868
|
+
self.mfresult = LSResult(self)
|
|
869
|
+
self.coef_ser = pd.Series(self.coef_estimate_dict, name=self.caption)
|
|
870
|
+
|
|
871
|
+
def estimate(self):
|
|
872
|
+
"""
|
|
873
|
+
Run LMFIT-based NLS.
|
|
874
|
+
|
|
875
|
+
Returns
|
|
876
|
+
-------
|
|
877
|
+
(lmfit.MinimizerResult, dict[str, float])
|
|
878
|
+
Fit result and a mapping of parameter token -> estimated value.
|
|
879
|
+
"""
|
|
880
|
+
def init_params(param_names, init_param=None):
|
|
881
|
+
init_param = init_param or {}
|
|
882
|
+
params = Parameters()
|
|
883
|
+
for name in param_names:
|
|
884
|
+
kwargs = init_param.get(name, {})
|
|
885
|
+
if "value" not in kwargs:
|
|
886
|
+
kwargs["value"] = 0.1
|
|
887
|
+
params.add(name=name, **kwargs)
|
|
888
|
+
return params
|
|
889
|
+
|
|
890
|
+
self.lmfit_params = init_params(self.c_params, self.default_params)
|
|
891
|
+
mresidual = model(self.residual_eq)
|
|
892
|
+
|
|
893
|
+
def residual(params):
|
|
894
|
+
start, end = self.smpl
|
|
895
|
+
values = [params.valuesdict()[p] for p in self.c_params]
|
|
896
|
+
self.eq_var_df.loc[:, self.c_params] = values
|
|
897
|
+
res = mresidual(self.eq_var_df, start, end, silent=True).loc[start:end, "RESIDUALS"]
|
|
898
|
+
return res.to_numpy()
|
|
899
|
+
|
|
900
|
+
result = minimize(residual, self.lmfit_params,
|
|
901
|
+
nan_policy="omit", method=self.method, calc_covar=True, **self.fit_kws)
|
|
902
|
+
coef_estimate_dict = result.params.valuesdict()
|
|
903
|
+
return result, coef_estimate_dict
|
|
904
|
+
|
|
905
|
+
def estimate_eviews(self):
|
|
906
|
+
"""
|
|
907
|
+
Run EViews estimation by round-tripping a temporary workfile.
|
|
908
|
+
|
|
909
|
+
Returns
|
|
910
|
+
-------
|
|
911
|
+
(str, dict[str, float])
|
|
912
|
+
EViews spool text and a mapping of parameter token -> estimated value.
|
|
913
|
+
"""
|
|
914
|
+
import py2eviews as evp # Imported lazily to keep import-time light
|
|
915
|
+
start, end = self.smpl
|
|
916
|
+
|
|
917
|
+
eviewsapp = evp.GetEViewsApp(instance="new", showwindow=True)
|
|
918
|
+
df_here = self.eq_var_df.copy()
|
|
919
|
+
|
|
920
|
+
# Restore to EViews C(n) regardless of est_param
|
|
921
|
+
eviews_eq = restore_c_params(self.org_eq_clean, est_param=self.est_param)
|
|
922
|
+
eviews_eq = re.sub(r"\bABS\(", "@ABS(", eviews_eq)
|
|
923
|
+
|
|
924
|
+
df_here.index = pd.to_datetime(df_here.index, format="%Y")
|
|
925
|
+
evp.PutPythonAsWF(df_here, app=eviewsapp)
|
|
926
|
+
|
|
927
|
+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
|
928
|
+
temp_path = Path(temp_file.name)
|
|
929
|
+
|
|
930
|
+
runlines = fr"""smpl {start} {end}
|
|
931
|
+
cd {temp_path.parent}
|
|
932
|
+
equation eq1.nls
|
|
933
|
+
eq1.ls {eviews_eq}
|
|
934
|
+
spool ib
|
|
935
|
+
eq1.output
|
|
936
|
+
ib.append eq1.output
|
|
937
|
+
ib.display
|
|
938
|
+
ib.save(t=txt) {temp_path}
|
|
939
|
+
"""
|
|
940
|
+
for l in runlines.split("\n"):
|
|
941
|
+
evp.Run(l, app=eviewsapp)
|
|
942
|
+
|
|
943
|
+
c_vector = evp.Get("C", app=eviewsapp)
|
|
944
|
+
# Map back to chosen est_param prefix
|
|
945
|
+
coef_estimate_dict = {f"{self.est_param}__{i+1}": v for i, v in enumerate(c_vector) if v != 0.0}
|
|
946
|
+
eviewsapp.Hide()
|
|
947
|
+
eviewsapp = None
|
|
948
|
+
evp.Cleanup()
|
|
949
|
+
|
|
950
|
+
with open(temp_path.with_suffix(".txt"), "rt") as f:
|
|
951
|
+
eviews_spool = f.read()
|
|
952
|
+
|
|
953
|
+
return eviews_spool, coef_estimate_dict
|
|
954
|
+
|
|
955
|
+
@property
|
|
956
|
+
def af_df(self) -> pd.DataFrame:
|
|
957
|
+
"""Actual and fitted values (computed via mfcalc helpers)."""
|
|
958
|
+
start, end = self.smpl
|
|
959
|
+
res = self.eq_var_df.mfcalc(f"<{start},{end}> {self.rhs_fit_eq}")
|
|
960
|
+
res = res.mfcalc(f"<{start},{end}> {self.lhs_actual_eq}")
|
|
961
|
+
return res.loc[start:end, ["ACTUAL", "FITTED"]].rename(columns=lambda s: s.lower().capitalize())
|
|
962
|
+
|
|
963
|
+
@property
|
|
964
|
+
def residuals_df(self) -> pd.DataFrame:
|
|
965
|
+
"""Residuals (computed via mfcalc helper)."""
|
|
966
|
+
start, end = self.smpl
|
|
967
|
+
res = self.eq_var_df.mfcalc(f"<{start},{end}> {self.residual_eq}")
|
|
968
|
+
return res.loc[start:end, ["RESIDUALS"]].rename(columns=lambda s: s.lower().capitalize())
|
|
969
|
+
|
|
970
|
+
@cached_property
|
|
971
|
+
def estimation_smpl(self) -> tuple:
|
|
972
|
+
"""
|
|
973
|
+
Determine the usable estimation sample from non-missing residuals.
|
|
974
|
+
|
|
975
|
+
Returns
|
|
976
|
+
-------
|
|
977
|
+
(first_index, last_index)
|
|
978
|
+
The first and last index positions with non-missing residuals.
|
|
979
|
+
"""
|
|
980
|
+
df = self.residuals_df
|
|
981
|
+
mask = df["Residuals"].notna()
|
|
982
|
+
if not mask.any():
|
|
983
|
+
print("Not all data are available")
|
|
984
|
+
print(self.eq_var_df.loc[self.smpl[0]:self.smpl[1], self.eq__var])
|
|
985
|
+
raise ValueError("Can't run estimation")
|
|
986
|
+
first_index = mask.idxmax()
|
|
987
|
+
last_index = mask[::-1].idxmax()
|
|
988
|
+
return first_index, last_index
|
|
989
|
+
|
|
990
|
+
@cached_property
|
|
991
|
+
def c_params(self) -> List[str]:
|
|
992
|
+
"""Sorted list of parameter placeholders for the chosen prefix."""
|
|
993
|
+
prefix = f"{self.est_param}__"
|
|
994
|
+
return sorted([v for v in self.varname_all if v.startswith(prefix)],
|
|
995
|
+
key=lambda x: int(x.split(prefix)[1]))
|
|
996
|
+
|
|
997
|
+
def _repr_html_(self):
|
|
998
|
+
return self.mfresult.get_html_report(plot_format="svg")
|
|
999
|
+
|
|
1000
|
+
def get_html_report(self, plot_format="svg"):
|
|
1001
|
+
return self.mfresult.get_html_report(plot_format=plot_format)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
@classmethod
|
|
1006
|
+
def with_defaults_alternative(cls, **defaults) -> Callable[..., "Eq_parent"]:
|
|
1007
|
+
"""
|
|
1008
|
+
Create a small factory that pre-fills default arguments for this estimator.
|
|
1009
|
+
|
|
1010
|
+
Parameters
|
|
1011
|
+
----------
|
|
1012
|
+
**defaults :
|
|
1013
|
+
Keyword arguments that will be used as defaults when constructing
|
|
1014
|
+
instances of this class (e.g. smpl, input_df, var_description, etc.)
|
|
1015
|
+
|
|
1016
|
+
Returns
|
|
1017
|
+
-------
|
|
1018
|
+
Callable[..., Eq_parent]
|
|
1019
|
+
A function you can call to create an instance with those defaults.
|
|
1020
|
+
It accepts the equation either positionally or as 'org_eq=...'.
|
|
1021
|
+
|
|
1022
|
+
Usage
|
|
1023
|
+
-----
|
|
1024
|
+
ls = Estimate_nls.with_defaults(smpl=(2012, 2019),
|
|
1025
|
+
var_description=var_description,
|
|
1026
|
+
input_df=npl)
|
|
1027
|
+
|
|
1028
|
+
m1 = ls("DLOG(Y)=C(1)+C(2)*DLOG(X)")
|
|
1029
|
+
m2 = ls(org_eq="DLOG(Z)=C(1)+C(3)*DLOG(W)", caption="Alt spec")
|
|
1030
|
+
|
|
1031
|
+
Notes
|
|
1032
|
+
-----
|
|
1033
|
+
- Positional form: the first positional argument is treated as the equation.
|
|
1034
|
+
- Keyword form: pass 'org_eq="..."'.
|
|
1035
|
+
- Any keyword passed to the factory overrides the stored defaults.
|
|
1036
|
+
"""
|
|
1037
|
+
def factory(*args: Any, **overrides: Dict[str, Any]) -> "Eq_parent":
|
|
1038
|
+
# Accept equation positionally or via org_eq=...
|
|
1039
|
+
if args:
|
|
1040
|
+
if len(args) > 1:
|
|
1041
|
+
raise TypeError(
|
|
1042
|
+
f"{cls.__name__}.with_defaults factory accepts at most one "
|
|
1043
|
+
"positional argument (the equation string)."
|
|
1044
|
+
)
|
|
1045
|
+
org_eq = args[0]
|
|
1046
|
+
else:
|
|
1047
|
+
try:
|
|
1048
|
+
org_eq = overrides.pop("org_eq")
|
|
1049
|
+
except KeyError:
|
|
1050
|
+
raise TypeError(
|
|
1051
|
+
"Missing equation. Provide it positionally or as org_eq='...'."
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
params = {**defaults, **overrides, "org_eq": org_eq}
|
|
1055
|
+
return cls(**params)
|
|
1056
|
+
|
|
1057
|
+
return factory
|
|
1058
|
+
|
|
1059
|
+
@classmethod
|
|
1060
|
+
def with_defaults(cls, input_df=None, **default_kwargs):
|
|
1061
|
+
"""
|
|
1062
|
+
Returns a subclass of the estimator with pre-filled defaults and automatic
|
|
1063
|
+
preprocessing of the equation (uppercase + replace '@ABS').
|
|
1064
|
+
"""
|
|
1065
|
+
class EstimateWithDefaults(cls):
|
|
1066
|
+
def __init__(self, eq, **kwargs):
|
|
1067
|
+
clean_eq = eq.upper().replace('@ABS', 'ABS')
|
|
1068
|
+
merged_kwargs = {'input_df': input_df} | default_kwargs | kwargs
|
|
1069
|
+
super().__init__(org_eq=clean_eq, **merged_kwargs)
|
|
1070
|
+
|
|
1071
|
+
return EstimateWithDefaults
|
|
1072
|
+
|
|
1073
|
+
# ---------------------------------------------------------------------------
|
|
1074
|
+
# Result wrapper: HTML report, plots, summary glue
|
|
1075
|
+
# ---------------------------------------------------------------------------
|
|
1076
|
+
|
|
1077
|
+
@dataclass
|
|
1078
|
+
class LSResult:
|
|
1079
|
+
"""
|
|
1080
|
+
Structured wrapper for an estimated model (OLS or NLS), providing
|
|
1081
|
+
summary/HTML export and plot generation.
|
|
1082
|
+
|
|
1083
|
+
Attributes
|
|
1084
|
+
----------
|
|
1085
|
+
olsmodel : Estimate_ols | Estimate_nls
|
|
1086
|
+
The fitted model containing regression results and metadata.
|
|
1087
|
+
"""
|
|
1088
|
+
olsmodel: Union[Estimate_ols, Estimate_nls]
|
|
1089
|
+
|
|
1090
|
+
def __post_init__(self) -> None:
|
|
1091
|
+
self.result = self.olsmodel.regression_model
|
|
1092
|
+
self.af_df = self.olsmodel.af_df
|
|
1093
|
+
self.residuals_df = self.olsmodel.residuals_df
|
|
1094
|
+
self.omodel = self.olsmodel.omodel
|
|
1095
|
+
|
|
1096
|
+
self.estimator = self.olsmodel.__class__.__name__
|
|
1097
|
+
self.normal = nz.normal(self.olsmodel.org_eq_unlinked)
|
|
1098
|
+
|
|
1099
|
+
def get_html_report(self, plot_format: str = "svg") -> str:
|
|
1100
|
+
"""
|
|
1101
|
+
Build a full HTML report for the model, including:
|
|
1102
|
+
- caption, variable description, sample
|
|
1103
|
+
- original/expanded/normalized equations
|
|
1104
|
+
- regression summary (statsmodels / lmfit / EViews)
|
|
1105
|
+
- responsive Actual vs Fitted plot image
|
|
1106
|
+
|
|
1107
|
+
Parameters
|
|
1108
|
+
----------
|
|
1109
|
+
plot_format : {"svg", "png"}, default "svg"
|
|
1110
|
+
Export format for the embedded plot.
|
|
1111
|
+
|
|
1112
|
+
Returns
|
|
1113
|
+
-------
|
|
1114
|
+
str
|
|
1115
|
+
HTML content (safe to display in notebooks or write to file).
|
|
1116
|
+
"""
|
|
1117
|
+
assert plot_format in ("svg", "png"), "Only 'svg' and 'png' formats are supported"
|
|
1118
|
+
|
|
1119
|
+
def add_linebreaks_for_param10plus(equation: str, param: str = "C") -> str:
|
|
1120
|
+
pat = rf"\s*{re.escape(param)}\((1\d+|\d{{3,}})\)"
|
|
1121
|
+
return re.sub(pat, lambda m: "\n" + m.group(0).strip(), equation)
|
|
1122
|
+
|
|
1123
|
+
fmt_eq = add_linebreaks_for_param10plus(self.olsmodel.org_eq, getattr(self.olsmodel, "est_param", "C"))
|
|
1124
|
+
estimation_smpl_start, estimation_smpl_end = self.olsmodel.estimation_smpl
|
|
1125
|
+
title_html = f"""
|
|
1126
|
+
<h2>{self.olsmodel.caption}: {self.olsmodel.endo_var}: {self.omodel.var_description.get(self.olsmodel.endo_var, '')}</h2>
|
|
1127
|
+
<p><strong>Sample:</strong> {estimation_smpl_start} to {estimation_smpl_end}</p>
|
|
1128
|
+
<p><strong>Original Equation:</strong><br><pre><code>{fmt_eq}</code></pre></p>
|
|
1129
|
+
<p><strong>Expanded Equation:</strong><br><pre><code>{self.olsmodel.org_eq_unlinked}</code></pre></p>
|
|
1130
|
+
<p><strong>Normalized Equation:</strong><br><pre><code>{self.normal.normalized}</code></pre></p>
|
|
1131
|
+
"""
|
|
1132
|
+
|
|
1133
|
+
# Regression summary section
|
|
1134
|
+
match self.estimator[:12]:
|
|
1135
|
+
case "Estimate_ols":
|
|
1136
|
+
html_text = self.result().summary().as_html()
|
|
1137
|
+
html_text = html_text.replace(
|
|
1138
|
+
"<caption>OLS Regression Results</caption>",
|
|
1139
|
+
"<caption><h3>OLS Regression Results</h3></caption>"
|
|
1140
|
+
)
|
|
1141
|
+
case "Estimate_nls":
|
|
1142
|
+
match getattr(self.olsmodel, "solver", ""):
|
|
1143
|
+
case "lmfit":
|
|
1144
|
+
html_text = self.olsmodel.regression_model._repr_html_()
|
|
1145
|
+
html_text = html_text.replace("<h2>Fit Result</h2>", "<h3>NLS Regression Results</h3>")
|
|
1146
|
+
case "eviews":
|
|
1147
|
+
html_text = eviews_output_to_html(self.olsmodel.regression_model)
|
|
1148
|
+
case _:
|
|
1149
|
+
raise Exception("lmfit or eviews is allowed")
|
|
1150
|
+
case _:
|
|
1151
|
+
html_text = ""
|
|
1152
|
+
|
|
1153
|
+
# Plot: Actual vs Fitted
|
|
1154
|
+
img_buffer = BytesIO()
|
|
1155
|
+
plot_actual_vs_fitted(self.olsmodel, save_to=img_buffer, format=plot_format, figsize=(8, 5), dpi=150)
|
|
1156
|
+
img_buffer.seek(0)
|
|
1157
|
+
img_data = base64.b64encode(img_buffer.read()).decode("utf-8")
|
|
1158
|
+
mime_type = "svg+xml" if plot_format == "svg" else "png"
|
|
1159
|
+
img_html = f'<h3>Actual vs Fitted Plot</h3><img style="max-width:100%; height:auto;" src="data:image/{mime_type};base64,{img_data}" />'
|
|
1160
|
+
|
|
1161
|
+
return title_html + html_text + img_html
|
|
1162
|
+
|
|
1163
|
+
def show(self, plot_format: str = "svg") -> None:
|
|
1164
|
+
"""Display the HTML report inline (useful in notebooks)."""
|
|
1165
|
+
html = self.get_html_report(plot_format=plot_format)
|
|
1166
|
+
display(HTML(html))
|
|
1167
|
+
|
|
1168
|
+
def _repr_html_(self) -> str:
|
|
1169
|
+
"""Notebook auto-representation."""
|
|
1170
|
+
return self.get_html_report(plot_format="svg")
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
# ---------------------------------------------------------------------------
|
|
1174
|
+
# Equation containers and batch helpers
|
|
1175
|
+
# ---------------------------------------------------------------------------
|
|
1176
|
+
|
|
1177
|
+
@dataclass
|
|
1178
|
+
class EqContainer:
|
|
1179
|
+
"""
|
|
1180
|
+
Container of equations (OLS/NLS/Eq). Provides helpers to produce a clean
|
|
1181
|
+
normalized ModelFlow model, FRML-emitting, and add-factor initialization.
|
|
1182
|
+
"""
|
|
1183
|
+
equations: List[Union[Estimate_nls, Eq_parent, str]] = field(default_factory=list)
|
|
1184
|
+
|
|
1185
|
+
def test(self) -> None:
|
|
1186
|
+
"""Quick print of equation types and their original text."""
|
|
1187
|
+
for eq in self.equations:
|
|
1188
|
+
print(f"{eq.__class__.__name__:12} {eq.org_eq}")
|
|
1189
|
+
|
|
1190
|
+
@property
|
|
1191
|
+
def clean_normal(self) -> str:
|
|
1192
|
+
"""Normalized equations (without add-factors), one per line."""
|
|
1193
|
+
out_eq_n = [nz.normal(eq.org_eq_unlinked, add_add_factor=False).normalized
|
|
1194
|
+
for eq in self.equations]
|
|
1195
|
+
return "\n".join(out_eq_n)
|
|
1196
|
+
|
|
1197
|
+
@property
|
|
1198
|
+
def clean(self) -> str:
|
|
1199
|
+
"""Original equations, one per line (no normalization)."""
|
|
1200
|
+
out_eq_n = [eq.org_eq for eq in self.equations]
|
|
1201
|
+
return "\n".join(out_eq_n)
|
|
1202
|
+
|
|
1203
|
+
@property
|
|
1204
|
+
def model_clean(self):
|
|
1205
|
+
"""
|
|
1206
|
+
Build a ModelFlow model from the normalized (no add-factor) equations
|
|
1207
|
+
and propagate combined variable descriptions.
|
|
1208
|
+
"""
|
|
1209
|
+
tmodel = model(self.clean_normal)
|
|
1210
|
+
tmodel.var_description = tmodel.enrich_var_description(self.var_description)
|
|
1211
|
+
return tmodel
|
|
1212
|
+
|
|
1213
|
+
@property
|
|
1214
|
+
def eqs_norm(self) -> str:
|
|
1215
|
+
"""
|
|
1216
|
+
Normalized equations with FRML headers and configured flags for
|
|
1217
|
+
add-factor/fixable/fitted.
|
|
1218
|
+
"""
|
|
1219
|
+
out_eq_n = [f"""{eq.frml_name} {nz.normal(eq.org_eq_unlinked,
|
|
1220
|
+
add_add_factor=eq.add_add_factor,
|
|
1221
|
+
make_fixable=eq.make_fixable,
|
|
1222
|
+
make_fitted=eq.make_fitted).normalized}"""
|
|
1223
|
+
for eq in self.equations]
|
|
1224
|
+
return "\n".join(out_eq_n)
|
|
1225
|
+
|
|
1226
|
+
@property
|
|
1227
|
+
def model(self):
|
|
1228
|
+
"""
|
|
1229
|
+
Build a ModelFlow model including both equations and (if requested) the
|
|
1230
|
+
generated add-factor calculation equations.
|
|
1231
|
+
"""
|
|
1232
|
+
tmodel = model(self.eqs_norm + "\n" + self.eqs_add_model)
|
|
1233
|
+
tmodel.var_description = tmodel.enrich_var_description(self.var_description)
|
|
1234
|
+
return tmodel
|
|
1235
|
+
|
|
1236
|
+
@property
|
|
1237
|
+
def var_description(self) -> dict:
|
|
1238
|
+
"""Union of all variable descriptions contributed by member equations."""
|
|
1239
|
+
eqs = [eq for eq in self.equations]
|
|
1240
|
+
temp = reduce(lambda acc, eq: acc | eq.omodel.var_description, eqs, {})
|
|
1241
|
+
return temp
|
|
1242
|
+
|
|
1243
|
+
@property
|
|
1244
|
+
def eqs_add_model(self) -> str:
|
|
1245
|
+
"""
|
|
1246
|
+
Add-factor calculation equations for member equations that requested
|
|
1247
|
+
`add_add_factor=True`.
|
|
1248
|
+
"""
|
|
1249
|
+
out_eq_n = [f"""<CALC_ADD_FACTOR> {nz.normal(eq.org_eq_unlinked,
|
|
1250
|
+
add_add_factor=eq.add_add_factor,
|
|
1251
|
+
make_fixable=eq.make_fixable,
|
|
1252
|
+
make_fitted=eq.make_fitted).calc_add_factor}"""
|
|
1253
|
+
for eq in self.equations if eq.add_add_factor]
|
|
1254
|
+
return "\n".join(out_eq_n)
|
|
1255
|
+
|
|
1256
|
+
@property
|
|
1257
|
+
def add_model(self):
|
|
1258
|
+
"""A ModelFlow model consisting only of the add-factor calculations."""
|
|
1259
|
+
return model(self.eqs_add_model.replace("<CALC_ADD_FACTOR>", "<CALC>"))
|
|
1260
|
+
|
|
1261
|
+
def init_addfactors(self, df: pd.DataFrame, start: Union[str, int] = "",
|
|
1262
|
+
end: Union[str, int] = "", show: bool = False,
|
|
1263
|
+
check: bool = False, silent: bool = True,
|
|
1264
|
+
multiplier: float = 1.0) -> pd.DataFrame:
|
|
1265
|
+
"""
|
|
1266
|
+
Calculate and apply add factors to align model results with historical data.
|
|
1267
|
+
|
|
1268
|
+
The returned dataframe includes add-factors such that a model simulation
|
|
1269
|
+
reproduces the historical values in `df` over the specified period.
|
|
1270
|
+
This is helpful for backfitting, scenario baselining, or calibration.
|
|
1271
|
+
|
|
1272
|
+
Parameters
|
|
1273
|
+
----------
|
|
1274
|
+
df : pandas.DataFrame
|
|
1275
|
+
Historical data to align with.
|
|
1276
|
+
start, end : str | int, optional
|
|
1277
|
+
Alignment window. Defaults to full range.
|
|
1278
|
+
show : bool, default False
|
|
1279
|
+
If True, print the calculated add factors.
|
|
1280
|
+
check : bool, default False
|
|
1281
|
+
If True, re-simulate the model using the aligned data and print the
|
|
1282
|
+
difference between actual and simulated outcomes.
|
|
1283
|
+
silent : bool, default True
|
|
1284
|
+
Silence ModelFlow runtime output.
|
|
1285
|
+
multiplier : float, default 1.0
|
|
1286
|
+
Optional scale factor for the residual check printout.
|
|
1287
|
+
|
|
1288
|
+
Returns
|
|
1289
|
+
-------
|
|
1290
|
+
pandas.DataFrame
|
|
1291
|
+
A modified copy of `df` with add factors applied.
|
|
1292
|
+
"""
|
|
1293
|
+
add_model = self.add_model
|
|
1294
|
+
alligned_df = add_model(df, start=start, end=end, silent=silent)
|
|
1295
|
+
if show:
|
|
1296
|
+
print("\n\nAdd factors to allign historic values and model results")
|
|
1297
|
+
print(add_model["*_A"].df)
|
|
1298
|
+
if check:
|
|
1299
|
+
this_model = self.model
|
|
1300
|
+
_ = this_model(alligned_df, start=start, end=end, silent=silent)
|
|
1301
|
+
print("\n\nDifference between historic values and model results")
|
|
1302
|
+
if multiplier != 1.0:
|
|
1303
|
+
print(f"Multiplied by {multiplier}")
|
|
1304
|
+
this_model.basedf = df
|
|
1305
|
+
display(this_model["#ENDO"].dif.df * multiplier)
|
|
1306
|
+
return alligned_df
|
|
1307
|
+
|
|
1308
|
+
# Operator sugar for containers
|
|
1309
|
+
def __add__(self, other: Union["EqContainer", Eq_parent, str]) -> "EqContainer":
|
|
1310
|
+
if isinstance(other, EqContainer):
|
|
1311
|
+
return EqContainer(self.equations + other.equations)
|
|
1312
|
+
elif isinstance(other, Eq_parent):
|
|
1313
|
+
return EqContainer(self.equations + [other])
|
|
1314
|
+
elif isinstance(other, str):
|
|
1315
|
+
return EqContainer(self.equations + process_string_eq(other))
|
|
1316
|
+
else:
|
|
1317
|
+
raise TypeError(f"Cannot add object of type {type(other).__name__} to EqContainer.")
|
|
1318
|
+
|
|
1319
|
+
def __iadd__(self, other: Union["EqContainer", Eq_parent, str]) -> "EqContainer":
|
|
1320
|
+
if isinstance(other, EqContainer):
|
|
1321
|
+
self.equations.extend(other.equations)
|
|
1322
|
+
elif isinstance(other, Eq_parent):
|
|
1323
|
+
self.equations.append(other)
|
|
1324
|
+
else:
|
|
1325
|
+
raise TypeError(f"Cannot add object of type {type(other).__name__} to EqContainer.")
|
|
1326
|
+
return self
|
|
1327
|
+
|
|
1328
|
+
def __radd__(self, other: Union[str, Eq_parent]) -> "EqContainer":
|
|
1329
|
+
if isinstance(other, str):
|
|
1330
|
+
return EqContainer(process_string_eq(other) + self.equations)
|
|
1331
|
+
else:
|
|
1332
|
+
raise TypeError(f"Cannot add object of type {type(other).__name__} to EqContainer.")
|
|
1333
|
+
|
|
1334
|
+
def __repr__(self) -> str:
|
|
1335
|
+
return f"EqContainer({self.equations})"
|
|
1336
|
+
|
|
1337
|
+
def __iter__(self):
|
|
1338
|
+
return iter(self.equations)
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
# ---------------------------------------------------------------------------
|
|
1342
|
+
# Free helpers
|
|
1343
|
+
# ---------------------------------------------------------------------------
|
|
1344
|
+
|
|
1345
|
+
def process_string_eq(eqs):
|
|
1346
|
+
out = [Eq(es) for e in eqs.split('\n') if len(es:=e.strip())]
|
|
1347
|
+
return out
|
|
1348
|
+
|
|
1349
|
+
# --- Helpers: parameter normalization / restoration --------------------------
|
|
1350
|
+
|
|
1351
|
+
def replace_c_params(equation: str, est_param: str = "C") -> str:
|
|
1352
|
+
"""
|
|
1353
|
+
Convert EViews-style parameters to mfcalc-style for a chosen prefix.
|
|
1354
|
+
|
|
1355
|
+
Examples:
|
|
1356
|
+
"Y = C(1) + C(2)*X" -> with est_param="B" -> "Y = B__1 + B__2*X"
|
|
1357
|
+
"Y = B(1) + B(2)*X" -> with est_param="B" -> "Y = B__1 + B__2*X"
|
|
1358
|
+
|
|
1359
|
+
Notes:
|
|
1360
|
+
- Accepts both 'C(n)' and '{est_param}(n)' on input, and outputs '{est_param}__n'.
|
|
1361
|
+
- Allows negative indices in parentheses (consistent with your original code).
|
|
1362
|
+
"""
|
|
1363
|
+
# 1) Convert C(n) -> {est_param}__n
|
|
1364
|
+
equation = re.sub(r'C\((\-?\d+)\)', rf'{est_param}__\1', equation)
|
|
1365
|
+
# 2) Also convert {est_param}(n) -> {est_param}__n
|
|
1366
|
+
equation = re.sub(rf'{re.escape(est_param)}\((\-?\d+)\)', rf'{est_param}__\1', equation)
|
|
1367
|
+
return equation
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def restore_c_params(equation: str, est_param: str = "C") -> str:
|
|
1371
|
+
"""
|
|
1372
|
+
Convert mfcalc-style '{est_param}__n' back to EViews-style 'C(n)'.
|
|
1373
|
+
Used for EViews backends regardless of current est_param.
|
|
1374
|
+
"""
|
|
1375
|
+
return re.sub(rf'{re.escape(est_param)}__(\-?\d+)', r'C(\1)', equation)
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def expand_equation_with_coefficients(equation_text, coefficients={}, decimals=6):
|
|
1379
|
+
"""
|
|
1380
|
+
Replace coefficient names in an equation with their numeric values.
|
|
1381
|
+
|
|
1382
|
+
Args:
|
|
1383
|
+
equation_text (str): The original equation text.
|
|
1384
|
+
coefficients (dict): Dictionary mapping coefficient names to values.
|
|
1385
|
+
decimals (int): How many decimals to keep when inserting values.
|
|
1386
|
+
|
|
1387
|
+
Returns:
|
|
1388
|
+
str: Expanded equation.
|
|
1389
|
+
"""
|
|
1390
|
+
# Sort coefficient names by length descending to avoid partial matches
|
|
1391
|
+
sorted_keys = sorted(coefficients.keys(), key=lambda x: -len(x))
|
|
1392
|
+
|
|
1393
|
+
for key in sorted_keys:
|
|
1394
|
+
if key not in coefficients:
|
|
1395
|
+
continue # Safety: skip missing coefficients
|
|
1396
|
+
value = coefficients[key]
|
|
1397
|
+
pattern = re.escape(key)
|
|
1398
|
+
formatted_value = f'({value:.{decimals}f})' # Control decimal places
|
|
1399
|
+
equation_text = re.sub(pattern, formatted_value, equation_text)
|
|
1400
|
+
|
|
1401
|
+
return equation_text
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
from pathlib import Path
|
|
1405
|
+
import webbrowser
|
|
1406
|
+
|
|
1407
|
+
def export_ols_reports_to_html(
|
|
1408
|
+
models,
|
|
1409
|
+
path: str = 'html', # directory path
|
|
1410
|
+
filename: str = 'ols_report.html', # file name
|
|
1411
|
+
plot_format: str = 'svg',
|
|
1412
|
+
title: str = "Estimation Summary",
|
|
1413
|
+
open_file: bool = False
|
|
1414
|
+
):
|
|
1415
|
+
"""
|
|
1416
|
+
Export a list of OLS model result objects to a structured HTML report with interactive features.
|
|
1417
|
+
|
|
1418
|
+
The generated HTML report includes:
|
|
1419
|
+
- A collapsible Table of Contents
|
|
1420
|
+
- Expandable/collapsible sections for each model
|
|
1421
|
+
- Buttons for "Expand All", "Collapse All", "Print All", and "Download"
|
|
1422
|
+
- Embedded actual vs. fitted plots (responsive)
|
|
1423
|
+
- Optional automatic browser opening after export
|
|
1424
|
+
|
|
1425
|
+
Parameters:
|
|
1426
|
+
----------
|
|
1427
|
+
models : list
|
|
1428
|
+
A list of model result objects, each with a `get_html_report(plot_format)` method.
|
|
1429
|
+
path : str, default 'html'
|
|
1430
|
+
Directory where the HTML report will be saved. Created if it does not exist.
|
|
1431
|
+
filename : str, default 'ols_report.html'
|
|
1432
|
+
Name of the HTML file to be saved inside `path`.
|
|
1433
|
+
plot_format : str, default 'svg'
|
|
1434
|
+
Format for embedded plots. Options: 'svg' (preferred), or 'png'.
|
|
1435
|
+
title : str, default "OLS Estimation Summary"
|
|
1436
|
+
Title displayed at the top of the report and in the HTML page title.
|
|
1437
|
+
open_file : bool, default False
|
|
1438
|
+
If True, automatically opens the report in the system's default web browser.
|
|
1439
|
+
|
|
1440
|
+
Returns:
|
|
1441
|
+
-------
|
|
1442
|
+
None. Writes an HTML file to disk and optionally opens it in the browser.
|
|
1443
|
+
"""
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
# Ensure directory exists
|
|
1447
|
+
path = Path(path)
|
|
1448
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
1449
|
+
full_path = path / filename
|
|
1450
|
+
|
|
1451
|
+
html_parts = [f"""
|
|
1452
|
+
<html>
|
|
1453
|
+
<head>
|
|
1454
|
+
<meta charset='utf-8'>
|
|
1455
|
+
<title>{title}</title>
|
|
1456
|
+
<style>
|
|
1457
|
+
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
|
1458
|
+
h1 {{ border-bottom: 2px solid #ccc; }}
|
|
1459
|
+
.toc {{ margin-bottom: 30px; border: 1px solid #ccc; background: #f9f9f9; padding: 10px; }}
|
|
1460
|
+
.toc h2 {{ margin-top: 0; }}
|
|
1461
|
+
.toc ul {{ list-style: none; padding-left: 0; }}
|
|
1462
|
+
.toc li {{ margin: 5px 0; }}
|
|
1463
|
+
.toggle-btn, .print-btn {{
|
|
1464
|
+
margin: 10px 10px 20px 0;
|
|
1465
|
+
background: #555;
|
|
1466
|
+
color: white;
|
|
1467
|
+
border: none;
|
|
1468
|
+
padding: 8px 12px;
|
|
1469
|
+
cursor: pointer;
|
|
1470
|
+
border-radius: 4px;
|
|
1471
|
+
}}
|
|
1472
|
+
.accordion {{
|
|
1473
|
+
background-color: #eee;
|
|
1474
|
+
color: #444;
|
|
1475
|
+
cursor: pointer;
|
|
1476
|
+
padding: 15px;
|
|
1477
|
+
width: 100%;
|
|
1478
|
+
border: none;
|
|
1479
|
+
text-align: left;
|
|
1480
|
+
outline: none;
|
|
1481
|
+
font-size: 16px;
|
|
1482
|
+
transition: 0.3s;
|
|
1483
|
+
margin-top: 20px;
|
|
1484
|
+
border-radius: 4px;
|
|
1485
|
+
}}
|
|
1486
|
+
.active, .accordion:hover {{
|
|
1487
|
+
background-color: #ccc;
|
|
1488
|
+
}}
|
|
1489
|
+
.panel {{
|
|
1490
|
+
padding: 0 20px;
|
|
1491
|
+
display: none;
|
|
1492
|
+
background-color: white;
|
|
1493
|
+
overflow: hidden;
|
|
1494
|
+
border-left: 2px solid #ccc;
|
|
1495
|
+
border-right: 2px solid #ccc;
|
|
1496
|
+
border-bottom: 2px solid #ccc;
|
|
1497
|
+
border-radius: 0 0 6px 6px;
|
|
1498
|
+
}}
|
|
1499
|
+
.back-to-top {{
|
|
1500
|
+
margin-top: 20px;
|
|
1501
|
+
display: inline-block;
|
|
1502
|
+
background: #007BFF;
|
|
1503
|
+
color: white;
|
|
1504
|
+
padding: 6px 12px;
|
|
1505
|
+
border-radius: 4px;
|
|
1506
|
+
text-decoration: none;
|
|
1507
|
+
}}
|
|
1508
|
+
img {{
|
|
1509
|
+
max-width: 100%;
|
|
1510
|
+
height: auto;
|
|
1511
|
+
}}
|
|
1512
|
+
@media print {{
|
|
1513
|
+
.toggle-btn, .print-btn, .back-to-top, .accordion {{
|
|
1514
|
+
display: none !important;
|
|
1515
|
+
}}
|
|
1516
|
+
.panel {{
|
|
1517
|
+
display: block !important;
|
|
1518
|
+
}}
|
|
1519
|
+
}}
|
|
1520
|
+
</style>
|
|
1521
|
+
<script>
|
|
1522
|
+
function toggleTOC() {{
|
|
1523
|
+
const toc = document.getElementById("toc-content");
|
|
1524
|
+
toc.style.display = (toc.style.display === "none") ? "block" : "none";
|
|
1525
|
+
}}
|
|
1526
|
+
function expandAll() {{
|
|
1527
|
+
const acc = document.getElementsByClassName("accordion");
|
|
1528
|
+
for (let i = 0; i < acc.length; i++) {{
|
|
1529
|
+
const panel = acc[i].nextElementSibling;
|
|
1530
|
+
acc[i].classList.add("active");
|
|
1531
|
+
panel.style.display = "block";
|
|
1532
|
+
}}
|
|
1533
|
+
}}
|
|
1534
|
+
function collapseAll() {{
|
|
1535
|
+
const acc = document.getElementsByClassName("accordion");
|
|
1536
|
+
for (let i = 0; i < acc.length; i++) {{
|
|
1537
|
+
const panel = acc[i].nextElementSibling;
|
|
1538
|
+
acc[i].classList.remove("active");
|
|
1539
|
+
panel.style.display = "none";
|
|
1540
|
+
}}
|
|
1541
|
+
}}
|
|
1542
|
+
function printAll() {{
|
|
1543
|
+
expandAll();
|
|
1544
|
+
setTimeout(() => window.print(), 200);
|
|
1545
|
+
}}
|
|
1546
|
+
function downloadHTML() {{
|
|
1547
|
+
const htmlContent = document.documentElement.outerHTML;
|
|
1548
|
+
const blob = new Blob([htmlContent], {{ type: 'text/html' }});
|
|
1549
|
+
const url = URL.createObjectURL(blob);
|
|
1550
|
+
const a = document.createElement('a');
|
|
1551
|
+
a.href = url;
|
|
1552
|
+
a.download = '{filename}';
|
|
1553
|
+
a.click();
|
|
1554
|
+
URL.revokeObjectURL(url);
|
|
1555
|
+
}}
|
|
1556
|
+
document.addEventListener("DOMContentLoaded", function() {{
|
|
1557
|
+
const acc = document.getElementsByClassName("accordion");
|
|
1558
|
+
for (let i = 0; i < acc.length; i++) {{
|
|
1559
|
+
acc[i].addEventListener("click", function() {{
|
|
1560
|
+
this.classList.toggle("active");
|
|
1561
|
+
const panel = this.nextElementSibling;
|
|
1562
|
+
panel.style.display = (panel.style.display === "block") ? "none" : "block";
|
|
1563
|
+
}});
|
|
1564
|
+
}}
|
|
1565
|
+
}});
|
|
1566
|
+
</script>
|
|
1567
|
+
</head>
|
|
1568
|
+
<body id="top">
|
|
1569
|
+
<h1>{title}</h1>
|
|
1570
|
+
|
|
1571
|
+
<button class="toggle-btn" onclick="toggleTOC()">Toggle Table of Contents</button>
|
|
1572
|
+
<button class="toggle-btn" onclick="expandAll()">Expand All</button>
|
|
1573
|
+
<button class="toggle-btn" onclick="collapseAll()">Collapse All</button>
|
|
1574
|
+
<button class="print-btn" onclick="printAll()">📄 Print All</button>
|
|
1575
|
+
<button class="print-btn" onclick="downloadHTML()">💾 Download</button>
|
|
1576
|
+
|
|
1577
|
+
<div class="toc" id="toc-content">
|
|
1578
|
+
<h2>Table of Contents</h2>
|
|
1579
|
+
<ul>
|
|
1580
|
+
"""]
|
|
1581
|
+
|
|
1582
|
+
for i, model in enumerate(models):
|
|
1583
|
+
anchor_id = f"model_{i}"
|
|
1584
|
+
try:
|
|
1585
|
+
var_name = model.olsmodel.endo_var
|
|
1586
|
+
except:
|
|
1587
|
+
var_name = model.endo_var
|
|
1588
|
+
desc = model.omodel.var_description.get(var_name, "")
|
|
1589
|
+
html_parts.append(f"<li><a href='#{anchor_id}'>{model.caption}:{var_name}: {desc}</a></li>")
|
|
1590
|
+
|
|
1591
|
+
html_parts.append("</ul></div>")
|
|
1592
|
+
|
|
1593
|
+
for i, model in enumerate(models):
|
|
1594
|
+
anchor_id = f"model_{i}"
|
|
1595
|
+
try:
|
|
1596
|
+
var_name = model.olsmodel.endo_var
|
|
1597
|
+
except:
|
|
1598
|
+
var_name = model.endo_var
|
|
1599
|
+
desc = model.omodel.var_description.get(var_name, "")
|
|
1600
|
+
html_parts.append(f"""
|
|
1601
|
+
<button class="accordion" id="{anchor_id}">{model.caption}:{var_name}: {desc}</button>
|
|
1602
|
+
<div class="panel">
|
|
1603
|
+
{model.mfresult.get_html_report(plot_format=plot_format)}
|
|
1604
|
+
<br><a class="back-to-top" href="#top">⬆ Back to Top</a>
|
|
1605
|
+
</div>
|
|
1606
|
+
""")
|
|
1607
|
+
|
|
1608
|
+
html_parts.append("</body></html>")
|
|
1609
|
+
|
|
1610
|
+
html_out = '\n'.join(html_parts)
|
|
1611
|
+
full_path.write_text(html_out, encoding='utf-8')
|
|
1612
|
+
|
|
1613
|
+
print(f"✔ Report saved to {full_path}")
|
|
1614
|
+
|
|
1615
|
+
if open_file:
|
|
1616
|
+
webbrowser.open(f'file://{full_path.resolve()}')
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def plot_actual_vs_fitted(emodel, save_to=None, format='png', figsize=(7, 3), dpi=150):
|
|
1620
|
+
"""
|
|
1621
|
+
Plot Actual vs. Fitted values and Residuals.
|
|
1622
|
+
If `save_to` is a file-like object, the plot is saved in the specified format.
|
|
1623
|
+
"""
|
|
1624
|
+
fig = plt.figure(figsize=figsize, dpi=dpi)
|
|
1625
|
+
gs = GridSpec(3, 1, height_ratios=[2, 0.1, 1])
|
|
1626
|
+
|
|
1627
|
+
ax1 = fig.add_subplot(gs[0])
|
|
1628
|
+
ax2 = fig.add_subplot(gs[2], sharex=ax1)
|
|
1629
|
+
|
|
1630
|
+
ax1.plot(emodel.af_df.index, emodel.af_df['Actual'], label='Actual', linewidth=2)
|
|
1631
|
+
ax1.plot(emodel.af_df.index, emodel.af_df['Fitted'], label='Fitted', linestyle='--')
|
|
1632
|
+
ax1.set_title(f"Actual vs Fitted: {emodel.endo_var}")
|
|
1633
|
+
ax1.set_ylabel(emodel.endo_var)
|
|
1634
|
+
ax1.grid(True)
|
|
1635
|
+
ax1.legend()
|
|
1636
|
+
|
|
1637
|
+
ax2.plot(emodel.residuals_df.index, emodel.residuals_df['Residuals'], color='gray')
|
|
1638
|
+
ax2.axhline(0, color='red', linestyle='--', linewidth=1)
|
|
1639
|
+
ax2.set_title("Residuals")
|
|
1640
|
+
ax2.set_xlabel("Time")
|
|
1641
|
+
ax2.set_ylabel("Residual")
|
|
1642
|
+
ax2.grid(True)
|
|
1643
|
+
|
|
1644
|
+
plt.tight_layout()
|
|
1645
|
+
|
|
1646
|
+
if save_to:
|
|
1647
|
+
fig.savefig(save_to, format=format, bbox_inches='tight')
|
|
1648
|
+
plt.close(fig)
|
|
1649
|
+
else:
|
|
1650
|
+
plt.show()
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def simple_html_escape(text):
|
|
1654
|
+
return (text.replace("&", "&")
|
|
1655
|
+
.replace("<", "<")
|
|
1656
|
+
.replace(">", ">"))
|
|
1657
|
+
|
|
1658
|
+
def eviews_output_to_html(eviews_text: str) -> str:
|
|
1659
|
+
lines = eviews_text.strip().splitlines()
|
|
1660
|
+
|
|
1661
|
+
html = ['<div style="font-family:monospace;">']
|
|
1662
|
+
table_rows = []
|
|
1663
|
+
stats_rows = []
|
|
1664
|
+
in_table = False
|
|
1665
|
+
in_stats = False
|
|
1666
|
+
collecting_equation = False
|
|
1667
|
+
equation_lines = []
|
|
1668
|
+
|
|
1669
|
+
for l in lines:
|
|
1670
|
+
line = l.strip()
|
|
1671
|
+
|
|
1672
|
+
# Horizontal rule
|
|
1673
|
+
if line.startswith('===='):
|
|
1674
|
+
html.append('<hr>')
|
|
1675
|
+
if collecting_equation:
|
|
1676
|
+
html.append('<pre>' + simple_html_escape('\n'.join(equation_lines)) + '</pre>')
|
|
1677
|
+
equation_lines = []
|
|
1678
|
+
collecting_equation = False
|
|
1679
|
+
continue
|
|
1680
|
+
|
|
1681
|
+
# Detect regression equation
|
|
1682
|
+
if re.search(r'=\s*-?\s*C\(\d+\)', line):
|
|
1683
|
+
collecting_equation = True
|
|
1684
|
+
equation_lines.append(line)
|
|
1685
|
+
continue
|
|
1686
|
+
elif collecting_equation and not re.match(r'^[A-Z]', line):
|
|
1687
|
+
equation_lines.append(line)
|
|
1688
|
+
continue
|
|
1689
|
+
elif collecting_equation:
|
|
1690
|
+
html.append('<pre>' + simple_html_escape('\n'.join(equation_lines)) + '</pre>')
|
|
1691
|
+
equation_lines = []
|
|
1692
|
+
collecting_equation = False
|
|
1693
|
+
|
|
1694
|
+
# Detect start of coefficient table
|
|
1695
|
+
if line.lower().startswith('coefficientc'):
|
|
1696
|
+
# print(line)
|
|
1697
|
+
in_table = True
|
|
1698
|
+
table_rows.append(['Name','Coefficient', 'Std. Error', 't-Statistic', 'Prob.'])
|
|
1699
|
+
continue
|
|
1700
|
+
elif in_table and re.match(r'^C\(\d+\)', line):
|
|
1701
|
+
parts = re.split(r'\s+', line)
|
|
1702
|
+
table_rows.append(parts)
|
|
1703
|
+
continue
|
|
1704
|
+
elif in_table and not line:
|
|
1705
|
+
in_table = False
|
|
1706
|
+
continue
|
|
1707
|
+
|
|
1708
|
+
# R-squared / Stats
|
|
1709
|
+
if re.match(r'^R-squared', line) or re.match(r'^S\.E\.', line):
|
|
1710
|
+
in_stats = True
|
|
1711
|
+
|
|
1712
|
+
if in_stats:
|
|
1713
|
+
if ':' in line or ' ' in line:
|
|
1714
|
+
parts = re.split(r'\s{2,}', line)
|
|
1715
|
+
stats_rows.append(parts)
|
|
1716
|
+
continue
|
|
1717
|
+
|
|
1718
|
+
# Preserve normal lines
|
|
1719
|
+
if line:
|
|
1720
|
+
html.append(f"<div>{simple_html_escape(line)}</div>")
|
|
1721
|
+
|
|
1722
|
+
# Add coefficient table
|
|
1723
|
+
if table_rows:
|
|
1724
|
+
html.append('<table border="1" cellpadding="4" cellspacing="0">')
|
|
1725
|
+
for row in table_rows:
|
|
1726
|
+
html.append('<tr>' + ''.join(f'<td>{simple_html_escape(col)}</td>' for col in row) + '</tr>')
|
|
1727
|
+
html.append('</table>')
|
|
1728
|
+
|
|
1729
|
+
# Add stats table
|
|
1730
|
+
if stats_rows:
|
|
1731
|
+
html.append('<table border="1" cellpadding="4" cellspacing="0">')
|
|
1732
|
+
for row in stats_rows:
|
|
1733
|
+
html.append('<tr>' + ''.join(f'<td>{simple_html_escape(col)}</td>' for col in row) + '</tr>')
|
|
1734
|
+
html.append('</table>')
|
|
1735
|
+
|
|
1736
|
+
html.append('</div>')
|
|
1737
|
+
return '\n'.join(html)
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
# --- EquationParse with configurable est_param -------------------------------
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
# --- Estimate_nls with est_param propagation ---------------------------------
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
#%% tests
|
|
1747
|
+
if __name__ == '__main__':
|
|
1748
|
+
|
|
1749
|
+
eviews_eq = """
|
|
1750
|
+
DLOG(BOLNECONPRVTKN) = - C(2) * (
|
|
1751
|
+
LOG(BOLNECONPRVTKN(-1)) - LOG((BOLNYYWBTOTLCN(-1) - BOLGGREVDRCTCN(-1) + BOLBXFSTREMTCD(-1) * BOLPANUSATLS(-1)) / BOLNECONPRVTXN(-1))
|
|
1752
|
+
) + C(10) * DLOG((BOLNYYWBTOTLCN - BOLGGREVDRCTCN + BOLBXFSTREMTCD * BOLPANUSATLS) / BOLNECONPRVTXN)
|
|
1753
|
+
+ C(11) * (BOLFMLBLLRLCFR / 100 - DLOG(BOLNECONPRVTKN))
|
|
1754
|
+
"""
|
|
1755
|
+
ols_eq = "BOLNECONPRVTKN = C(1) + C(2) * BOLNYYWBTOTLCN"
|
|
1756
|
+
nls_eq = "log(BOLNECONPRVTKN) = C(1) + C(2) * BOLNYYWBTOTLCN"
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
# Your DataFrame should contain all relevant variables (including BOLNECONPRVTKN, etc.)
|
|
1761
|
+
# df = pd.read_csv("your_data.csv")
|
|
1762
|
+
|
|
1763
|
+
mbol,df = model.modelload('bol')
|
|
1764
|
+
eq1 = "BOLNECONPRVTKN = C(1) + C(2) * BOLNYYWBTOTLCN + c__3 *BOLGGREVDRCTCN"
|
|
1765
|
+
if 1:
|
|
1766
|
+
nls = Estimate_nls(eviews_eq, input_df=df,smpl=(2002,2023),omodel=mbol,fit_kws={})
|
|
1767
|
+
print(nls.coef_estimate_dict)
|
|
1768
|
+
nls2 = Estimate_nls(eviews_eq, input_df=df,smpl=(2002,2023),omodel=mbol,fit_kws={},solver='eviews')
|
|
1769
|
+
print(nls2.coef_estimate_dict)
|
|
1770
|
+
ols = Estimate_ols(eq1, input_df=df)
|
|
1771
|
+
#%%
|
|
1772
|
+
eq1 = 'a = b'
|
|
1773
|
+
xx = Eq(eq1,input_df = df)
|
|
1774
|
+
xx+xx
|
|
1775
|
+
yy = Eq('a=b\nc=d', input_df=df,frml_name='ib')
|
|
1776
|
+
zz = yy + EqContainer([nls])
|