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.
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("&", "&amp;")
1655
+ .replace("<", "&lt;")
1656
+ .replace(">", "&gt;"))
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])