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
|
@@ -0,0 +1,2872 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Created on Thu Oct 16 09:43:04 2025
|
|
4
|
+
|
|
5
|
+
@author: ibhan
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# next generation
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field, fields
|
|
11
|
+
from typing import List, Optional, Any , Union
|
|
12
|
+
import re
|
|
13
|
+
from IPython.display import display, Math, Latex, Markdown , Image, SVG, display_svg,IFrame
|
|
14
|
+
import inspect
|
|
15
|
+
import functools
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from pprint import pformat
|
|
19
|
+
import textwrap
|
|
20
|
+
|
|
21
|
+
from modelmanipulation import tofrml,dounloop, sumunroll
|
|
22
|
+
from modelpattern import find_statements,split_frml,find_frml,list_extract,udtryk_parse,kw_frml_name,commentchar,split_frml_reqopts,rebuild_list
|
|
23
|
+
from modelpattern import namepat, check_syntax_model
|
|
24
|
+
from modelhelp import debug_var
|
|
25
|
+
from model_latex_class import a_latex_model,a_latex_equation,defrack, depower,debrace,defunk
|
|
26
|
+
from modelreport import LatexRepo
|
|
27
|
+
|
|
28
|
+
class ModelSpecificationError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def clean_expressions(original_statements: str) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Pipeline
|
|
35
|
+
--------
|
|
36
|
+
1. Uppercase the DSL.
|
|
37
|
+
2. Collapse LIST blocks to one line, preserving whether they ended with '$'.
|
|
38
|
+
3. Run tofrml().
|
|
39
|
+
4. Rebuild LIST blocks through:
|
|
40
|
+
list_extract(..., add_auto_sublists=False)
|
|
41
|
+
rebuild_list(...)
|
|
42
|
+
"""
|
|
43
|
+
import re
|
|
44
|
+
|
|
45
|
+
stmt_start = re.compile(r'^\s*(LIST|TLIST|FRML|DO|ENDDO|DOABLE)\b')
|
|
46
|
+
|
|
47
|
+
def is_list_start(line: str) -> bool:
|
|
48
|
+
# Both LIST and TLIST start a list block. TLIST is the transposed
|
|
49
|
+
# form: the second "row" gives sublist names and values run down
|
|
50
|
+
# the columns. TLIST is converted to standard LIST during
|
|
51
|
+
# normalize_lists() so downstream code only ever sees LIST.
|
|
52
|
+
return bool(re.match(r'^\s*(LIST|TLIST)\b', line))
|
|
53
|
+
|
|
54
|
+
def is_tlist_start(line: str) -> bool:
|
|
55
|
+
return bool(re.match(r'^\s*TLIST\b', line))
|
|
56
|
+
|
|
57
|
+
def collect_list_block(lines, start_index):
|
|
58
|
+
"""
|
|
59
|
+
Collect one LIST block starting at start_index.
|
|
60
|
+
|
|
61
|
+
A block continues while the current line ends with '/'.
|
|
62
|
+
A block ends when the current line ends with '$', or when
|
|
63
|
+
the current line does not end with '/'.
|
|
64
|
+
"""
|
|
65
|
+
block_lines = [lines[start_index].rstrip()]
|
|
66
|
+
j = start_index + 1
|
|
67
|
+
|
|
68
|
+
while j < len(lines):
|
|
69
|
+
prev = block_lines[-1].rstrip()
|
|
70
|
+
|
|
71
|
+
if prev.endswith('$'):
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
if prev.endswith('/'):
|
|
75
|
+
block_lines.append(lines[j].rstrip())
|
|
76
|
+
j += 1
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
return '\n'.join(block_lines), j
|
|
82
|
+
|
|
83
|
+
def _convert_tlist_to_list(flat_no_dollar: str) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Convert a flattened TLIST single-line form to standard LIST form.
|
|
86
|
+
|
|
87
|
+
Input (flat, no trailing '$'):
|
|
88
|
+
TLIST <name> = h1 h2 h3 / v11 v12 v13 / v21 v22 v23 / v31 v32 v33
|
|
89
|
+
|
|
90
|
+
Output:
|
|
91
|
+
LIST <name> = h1 : v11 v21 v31 / h2 : v12 v22 v32 / h3 : v13 v23 v33
|
|
92
|
+
|
|
93
|
+
The first '/'-separated chunk after '=' is the header row giving
|
|
94
|
+
sublist names. Each subsequent chunk is one tuple of values, one
|
|
95
|
+
value per sublist. Data therefore runs *down* the columns, hence
|
|
96
|
+
'transposed list'.
|
|
97
|
+
"""
|
|
98
|
+
# strip the leading TLIST keyword
|
|
99
|
+
m = re.match(r'^\s*TLIST\b\s*(.*)$', flat_no_dollar, flags=re.IGNORECASE)
|
|
100
|
+
if not m:
|
|
101
|
+
raise ModelSpecificationError(
|
|
102
|
+
f"Internal: _convert_tlist_to_list called on non-TLIST: {flat_no_dollar!r}"
|
|
103
|
+
)
|
|
104
|
+
body = m.group(1).strip()
|
|
105
|
+
|
|
106
|
+
if '=' not in body:
|
|
107
|
+
raise ModelSpecificationError(
|
|
108
|
+
f"TLIST is missing '=' between name and contents: {flat_no_dollar!r}"
|
|
109
|
+
)
|
|
110
|
+
list_name, rhs = body.split('=', 1)
|
|
111
|
+
list_name = list_name.strip()
|
|
112
|
+
if not list_name:
|
|
113
|
+
raise ModelSpecificationError(
|
|
114
|
+
f"TLIST has empty name: {flat_no_dollar!r}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# split rows on '/'
|
|
118
|
+
raw_rows = [r.strip() for r in rhs.split('/')]
|
|
119
|
+
raw_rows = [r for r in raw_rows if r != '']
|
|
120
|
+
if len(raw_rows) < 2:
|
|
121
|
+
raise ModelSpecificationError(
|
|
122
|
+
f"TLIST {list_name} needs a header row and at least one value row "
|
|
123
|
+
f"(rows separated by '/'); got: {flat_no_dollar!r}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def tokens(row: str):
|
|
127
|
+
# split on whitespace or commas, drop empties
|
|
128
|
+
return [t for t in re.split(r'[\s,]+', row.strip()) if t != '']
|
|
129
|
+
|
|
130
|
+
header = tokens(raw_rows[0])
|
|
131
|
+
value_rows = [tokens(r) for r in raw_rows[1:]]
|
|
132
|
+
|
|
133
|
+
if not header:
|
|
134
|
+
raise ModelSpecificationError(
|
|
135
|
+
f"TLIST {list_name} has empty header row"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
ncols = len(header)
|
|
139
|
+
for k, vr in enumerate(value_rows, 1):
|
|
140
|
+
if len(vr) != ncols:
|
|
141
|
+
raise ModelSpecificationError(
|
|
142
|
+
f"TLIST {list_name}: value row {k} has {len(vr)} entries "
|
|
143
|
+
f"but header has {ncols} ({header})"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# transpose: column j across all value rows becomes the j-th sublist
|
|
147
|
+
sublists = []
|
|
148
|
+
for j, sublist_name in enumerate(header):
|
|
149
|
+
col = [vr[j] for vr in value_rows]
|
|
150
|
+
sublists.append(f"{sublist_name} : {' '.join(col)}")
|
|
151
|
+
|
|
152
|
+
return f"LIST {list_name} = " + ' / '.join(sublists)
|
|
153
|
+
|
|
154
|
+
def normalize_list_block(block: str) -> str:
|
|
155
|
+
"""
|
|
156
|
+
Flatten one LIST or TLIST block to a single line.
|
|
157
|
+
Preserve a trailing '$' if present in the original block.
|
|
158
|
+
|
|
159
|
+
TLIST blocks are transposed into the canonical LIST form here, so
|
|
160
|
+
every later stage (tofrml, list_extract, rebuild_list, ...) only
|
|
161
|
+
ever sees LIST.
|
|
162
|
+
"""
|
|
163
|
+
saw_dollar = bool(re.search(r'\$\s*$', block))
|
|
164
|
+
is_tlist = bool(re.match(r'^\s*TLIST\b', block))
|
|
165
|
+
|
|
166
|
+
flat = ' '.join(line.strip() for line in block.splitlines())
|
|
167
|
+
flat = re.sub(r'\s+', ' ', flat).strip()
|
|
168
|
+
|
|
169
|
+
# remove one trailing '$' and one trailing '/'
|
|
170
|
+
flat = re.sub(r'\s*\$\s*$', '', flat)
|
|
171
|
+
flat = re.sub(r'\s*/\s*$', '', flat)
|
|
172
|
+
|
|
173
|
+
if is_tlist:
|
|
174
|
+
flat = _convert_tlist_to_list(flat)
|
|
175
|
+
|
|
176
|
+
if saw_dollar:
|
|
177
|
+
flat = flat + ' $'
|
|
178
|
+
|
|
179
|
+
return flat
|
|
180
|
+
|
|
181
|
+
def normalize_lists(text: str) -> str:
|
|
182
|
+
lines = text.splitlines()
|
|
183
|
+
out = []
|
|
184
|
+
i = 0
|
|
185
|
+
|
|
186
|
+
while i < len(lines):
|
|
187
|
+
if not is_list_start(lines[i]):
|
|
188
|
+
out.append(lines[i])
|
|
189
|
+
i += 1
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
block, next_i = collect_list_block(lines, i)
|
|
193
|
+
out.append(normalize_list_block(block))
|
|
194
|
+
i = next_i
|
|
195
|
+
|
|
196
|
+
return '\n'.join(out)
|
|
197
|
+
|
|
198
|
+
def restore_list_block(block: str) -> str:
|
|
199
|
+
"""
|
|
200
|
+
Rebuild one LIST block through list_extract() + rebuild_list().
|
|
201
|
+
"""
|
|
202
|
+
saw_dollar = bool(re.search(r'\$\s*$', block))
|
|
203
|
+
|
|
204
|
+
parse_block = block if saw_dollar else block.rstrip() + ' $'
|
|
205
|
+
list_dict = list_extract(parse_block, add_auto_sublists=False)
|
|
206
|
+
rebuilt = rebuild_list(list_dict)
|
|
207
|
+
|
|
208
|
+
if not saw_dollar:
|
|
209
|
+
rebuilt = re.sub(r'\s*\$\s*$', '', rebuilt)
|
|
210
|
+
|
|
211
|
+
return rebuilt
|
|
212
|
+
|
|
213
|
+
def restore_lists(text: str) -> str:
|
|
214
|
+
lines = text.splitlines()
|
|
215
|
+
out = []
|
|
216
|
+
i = 0
|
|
217
|
+
|
|
218
|
+
while i < len(lines):
|
|
219
|
+
if not is_list_start(lines[i]):
|
|
220
|
+
out.append(lines[i])
|
|
221
|
+
i += 1
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# after normalize_lists(), LIST blocks are normally single-line,
|
|
225
|
+
# but we keep block collection here for robustness
|
|
226
|
+
block_lines = [lines[i].rstrip()]
|
|
227
|
+
j = i + 1
|
|
228
|
+
|
|
229
|
+
while j < len(lines):
|
|
230
|
+
prev = block_lines[-1].rstrip()
|
|
231
|
+
|
|
232
|
+
if prev.endswith('$'):
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
if prev.endswith('/'):
|
|
236
|
+
block_lines.append(lines[j].rstrip())
|
|
237
|
+
j += 1
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
if stmt_start.match(lines[j]):
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
block = '\n'.join(block_lines)
|
|
246
|
+
out.append(restore_list_block(block))
|
|
247
|
+
i = j
|
|
248
|
+
|
|
249
|
+
return '\n'.join(out)
|
|
250
|
+
|
|
251
|
+
up = original_statements.upper()
|
|
252
|
+
flattened = normalize_lists(up)
|
|
253
|
+
frml_added = tofrml(flattened)
|
|
254
|
+
return restore_lists(frml_added)
|
|
255
|
+
|
|
256
|
+
def doable_unroll(in_equations,funks=[]):
|
|
257
|
+
''' expands all sum(list,'expression') in a model
|
|
258
|
+
returns a new model'''
|
|
259
|
+
import model_latex_class as ml
|
|
260
|
+
nymodel = []
|
|
261
|
+
equations = in_equations[:].upper() # we want do change the e
|
|
262
|
+
# debug_var(equations)
|
|
263
|
+
|
|
264
|
+
for comment, command, value in find_statements(equations):
|
|
265
|
+
# print('>>',comment,'<',command,'>',value)
|
|
266
|
+
# debug_var(comment,command,value)
|
|
267
|
+
if comment:
|
|
268
|
+
nymodel.append(comment)
|
|
269
|
+
else:
|
|
270
|
+
if command == 'DOABLE':
|
|
271
|
+
unrolled = ml.doable(value)
|
|
272
|
+
nymodel.append(unrolled + ' ')
|
|
273
|
+
# debug_var(unrolled)
|
|
274
|
+
|
|
275
|
+
else:
|
|
276
|
+
nymodel.append(command + ' ' + value)
|
|
277
|
+
equations = '\n'.join(nymodel)
|
|
278
|
+
# debug_var(equations)
|
|
279
|
+
return equations
|
|
280
|
+
|
|
281
|
+
# Simple extractor
|
|
282
|
+
def extract_model_from_markdown(md_text: str) -> str:
|
|
283
|
+
"""Extracts model lines (starting with '>') from Markdown."""
|
|
284
|
+
return "\n".join(
|
|
285
|
+
re.findall(r'^[>]\s?(.*)', md_text, flags=re.MULTILINE)
|
|
286
|
+
).strip()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
TAG_RE = re.compile(r'^%\s*@<([^>]+)>')
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def extract_latex_equation_block(lines, start):
|
|
294
|
+
block = []
|
|
295
|
+
i = start + 1
|
|
296
|
+
|
|
297
|
+
while i < len(lines) and r"\end{equation}" not in lines[i]:
|
|
298
|
+
block.append(lines[i])
|
|
299
|
+
i += 1
|
|
300
|
+
|
|
301
|
+
return block, i + 1
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def extract_model_from_markdown(md_text: str, list_position: str = "front") -> str:
|
|
306
|
+
"""
|
|
307
|
+
Extract ModelFlow model equations from markdown.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
md_text : str
|
|
312
|
+
Markdown text containing model equations and LaTeX.
|
|
313
|
+
list_position : {"front", "end"}
|
|
314
|
+
Where extracted LIST definitions should be placed.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
if list_position not in {"front", "end"}:
|
|
318
|
+
raise ValueError("list_position must be 'front' or 'end'")
|
|
319
|
+
|
|
320
|
+
model_lines = []
|
|
321
|
+
latex_buffer = [] # collect ALL LaTeX for LIST extraction
|
|
322
|
+
|
|
323
|
+
lines = md_text.splitlines()
|
|
324
|
+
current = ""
|
|
325
|
+
i = 0
|
|
326
|
+
n = len(lines)
|
|
327
|
+
|
|
328
|
+
in_dollars = False
|
|
329
|
+
|
|
330
|
+
while i < n:
|
|
331
|
+
line = lines[i]
|
|
332
|
+
stripped = line.lstrip()
|
|
333
|
+
|
|
334
|
+
# ----------------------------------------------------------
|
|
335
|
+
# Track $$ display math
|
|
336
|
+
# ----------------------------------------------------------
|
|
337
|
+
if stripped.startswith("$$"):
|
|
338
|
+
in_dollars = not in_dollars
|
|
339
|
+
latex_buffer.append(line)
|
|
340
|
+
i += 1
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
if in_dollars:
|
|
344
|
+
latex_buffer.append(line)
|
|
345
|
+
i += 1
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
# ----------------------------------------------------------
|
|
349
|
+
# LaTeX equation environment (standalone)
|
|
350
|
+
# ----------------------------------------------------------
|
|
351
|
+
if stripped.startswith(r"\begin{equation}"):
|
|
352
|
+
block, i = extract_latex_equation_block(lines, i)
|
|
353
|
+
|
|
354
|
+
tag = None
|
|
355
|
+
label = None
|
|
356
|
+
body_lines = []
|
|
357
|
+
|
|
358
|
+
for l in block:
|
|
359
|
+
s = l.strip()
|
|
360
|
+
if not s:
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
latex_buffer.append(s)
|
|
364
|
+
|
|
365
|
+
# % @<...>
|
|
366
|
+
if s.startswith('%'):
|
|
367
|
+
m = TAG_RE.match(s)
|
|
368
|
+
if m:
|
|
369
|
+
tag = m.group(1)
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
# \label{eq:...}
|
|
373
|
+
if s.startswith(r"\label{eq:"):
|
|
374
|
+
label = s[len(r"\label{eq:"):-1]
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
body_lines.append(s)
|
|
378
|
+
|
|
379
|
+
if label and body_lines:
|
|
380
|
+
final_tag = f'<{tag}>' if tag else '<>'
|
|
381
|
+
body = " ".join(body_lines)
|
|
382
|
+
model_lines.append(
|
|
383
|
+
latex_to_doable(body, tag=final_tag)
|
|
384
|
+
)
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
# ----------------------------------------------------------
|
|
388
|
+
# Inline LaTeX (for LIST detection)
|
|
389
|
+
# ----------------------------------------------------------
|
|
390
|
+
if "$" in line:
|
|
391
|
+
latex_buffer.append(line)
|
|
392
|
+
|
|
393
|
+
# ----------------------------------------------------------
|
|
394
|
+
# Markdown continuation >>
|
|
395
|
+
# ----------------------------------------------------------
|
|
396
|
+
if stripped.startswith(">>"):
|
|
397
|
+
current += " " + stripped[2:].lstrip()
|
|
398
|
+
i += 1
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
# ----------------------------------------------------------
|
|
402
|
+
# Markdown model line >
|
|
403
|
+
# ----------------------------------------------------------
|
|
404
|
+
if stripped.startswith(">"):
|
|
405
|
+
if current.strip():
|
|
406
|
+
model_lines.append(current.strip())
|
|
407
|
+
current = stripped[1:].lstrip()
|
|
408
|
+
i += 1
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
# ----------------------------------------------------------
|
|
412
|
+
# Flush on non-model line
|
|
413
|
+
# ----------------------------------------------------------
|
|
414
|
+
if current.strip():
|
|
415
|
+
model_lines.append(current.strip())
|
|
416
|
+
current = ""
|
|
417
|
+
|
|
418
|
+
i += 1
|
|
419
|
+
|
|
420
|
+
# ----------------------------------------------------------
|
|
421
|
+
# Final flush
|
|
422
|
+
# ----------------------------------------------------------
|
|
423
|
+
if current.strip():
|
|
424
|
+
model_lines.append(current.strip())
|
|
425
|
+
|
|
426
|
+
# ----------------------------------------------------------
|
|
427
|
+
# GLOBAL LIST extraction (ONCE)
|
|
428
|
+
# ----------------------------------------------------------
|
|
429
|
+
lists = findlists_in_latex(md_text)
|
|
430
|
+
# debug_var(lists,model_lines)
|
|
431
|
+
if lists:
|
|
432
|
+
if list_position == "front":
|
|
433
|
+
model_lines = [lists] + model_lines
|
|
434
|
+
else: # "end"
|
|
435
|
+
model_lines = model_lines + [lists]
|
|
436
|
+
|
|
437
|
+
return "\n".join(model_lines)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# def findlists_in_latex(input):
|
|
442
|
+
# '''extracte list with sublist from latex'''
|
|
443
|
+
# relevant = re.findall(r'\$LIST\s*\\;\s*[^$]*\$',input.upper())
|
|
444
|
+
# # print(f'{relevant=}')
|
|
445
|
+
# temp1 = [l.replace('$','').replace('\\','')
|
|
446
|
+
# .replace(',',' ').replace(';',' ')
|
|
447
|
+
# .replace('{','').replace('}','').replace('\n','/ \n')
|
|
448
|
+
# for l in relevant]
|
|
449
|
+
# # print(f'\n{temp1=}\n')
|
|
450
|
+
# temp2 = ['LIST ' + l.split('=')[0][4:].strip() +' = '
|
|
451
|
+
# + l.split('=')[0][4:]
|
|
452
|
+
# +' : '+ l.split('=')[1] for l in temp1]
|
|
453
|
+
|
|
454
|
+
# return ('\n'.join(temp2)+'\n')
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
import re
|
|
458
|
+
|
|
459
|
+
import re
|
|
460
|
+
|
|
461
|
+
def clean_mathjax(s):
|
|
462
|
+
s = (
|
|
463
|
+
s.replace('$$\n','')
|
|
464
|
+
.replace('$','')
|
|
465
|
+
.replace('\\allowbreak',' ')
|
|
466
|
+
.replace('\\\\',' ')
|
|
467
|
+
.replace('\\begin{aligned}','')
|
|
468
|
+
.replace('\\end{aligned}','')
|
|
469
|
+
.replace(r'\{','')
|
|
470
|
+
.replace(r'\}','')
|
|
471
|
+
.replace(',',' ')
|
|
472
|
+
.replace(r'\;',' ')
|
|
473
|
+
.replace('\n','/ \n')
|
|
474
|
+
.replace(r'\_','_')
|
|
475
|
+
)
|
|
476
|
+
# remove line-start + blanks
|
|
477
|
+
return re.sub(r'(?m)^\s+', '', s)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def findlists_in_latex(input):
|
|
482
|
+
'''extract list with possible MathJax line breaks'''
|
|
483
|
+
|
|
484
|
+
relevant = re.findall(
|
|
485
|
+
r'\${1,2}\s*LIST\s*\\;\s*.*?\${1,2}',
|
|
486
|
+
input,
|
|
487
|
+
flags=re.IGNORECASE | re.DOTALL
|
|
488
|
+
)
|
|
489
|
+
temp0 = [l.upper() for l in relevant]
|
|
490
|
+
temp1 = [clean_mathjax(l.upper()) for l in relevant]
|
|
491
|
+
|
|
492
|
+
temp2 = [
|
|
493
|
+
'LIST ' + l.split('=')[0][4:].strip()
|
|
494
|
+
+ ' = ' + l.split('=')[0][4:]
|
|
495
|
+
+ ' : ' + l.split('=')[1]
|
|
496
|
+
for l in temp1
|
|
497
|
+
]
|
|
498
|
+
# debug_var(temp0,temp1,temp2)
|
|
499
|
+
result = '\n'.join(temp2) + '\n'
|
|
500
|
+
return result
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def latex_to_doable(temp,tag='<>'):
|
|
505
|
+
"""
|
|
506
|
+
Given a LaTeX equation string in `temp`, this function processes and converts it to a more standardized format.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
temp (str): A LaTeX equation string to be processed and standardized.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
str: A processed and standardized version of the input `temp` string.
|
|
513
|
+
|
|
514
|
+
Raises:
|
|
515
|
+
None.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
if type(temp) == type(None):
|
|
519
|
+
return None
|
|
520
|
+
trans={r'\left':'',
|
|
521
|
+
r'\right':'',
|
|
522
|
+
# r'\min':'min',
|
|
523
|
+
# r'\max':'max',
|
|
524
|
+
r'\rho':'rho',
|
|
525
|
+
r'\alpha':'alpha',
|
|
526
|
+
r'\beta':'beta',
|
|
527
|
+
r'\tau':'tau',
|
|
528
|
+
r'\sigma':'sigma',
|
|
529
|
+
r'\exp':'exp',
|
|
530
|
+
r'&':'',
|
|
531
|
+
r'\\':'',
|
|
532
|
+
r'\nonumber' : '',
|
|
533
|
+
r'\_' : '_',
|
|
534
|
+
r'_{t}' : '',
|
|
535
|
+
r"_t(?![a-zA-Z0-9])" :'',
|
|
536
|
+
'logit^{-1}' : 'logit_inverse',
|
|
537
|
+
r'\{' : '{',
|
|
538
|
+
r'\}' : '}',
|
|
539
|
+
r'\begin{split}' : '',
|
|
540
|
+
'\n' :'',
|
|
541
|
+
r'\forall' :'',
|
|
542
|
+
r'\;' :'',
|
|
543
|
+
r'\:' :'',
|
|
544
|
+
r'\,' :'',
|
|
545
|
+
r'\big' :'',
|
|
546
|
+
r'\begin{aligned}' :'',
|
|
547
|
+
r'\end{aligned}' :'',
|
|
548
|
+
r' ' :' ',
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
}
|
|
552
|
+
ftrans = {
|
|
553
|
+
r'\sqrt':'sqrt',
|
|
554
|
+
r'\Delta':'diff',
|
|
555
|
+
r'\Phi':'NORM.CDF',
|
|
556
|
+
r'\Phi^{-1}':'NORM.PDF'
|
|
557
|
+
}
|
|
558
|
+
regtrans = {
|
|
559
|
+
r'\\Delta ([A-Za-z_][\w{},\^]*)':r'diff(\1)', # \Delta xy => diff(xy)
|
|
560
|
+
r'_{t-([1-9]+)}' : r'(-\1)', # _{t-x} => (-x)
|
|
561
|
+
r'_{t\+([1-9]+)}' : r'(+\1)', # _{t+x} => (+x)
|
|
562
|
+
|
|
563
|
+
# r'\^([\w])' : r'_\1', # ^x => _x
|
|
564
|
+
# r'\^\{([\w]+)\}(\w)' : r'_\1_\2', # ^{xx}y => _xx_y
|
|
565
|
+
r'\^{([\w+-]+)}' : r'__{\1}', # ^{xx} => _xx
|
|
566
|
+
r'\^{([\w+-]+),([\w+-]+)\}' : r'__{\1}__{\2}', # ^{xx,yy} => _xx_yy
|
|
567
|
+
r'\^{([\w+-]+),([\w+-]+),([\w+-]+)\}' : r'__{\1}__{\2}__{\3}', # ^{xx,yy} => _xx_yy
|
|
568
|
+
r'\^{([\w+-]+),([\w+-]+),([\w+-]+),([\w+-]+)\}' : r'__{\1}__{\2}__{\3}__{\4}', # ^{xx,yy} => _xx_yy
|
|
569
|
+
r'\s*\\times\s*':'*' ,
|
|
570
|
+
r'\s*\\cdot\s*':'*' ,
|
|
571
|
+
r'\\text{\[([\w+-,.]+)\]}' : r'[\1]',
|
|
572
|
+
r'\\sum_{('+namepat+r')}\(' : r'sum(\1,',
|
|
573
|
+
r"\\sum_{([a-zA-Z][a-zA-Z0-9_]*)=([a-zA-Z][a-zA-Z0-9_]*)}\(": r'sum(\1 \2=1,',
|
|
574
|
+
|
|
575
|
+
r'\\max_{('+namepat+r')}\(' : r'lmax(\1,',
|
|
576
|
+
r"\\max_{([a-zA-Z][a-zA-Z0-9_]*)=([a-zA-Z][a-zA-Z0-9_]*)}\(": r'lmax(\1 \2=1,',
|
|
577
|
+
r'\\min_{('+namepat+r')}\(' : r'lmin(\1,',
|
|
578
|
+
r"\\min_{([a-zA-Z][a-zA-Z0-9_]*)=([a-zA-Z][a-zA-Z0-9_]*)}\(": r'lmin(\1 \2=1,',
|
|
579
|
+
|
|
580
|
+
}
|
|
581
|
+
# breakpoint()
|
|
582
|
+
try:
|
|
583
|
+
for before,to in ftrans.items():
|
|
584
|
+
temp = defunk(before,to,temp)
|
|
585
|
+
except:
|
|
586
|
+
print(f'{before=} {to=} {temp=}')
|
|
587
|
+
# debug_var(temp)
|
|
588
|
+
|
|
589
|
+
for before,to in trans.items():
|
|
590
|
+
temp = temp.replace(before,to)
|
|
591
|
+
# debug_var(temp)
|
|
592
|
+
|
|
593
|
+
for before,to in regtrans.items():
|
|
594
|
+
temp = re.sub(before,to,temp)
|
|
595
|
+
# debug_var(temp)
|
|
596
|
+
temp = debrace(temp)
|
|
597
|
+
temp = defrack(temp)
|
|
598
|
+
temp = depower(temp)
|
|
599
|
+
temp = ' '.join(temp.split())
|
|
600
|
+
if '__' in temp:
|
|
601
|
+
res = f'doable {tag} {temp}'
|
|
602
|
+
else:
|
|
603
|
+
res = f'{tag} {temp}'
|
|
604
|
+
|
|
605
|
+
if '\\' in res:
|
|
606
|
+
raise ModelSpecificationError(
|
|
607
|
+
f"Some LaTeX has survived in the model (excerpt): {res[:200]}"
|
|
608
|
+
)
|
|
609
|
+
return res
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def apply_replacements(formulas: str, replacements: Union[tuple[str, str], list[tuple[str, str]]]) -> str:
|
|
614
|
+
"""
|
|
615
|
+
Apply one or more string replacements to a formula string.
|
|
616
|
+
|
|
617
|
+
Parameters
|
|
618
|
+
----------
|
|
619
|
+
formulas : str
|
|
620
|
+
The input string containing one or more formulas.
|
|
621
|
+
replacements : tuple[str, str] or list[tuple[str, str]]
|
|
622
|
+
Either a single (pattern, replacement) tuple or
|
|
623
|
+
a list of such tuples.
|
|
624
|
+
|
|
625
|
+
Example:
|
|
626
|
+
('__dim', '__{banks}__{country}__{ports}')
|
|
627
|
+
or:
|
|
628
|
+
[
|
|
629
|
+
('__dim', '__{banks}__{country}__{ports}'),
|
|
630
|
+
('GDP', 'GDP_REAL')
|
|
631
|
+
]
|
|
632
|
+
|
|
633
|
+
Returns
|
|
634
|
+
-------
|
|
635
|
+
str
|
|
636
|
+
The modified formula string after all replacements have been applied.
|
|
637
|
+
If no replacements are given or replacements is empty, the original
|
|
638
|
+
string is returned unchanged.
|
|
639
|
+
"""
|
|
640
|
+
if not replacements:
|
|
641
|
+
return formulas
|
|
642
|
+
|
|
643
|
+
# Ensure replacements is a list of tuples
|
|
644
|
+
if isinstance(replacements, tuple) and len(replacements) == 2 and isinstance(replacements[0], str):
|
|
645
|
+
replacements = [replacements]
|
|
646
|
+
|
|
647
|
+
updated = formulas
|
|
648
|
+
for old, new in replacements:
|
|
649
|
+
updated = updated.replace(old, new)
|
|
650
|
+
return updated
|
|
651
|
+
|
|
652
|
+
def mfmod_list_to_markdown(text: str) -> str:
|
|
653
|
+
"""
|
|
654
|
+
Convert MFMod-style list definitions (EViews/ModelFlow format) into
|
|
655
|
+
aligned Markdown tables, while keeping all other lines unchanged.
|
|
656
|
+
|
|
657
|
+
Example:
|
|
658
|
+
Input:
|
|
659
|
+
>list ages = ages : age_0 * age_11
|
|
660
|
+
>list sexes = sexes : female male /
|
|
661
|
+
> fertile : 1 0
|
|
662
|
+
other stuff
|
|
663
|
+
|
|
664
|
+
Output:
|
|
665
|
+
(Markdown tables for lists with blank line before each)
|
|
666
|
+
other stuff
|
|
667
|
+
|
|
668
|
+
Behavior:
|
|
669
|
+
- Detects lines starting with '>list' as list definitions.
|
|
670
|
+
- Parses sublists separated by '/' and items separated by whitespace.
|
|
671
|
+
- Renders Markdown tables with aligned columns and clear '|'.
|
|
672
|
+
- Leaves all non-list lines unchanged (including indentation).
|
|
673
|
+
- Adds one blank line before each rendered list.
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
lines = text.splitlines()
|
|
677
|
+
output = []
|
|
678
|
+
|
|
679
|
+
# Temporary buffers for list parsing
|
|
680
|
+
current_list = None
|
|
681
|
+
current_entries = []
|
|
682
|
+
inside_list = False
|
|
683
|
+
raw_block = [] # keep original lines for fallback
|
|
684
|
+
|
|
685
|
+
def flush():
|
|
686
|
+
"""
|
|
687
|
+
Finalize the current list block:
|
|
688
|
+
- If successfully parsed → render as Markdown table.
|
|
689
|
+
- If invalid → output raw block unchanged.
|
|
690
|
+
- Always reset list state.
|
|
691
|
+
"""
|
|
692
|
+
nonlocal current_list, current_entries, inside_list, raw_block
|
|
693
|
+
if current_list and current_entries:
|
|
694
|
+
# Add a blank line before each table unless we’re at the top
|
|
695
|
+
if output and output[-1].strip():
|
|
696
|
+
output.append("")
|
|
697
|
+
output.append(render_list_table(current_list, current_entries))
|
|
698
|
+
output.append("") # 👈 ensures a blank line after the table
|
|
699
|
+
|
|
700
|
+
elif inside_list and raw_block:
|
|
701
|
+
# List start detected but not parsed → keep original text
|
|
702
|
+
if output and output[-1].strip():
|
|
703
|
+
output.append("")
|
|
704
|
+
output.extend(raw_block)
|
|
705
|
+
|
|
706
|
+
# Reset for next possible list
|
|
707
|
+
current_list, current_entries, inside_list, raw_block = None, [], False, []
|
|
708
|
+
|
|
709
|
+
# --- Main parsing loop ---
|
|
710
|
+
for line in lines:
|
|
711
|
+
stripped = line.strip()
|
|
712
|
+
|
|
713
|
+
# Case 1: Start of new list definition
|
|
714
|
+
if stripped.startswith(">list"):
|
|
715
|
+
flush() # finish any previous list
|
|
716
|
+
inside_list = True
|
|
717
|
+
raw_block = [line]
|
|
718
|
+
|
|
719
|
+
# Try to extract list name and right-hand content
|
|
720
|
+
match = re.match(r'>list\s+(\w+)\s*=\s*(.*)', stripped)
|
|
721
|
+
if match:
|
|
722
|
+
current_list, rest = match.groups()
|
|
723
|
+
current_entries.extend(parse_sublists(rest))
|
|
724
|
+
else:
|
|
725
|
+
# only name, content may follow later
|
|
726
|
+
current_list = stripped.replace('>list', '').strip()
|
|
727
|
+
|
|
728
|
+
# Case 2: Continuation lines (e.g., '> fertile : 1 0')
|
|
729
|
+
elif inside_list and stripped.startswith(">") and not stripped.startswith(">list"):
|
|
730
|
+
raw_block.append(line)
|
|
731
|
+
content = stripped[1:].strip()
|
|
732
|
+
current_entries.extend(parse_sublists(content))
|
|
733
|
+
|
|
734
|
+
# Case 3: A non-'>' line ends the list block
|
|
735
|
+
elif inside_list and not stripped.startswith(">"):
|
|
736
|
+
flush()
|
|
737
|
+
output.append(line)
|
|
738
|
+
|
|
739
|
+
# Case 4: Outside of any list
|
|
740
|
+
else:
|
|
741
|
+
if inside_list:
|
|
742
|
+
flush()
|
|
743
|
+
output.append(line)
|
|
744
|
+
|
|
745
|
+
# Handle any unfinished list at the end
|
|
746
|
+
flush()
|
|
747
|
+
|
|
748
|
+
return "\n".join(output)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def mfmod_list_to_codeblock(text: str) -> str:
|
|
752
|
+
"""
|
|
753
|
+
Wrap MFMod-style list definitions (lines starting with '>list' and following '>' lines)
|
|
754
|
+
inside triple backticks with 'text' language tag so they render as fixed-width blocks
|
|
755
|
+
in Jupyter Book. All non-list lines are left unchanged.
|
|
756
|
+
|
|
757
|
+
Example:
|
|
758
|
+
Input:
|
|
759
|
+
>list ages = ages : age_0 * age_11
|
|
760
|
+
>list sexes = sexes : female male /
|
|
761
|
+
> fertile : 1 0
|
|
762
|
+
other stuff
|
|
763
|
+
|
|
764
|
+
Output:
|
|
765
|
+
```text
|
|
766
|
+
>list ages = ages : age_0 * age_11
|
|
767
|
+
```
|
|
768
|
+
```text
|
|
769
|
+
>list sexes = sexes : female male /
|
|
770
|
+
> fertile : 1 0
|
|
771
|
+
```
|
|
772
|
+
other stuff
|
|
773
|
+
"""
|
|
774
|
+
lines = text.splitlines()
|
|
775
|
+
output = []
|
|
776
|
+
inside_block = False
|
|
777
|
+
|
|
778
|
+
for line in lines:
|
|
779
|
+
stripped = line.strip()
|
|
780
|
+
if stripped.startswith(">list"): # start of new block
|
|
781
|
+
if inside_block: # close previous
|
|
782
|
+
output.append("```")
|
|
783
|
+
inside_block = False
|
|
784
|
+
if output and output[-1].strip():
|
|
785
|
+
output.append("") # blank line before
|
|
786
|
+
output.append("```text")
|
|
787
|
+
output.append(line)
|
|
788
|
+
inside_block = True
|
|
789
|
+
elif inside_block and stripped.startswith(">"): # continuation
|
|
790
|
+
output.append(line)
|
|
791
|
+
elif inside_block: # end of block
|
|
792
|
+
output.append("```")
|
|
793
|
+
inside_block = False
|
|
794
|
+
output.append(line)
|
|
795
|
+
else:
|
|
796
|
+
output.append(line)
|
|
797
|
+
|
|
798
|
+
if inside_block: # close any open block
|
|
799
|
+
output.append("```")
|
|
800
|
+
|
|
801
|
+
return "\n".join(output)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def parse_sublists(text: str) -> list[tuple[str, list[str]]]:
|
|
805
|
+
"""
|
|
806
|
+
Parse a text fragment defining one or more sublists into a list of tuples.
|
|
807
|
+
|
|
808
|
+
Example:
|
|
809
|
+
'sexes : female male / fertile : 1 0'
|
|
810
|
+
→ [('sexes', ['female', 'male']), ('fertile', ['1', '0'])]
|
|
811
|
+
"""
|
|
812
|
+
result = []
|
|
813
|
+
for part in [p.strip() for p in text.split('/') if p.strip()]:
|
|
814
|
+
if ':' not in part:
|
|
815
|
+
continue
|
|
816
|
+
sub, vals = [p.strip() for p in part.split(':', 1)]
|
|
817
|
+
result.append((sub, vals.split()))
|
|
818
|
+
return result
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def render_list_table(list_name: str, entries: list[tuple[str, list[str]]]) -> str:
|
|
822
|
+
"""
|
|
823
|
+
Render one parsed list into a Markdown table, preserving structure.
|
|
824
|
+
|
|
825
|
+
Example:
|
|
826
|
+
[('ages', ['age_0', '*', 'age_11'])] →
|
|
827
|
+
> list
|
|
828
|
+
| **list name** | **sublist name** ||| |
|
|
829
|
+
|:------------------|:-------------------------------------------|-:|-:|-:|
|
|
830
|
+
| ages | ages | age_0 | * | age_11 |
|
|
831
|
+
"""
|
|
832
|
+
if not entries:
|
|
833
|
+
return ""
|
|
834
|
+
|
|
835
|
+
# Determine max number of value columns
|
|
836
|
+
max_cols = max(len(e[1]) for e in entries)
|
|
837
|
+
pipes = " |" * max_cols
|
|
838
|
+
|
|
839
|
+
# Header and alignment lines (exact Markdown structure)
|
|
840
|
+
header = f"\n| **list name** | **sublist name** {pipes} |\n"
|
|
841
|
+
header += (
|
|
842
|
+
f"|:------------------|:-------------------------------------------|"
|
|
843
|
+
+ "|".join(["---:" for _ in range(max_cols)])
|
|
844
|
+
+ "|\n"
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
# Align sublist names to the longest one for consistent look
|
|
848
|
+
max_sub_len = max(len(sub) for sub, _ in entries)
|
|
849
|
+
|
|
850
|
+
rows = []
|
|
851
|
+
first = True
|
|
852
|
+
for sublist, items in entries:
|
|
853
|
+
# Only show list name once
|
|
854
|
+
list_cell = list_name if first else ""
|
|
855
|
+
first = False
|
|
856
|
+
|
|
857
|
+
# Pad with blanks if some sublists have fewer entries
|
|
858
|
+
padded_items = items + [""] * (max_cols - len(items))
|
|
859
|
+
row = f"| {list_cell:<18}| {sublist:<{max_sub_len}} | " + " | ".join(padded_items) + " |"
|
|
860
|
+
rows.append(row)
|
|
861
|
+
|
|
862
|
+
return header + "\n".join(rows)
|
|
863
|
+
import re
|
|
864
|
+
|
|
865
|
+
def mfmod_list_to_codeblock(text: str) -> str:
|
|
866
|
+
"""
|
|
867
|
+
Wrap any consecutive lines starting with '>' (including '>list' definitions)
|
|
868
|
+
in fenced code blocks (```text) so they render in fixed-width font
|
|
869
|
+
in Jupyter Book. All other lines remain unchanged.
|
|
870
|
+
|
|
871
|
+
Example
|
|
872
|
+
-------
|
|
873
|
+
Input:
|
|
874
|
+
>list ages = ages : age_0 * age_11
|
|
875
|
+
>list sexes = sexes : female male /
|
|
876
|
+
> fertile : 1 0
|
|
877
|
+
other stuff
|
|
878
|
+
|
|
879
|
+
Output:
|
|
880
|
+
```text
|
|
881
|
+
>list ages = ages : age_0 * age_11
|
|
882
|
+
>list sexes = sexes : female male /
|
|
883
|
+
> fertile : 1 0
|
|
884
|
+
```
|
|
885
|
+
other stuff
|
|
886
|
+
"""
|
|
887
|
+
lines = text.splitlines()
|
|
888
|
+
output = []
|
|
889
|
+
inside_block = False
|
|
890
|
+
|
|
891
|
+
for line in lines:
|
|
892
|
+
stripped = line.strip()
|
|
893
|
+
|
|
894
|
+
# Lines starting with '>' belong to a code block
|
|
895
|
+
if stripped.startswith(">"):
|
|
896
|
+
if not inside_block:
|
|
897
|
+
# add a blank line before starting a block if previous line not blank
|
|
898
|
+
if output and output[-1].strip():
|
|
899
|
+
output.append("")
|
|
900
|
+
output.append("```text")
|
|
901
|
+
inside_block = True
|
|
902
|
+
output.append(line)
|
|
903
|
+
else:
|
|
904
|
+
# close any open block before non-'>' line
|
|
905
|
+
if inside_block:
|
|
906
|
+
output.append("```")
|
|
907
|
+
inside_block = False
|
|
908
|
+
output.append(line)
|
|
909
|
+
|
|
910
|
+
# close any unclosed block at end
|
|
911
|
+
if inside_block:
|
|
912
|
+
output.append("```")
|
|
913
|
+
|
|
914
|
+
return "\n".join(output)
|
|
915
|
+
|
|
916
|
+
import modelnormalize as nz
|
|
917
|
+
from functools import cached_property
|
|
918
|
+
|
|
919
|
+
def render_markdown_model(cell: str, style: str = "mixed") -> str:
|
|
920
|
+
"""
|
|
921
|
+
Prepare Markdown text for rendering.
|
|
922
|
+
|
|
923
|
+
Responsibilities:
|
|
924
|
+
- Protect model lines (starting with '>') by wrapping them in code fences
|
|
925
|
+
- Leave LaTeX blocks untouched
|
|
926
|
+
- Do NOT render anything
|
|
927
|
+
"""
|
|
928
|
+
|
|
929
|
+
if style == "plain":
|
|
930
|
+
return cell
|
|
931
|
+
|
|
932
|
+
lines = cell.splitlines()
|
|
933
|
+
out = []
|
|
934
|
+
|
|
935
|
+
in_code = False
|
|
936
|
+
in_fence = False
|
|
937
|
+
|
|
938
|
+
def start_code():
|
|
939
|
+
nonlocal in_code
|
|
940
|
+
if not in_code:
|
|
941
|
+
out.append("```text")
|
|
942
|
+
in_code = True
|
|
943
|
+
|
|
944
|
+
def end_code():
|
|
945
|
+
nonlocal in_code
|
|
946
|
+
if in_code:
|
|
947
|
+
out.append("```")
|
|
948
|
+
in_code = False
|
|
949
|
+
|
|
950
|
+
for line in lines:
|
|
951
|
+
stripped = line.lstrip()
|
|
952
|
+
|
|
953
|
+
# Preserve existing fenced blocks
|
|
954
|
+
if stripped.startswith("```"):
|
|
955
|
+
end_code()
|
|
956
|
+
out.append(stripped)
|
|
957
|
+
in_fence = not in_fence
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
if in_fence:
|
|
961
|
+
out.append(line)
|
|
962
|
+
continue
|
|
963
|
+
|
|
964
|
+
# Model lines → code block
|
|
965
|
+
if stripped.startswith(">"):
|
|
966
|
+
start_code()
|
|
967
|
+
out.append(stripped)
|
|
968
|
+
continue
|
|
969
|
+
|
|
970
|
+
# Normal text
|
|
971
|
+
end_code()
|
|
972
|
+
out.append(line)
|
|
973
|
+
|
|
974
|
+
end_code()
|
|
975
|
+
# debug_var(out)
|
|
976
|
+
return "\n".join(out)
|
|
977
|
+
|
|
978
|
+
import re
|
|
979
|
+
from IPython.display import display, Markdown, Math
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
import re
|
|
983
|
+
from IPython.display import display, Markdown, Math
|
|
984
|
+
|
|
985
|
+
def display_model(cell: str, spec: str = "markdown"):
|
|
986
|
+
"""
|
|
987
|
+
Display a model specification.
|
|
988
|
+
|
|
989
|
+
spec = "markdown" | "latex"
|
|
990
|
+
"""
|
|
991
|
+
if spec == "markdown":
|
|
992
|
+
display_markdown_model(cell)
|
|
993
|
+
elif spec == "latex":
|
|
994
|
+
display_latex_model(cell)
|
|
995
|
+
else:
|
|
996
|
+
raise ValueError("spec must be 'markdown' or 'latex'")
|
|
997
|
+
|
|
998
|
+
def display_markdown_model(cell: str):
|
|
999
|
+
cell = render_markdown_model(cell)
|
|
1000
|
+
display_mixed_markdown(cell)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def markdown_headings_to_latex(text: str) -> str:
|
|
1005
|
+
lines = []
|
|
1006
|
+
for line in text.splitlines():
|
|
1007
|
+
if line.startswith("### "):
|
|
1008
|
+
lines.append(r"\subsubsection{" + line[4:] + "}")
|
|
1009
|
+
elif line.startswith("## "):
|
|
1010
|
+
lines.append(r"\subsection{" + line[3:] + "}")
|
|
1011
|
+
elif line.startswith("# "):
|
|
1012
|
+
lines.append(r"\section{" + line[2:] + "}")
|
|
1013
|
+
else:
|
|
1014
|
+
lines.append(line)
|
|
1015
|
+
return "\n".join(lines)
|
|
1016
|
+
|
|
1017
|
+
def protect_model_code_latex(md: str) -> str:
|
|
1018
|
+
out = []
|
|
1019
|
+
in_code = False
|
|
1020
|
+
|
|
1021
|
+
for line in md.splitlines():
|
|
1022
|
+
if line.lstrip().startswith(">"):
|
|
1023
|
+
if not in_code:
|
|
1024
|
+
out.append(r"\begin{verbatim}")
|
|
1025
|
+
in_code = True
|
|
1026
|
+
out.append(line.lstrip()[1:])
|
|
1027
|
+
else:
|
|
1028
|
+
if in_code:
|
|
1029
|
+
out.append(r"\end{verbatim}")
|
|
1030
|
+
in_code = False
|
|
1031
|
+
out.append(line)
|
|
1032
|
+
|
|
1033
|
+
if in_code:
|
|
1034
|
+
out.append(r"\end{verbatim}")
|
|
1035
|
+
|
|
1036
|
+
return "\n".join(out)
|
|
1037
|
+
|
|
1038
|
+
from IPython.display import Latex, display
|
|
1039
|
+
|
|
1040
|
+
def display_latex_model(cell: str):
|
|
1041
|
+
# cell = protect_model_code_latex(cell)
|
|
1042
|
+
|
|
1043
|
+
display(Markdown(cell))
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def display_mixed_markdown(md: str):
|
|
1048
|
+
"""
|
|
1049
|
+
Display Markdown mixed with LaTeX safely in Jupyter.
|
|
1050
|
+
|
|
1051
|
+
Supports unlimited alternation of:
|
|
1052
|
+
- Markdown prose
|
|
1053
|
+
- LaTeX equation / align environments
|
|
1054
|
+
- $$ ... $$ blocks
|
|
1055
|
+
|
|
1056
|
+
Rendering rules:
|
|
1057
|
+
- Math() receives ONLY pure math (no environments, no labels, no tags)
|
|
1058
|
+
- Markdown() never sees LaTeX display environments
|
|
1059
|
+
"""
|
|
1060
|
+
|
|
1061
|
+
pattern = re.compile(
|
|
1062
|
+
r"\$\$(.*?)\$\$"
|
|
1063
|
+
r"|\\begin\{equation\}(.*?)\\end\{equation\}"
|
|
1064
|
+
r"|\\begin\{align\}(.*?)\\end\{align\}",
|
|
1065
|
+
flags=re.DOTALL,
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
pos = 0
|
|
1069
|
+
|
|
1070
|
+
def _clean_math_body(math: str) -> str:
|
|
1071
|
+
"""
|
|
1072
|
+
Remove non-math directives from LaTeX before MathJax rendering.
|
|
1073
|
+
"""
|
|
1074
|
+
LABEL_RE = re.compile(r"\\label\{[^}]*\}")
|
|
1075
|
+
TAG_RE = re.compile(r"%\s*@<[^>]*>")
|
|
1076
|
+
COMMENT_RE = re.compile(r"%.*?$", re.MULTILINE)
|
|
1077
|
+
|
|
1078
|
+
math = LABEL_RE.sub("", math)
|
|
1079
|
+
math = TAG_RE.sub("", math)
|
|
1080
|
+
math = COMMENT_RE.sub("", math)
|
|
1081
|
+
return math.strip()
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
for m in pattern.finditer(md):
|
|
1086
|
+
# ---- Markdown before math ----
|
|
1087
|
+
if m.start() > pos:
|
|
1088
|
+
text = md[pos:m.start()]
|
|
1089
|
+
if text.strip():
|
|
1090
|
+
display(Markdown(text))
|
|
1091
|
+
|
|
1092
|
+
# ---- Math block ----
|
|
1093
|
+
raw_math = next(g for g in m.groups() if g is not None)
|
|
1094
|
+
clean_math = _clean_math_body(raw_math)
|
|
1095
|
+
if clean_math:
|
|
1096
|
+
display(Math(clean_math))
|
|
1097
|
+
|
|
1098
|
+
pos = m.end()
|
|
1099
|
+
|
|
1100
|
+
# ---- Trailing Markdown ----
|
|
1101
|
+
rest = md[pos:]
|
|
1102
|
+
if rest.strip():
|
|
1103
|
+
display(Markdown(rest))
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
# =============================================================================
|
|
1110
|
+
# Optional in-Makemodel estimation support
|
|
1111
|
+
# =============================================================================
|
|
1112
|
+
|
|
1113
|
+
def _split_frml_options(frml_name: str) -> list[str]:
|
|
1114
|
+
"""Return comma-separated options inside a FRML-name flag."""
|
|
1115
|
+
if not frml_name:
|
|
1116
|
+
return []
|
|
1117
|
+
text = str(frml_name).strip()
|
|
1118
|
+
if text.startswith('<') and text.endswith('>'):
|
|
1119
|
+
text = text[1:-1]
|
|
1120
|
+
return [part.strip() for part in text.split(',') if part.strip()]
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _frml_option_value(frml_name: str, key: str, default=None):
|
|
1124
|
+
"""Read KEY or KEY=value from a FRML-name flag using kw_frml_name.
|
|
1125
|
+
|
|
1126
|
+
``EST`` is accepted as a short alias for ``ESTIMATOR``:
|
|
1127
|
+
|
|
1128
|
+
<est=ls> -> estimator named ``ls``
|
|
1129
|
+
<est> -> use the global/default estimator
|
|
1130
|
+
<estimator=ols> -> built-in OLS estimator
|
|
1131
|
+
|
|
1132
|
+
``kw_frml_name`` now returns True for bare flags. For compatibility with
|
|
1133
|
+
older versions that returned 1, this helper normalizes 1 to True.
|
|
1134
|
+
"""
|
|
1135
|
+
aliases = {
|
|
1136
|
+
"ESTIMATOR": ("ESTIMATOR", "EST"),
|
|
1137
|
+
}
|
|
1138
|
+
keys = aliases.get(str(key).upper(), (key,))
|
|
1139
|
+
for lookup_key in keys:
|
|
1140
|
+
value = kw_frml_name(frml_name, lookup_key, default=None)
|
|
1141
|
+
if value is not None:
|
|
1142
|
+
return True if value == 1 else value
|
|
1143
|
+
return default
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _strip_frml_options(frml_name: str, remove_keys: set[str]) -> str:
|
|
1147
|
+
"""Remove selected options from a FRML-name flag and rebuild <...>.
|
|
1148
|
+
|
|
1149
|
+
Kept for compatibility with older helper code. Makemodel now preserves
|
|
1150
|
+
estimation options such as ``estimator``/``est`` and ``smpl`` in emitted
|
|
1151
|
+
FRML names, so normal model construction no longer calls this helper.
|
|
1152
|
+
"""
|
|
1153
|
+
remove_keys = {k.upper() for k in remove_keys}
|
|
1154
|
+
kept = []
|
|
1155
|
+
for part in _split_frml_options(frml_name):
|
|
1156
|
+
lhs = part.partition('=')[0].strip().upper()
|
|
1157
|
+
if lhs not in remove_keys:
|
|
1158
|
+
kept.append(part)
|
|
1159
|
+
return '<' + ','.join(kept) + '>' if kept else '<>'
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _parse_smpl(smpl, df=None):
|
|
1163
|
+
"""Parse an estimation sample specification.
|
|
1164
|
+
|
|
1165
|
+
Preferred FRML syntax is::
|
|
1166
|
+
|
|
1167
|
+
<smpl=start end>
|
|
1168
|
+
|
|
1169
|
+
where ``start`` and ``end`` are separated by one or more blanks. The
|
|
1170
|
+
labels are **not** coerced to integers, because the dataframe index may be
|
|
1171
|
+
a PeriodIndex, DatetimeIndex, quarterly strings, or another custom index
|
|
1172
|
+
type. If ``df`` is supplied, its index is used via ``slice_locs`` to
|
|
1173
|
+
validate the sample and to return the actual boundary labels present in
|
|
1174
|
+
the dataframe.
|
|
1175
|
+
|
|
1176
|
+
Backwards compatibility: ``start:end`` is still accepted.
|
|
1177
|
+
"""
|
|
1178
|
+
if smpl in (None, '', False):
|
|
1179
|
+
return None
|
|
1180
|
+
|
|
1181
|
+
def _slice_on_df(start, end, df_):
|
|
1182
|
+
"""Return (first_label, last_label) selected by df_.index.slice_locs.
|
|
1183
|
+
|
|
1184
|
+
Warns when the requested upper bound is past the last index label and
|
|
1185
|
+
the sample is silently truncated. ``slice_locs`` accepts an end
|
|
1186
|
+
beyond the index without complaining, so without this check users
|
|
1187
|
+
can ask for ``smpl=2010 2025`` against data that ends in 2020 and
|
|
1188
|
+
not notice the sample stopped early.
|
|
1189
|
+
"""
|
|
1190
|
+
if df_ is None:
|
|
1191
|
+
return (start, end)
|
|
1192
|
+
|
|
1193
|
+
idx = df_.index
|
|
1194
|
+
|
|
1195
|
+
attempts = [(start, end)]
|
|
1196
|
+
# Compatibility only: if the index is numeric and the user wrote
|
|
1197
|
+
# ``smpl=2012 2019`` as text, try integer labels after trying the raw
|
|
1198
|
+
# labels. We never coerce first, so quarterly/date/string labels keep
|
|
1199
|
+
# their natural interpretation.
|
|
1200
|
+
try:
|
|
1201
|
+
attempts.append((int(start), int(end)))
|
|
1202
|
+
except Exception:
|
|
1203
|
+
pass
|
|
1204
|
+
|
|
1205
|
+
last_error = None
|
|
1206
|
+
for s, e in attempts:
|
|
1207
|
+
try:
|
|
1208
|
+
istart, iend = idx.slice_locs(s, e)
|
|
1209
|
+
per = idx[istart:iend]
|
|
1210
|
+
if len(per) == 0:
|
|
1211
|
+
raise IndexError(
|
|
1212
|
+
f"sample {start!r} {end!r} selects no rows"
|
|
1213
|
+
)
|
|
1214
|
+
# Warn when the user asked for an end that the index did not
|
|
1215
|
+
# actually contain. Compare against the original requested
|
|
1216
|
+
# end (e), not the resolved per[-1].
|
|
1217
|
+
try:
|
|
1218
|
+
if e is not None and per[-1] != e:
|
|
1219
|
+
# If the index has any label strictly less than
|
|
1220
|
+
# per[-1] but greater than or equal to e, that means
|
|
1221
|
+
# e was *between* labels and we rounded down — fine.
|
|
1222
|
+
# The problematic case is e being beyond idx[-1].
|
|
1223
|
+
if e > idx[-1]:
|
|
1224
|
+
print(
|
|
1225
|
+
f"⚠️ Sample upper bound {e!r} is past the "
|
|
1226
|
+
f"end of the index ({idx[-1]!r}); using "
|
|
1227
|
+
f"{per[-1]!r} instead."
|
|
1228
|
+
)
|
|
1229
|
+
except Exception:
|
|
1230
|
+
# Comparison may fail across mixed index types; just skip
|
|
1231
|
+
# the warning rather than raising.
|
|
1232
|
+
pass
|
|
1233
|
+
return (per[0], per[-1])
|
|
1234
|
+
except Exception as exc:
|
|
1235
|
+
last_error = exc
|
|
1236
|
+
|
|
1237
|
+
raise ModelSpecificationError(
|
|
1238
|
+
f"Could not resolve sample {start!r} {end!r} on dataframe index "
|
|
1239
|
+
f"of type {type(idx).__name__}: {last_error}"
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
if isinstance(smpl, slice):
|
|
1243
|
+
start, end = smpl.start, smpl.stop
|
|
1244
|
+
if start is None or end is None:
|
|
1245
|
+
return smpl
|
|
1246
|
+
return _slice_on_df(start, end, df)
|
|
1247
|
+
|
|
1248
|
+
if isinstance(smpl, (tuple, list)):
|
|
1249
|
+
if len(smpl) != 2:
|
|
1250
|
+
raise ModelSpecificationError(
|
|
1251
|
+
f"Sample tuple/list must have exactly two elements: {smpl!r}"
|
|
1252
|
+
)
|
|
1253
|
+
return _slice_on_df(smpl[0], smpl[1], df)
|
|
1254
|
+
|
|
1255
|
+
text = str(smpl).strip()
|
|
1256
|
+
if not text:
|
|
1257
|
+
return None
|
|
1258
|
+
|
|
1259
|
+
# New preferred syntax: <smpl=start end>. Keep old colon syntax so older
|
|
1260
|
+
# notebooks do not break.
|
|
1261
|
+
if ':' in text and len(text.split()) == 1:
|
|
1262
|
+
start, end = [p.strip() for p in text.split(':', 1)]
|
|
1263
|
+
else:
|
|
1264
|
+
parts = text.split()
|
|
1265
|
+
if len(parts) != 2:
|
|
1266
|
+
raise ModelSpecificationError(
|
|
1267
|
+
"SMPL must be written as 'start end' separated by blanks "
|
|
1268
|
+
f"(or legacy 'start:end'); got {smpl!r}"
|
|
1269
|
+
)
|
|
1270
|
+
start, end = parts
|
|
1271
|
+
|
|
1272
|
+
return _slice_on_df(start, end, df)
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _estimator_display_name(estimator_spec) -> str:
|
|
1276
|
+
"""Short user/debug label for either a string estimator or a callable factory."""
|
|
1277
|
+
if isinstance(estimator_spec, str):
|
|
1278
|
+
return estimator_spec.strip().lower()
|
|
1279
|
+
name = getattr(estimator_spec, "__name__", None)
|
|
1280
|
+
if name and name != "factory":
|
|
1281
|
+
return name
|
|
1282
|
+
qualname = getattr(estimator_spec, "__qualname__", None)
|
|
1283
|
+
if qualname:
|
|
1284
|
+
return qualname
|
|
1285
|
+
return type(estimator_spec).__name__
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _estimator_factory_defaults(estimator_callable) -> dict:
|
|
1289
|
+
"""Return defaults attached by modelestimator_new.with_defaults(...).
|
|
1290
|
+
|
|
1291
|
+
Makemodel needs the factory defaults before instantiating the estimator so
|
|
1292
|
+
equation-local ``smpl=...`` can be parsed against the same dataframe index
|
|
1293
|
+
that the estimator will use. The important point is that this does **not**
|
|
1294
|
+
call the factory: constructing an EstimatorBackend runs the estimation in
|
|
1295
|
+
``__post_init__``.
|
|
1296
|
+
|
|
1297
|
+
New modelestimator_new factories expose:
|
|
1298
|
+
|
|
1299
|
+
factory._estimator_defaults = {"input_df": input_df, **default_kwargs}
|
|
1300
|
+
factory._estimator_class = cls
|
|
1301
|
+
|
|
1302
|
+
A small non-calling fallback for ``functools.partial`` and simple callable
|
|
1303
|
+
objects is kept for user-defined factories.
|
|
1304
|
+
"""
|
|
1305
|
+
defaults = getattr(estimator_callable, "_estimator_defaults", None)
|
|
1306
|
+
if isinstance(defaults, dict):
|
|
1307
|
+
return defaults
|
|
1308
|
+
|
|
1309
|
+
# functools.partial or similar callables.
|
|
1310
|
+
keywords = getattr(estimator_callable, "keywords", None)
|
|
1311
|
+
if isinstance(keywords, dict):
|
|
1312
|
+
return dict(keywords)
|
|
1313
|
+
|
|
1314
|
+
# Callable objects may expose defaults explicitly.
|
|
1315
|
+
defaults = getattr(estimator_callable, "defaults", None)
|
|
1316
|
+
if isinstance(defaults, dict):
|
|
1317
|
+
return defaults
|
|
1318
|
+
defaults = getattr(estimator_callable, "_defaults", None)
|
|
1319
|
+
if isinstance(defaults, dict):
|
|
1320
|
+
return defaults
|
|
1321
|
+
|
|
1322
|
+
return {}
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _input_df_from_estimator_factory(estimator_callable):
|
|
1326
|
+
"""Return the input_df captured by an estimator factory, if exposed."""
|
|
1327
|
+
return _estimator_factory_defaults(estimator_callable).get("input_df")
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _infer_input_df_from_estimator_spec(estimator_spec, estimator_classes: Optional[dict] = None):
|
|
1331
|
+
"""Infer the dataframe carried by an estimator class/factory, if possible.
|
|
1332
|
+
|
|
1333
|
+
This is only used to parse equation-local samples. It lets
|
|
1334
|
+
|
|
1335
|
+
ls = Estimate_nls.with_defaults(input_df=npl, smpl=(2011, 2019))
|
|
1336
|
+
<estimator=ls,smpl=2013 2018> ...
|
|
1337
|
+
|
|
1338
|
+
convert ``2013`` and ``2018`` using ``npl.index`` even when Makemodel itself
|
|
1339
|
+
was not constructed with ``input_df=npl``. The factory is not called during
|
|
1340
|
+
this discovery step, so no dummy estimation is run.
|
|
1341
|
+
"""
|
|
1342
|
+
try:
|
|
1343
|
+
constructor = _get_estimator_class(estimator_spec, estimator_classes)
|
|
1344
|
+
except Exception:
|
|
1345
|
+
return None
|
|
1346
|
+
return _input_df_from_estimator_factory(constructor)
|
|
1347
|
+
|
|
1348
|
+
def _caller_estimator_namespace() -> dict:
|
|
1349
|
+
"""Return caller locals/globals so notebook names can be used in <estimator=name>.
|
|
1350
|
+
|
|
1351
|
+
This is intentionally best-effort. Explicit ``estimator_classes`` passed to
|
|
1352
|
+
Makemodel still takes precedence over anything found in the caller scope.
|
|
1353
|
+
"""
|
|
1354
|
+
import inspect
|
|
1355
|
+
|
|
1356
|
+
frame = inspect.currentframe()
|
|
1357
|
+
try:
|
|
1358
|
+
frame = frame.f_back if frame else None
|
|
1359
|
+
while frame is not None:
|
|
1360
|
+
module_name = frame.f_globals.get('__name__')
|
|
1361
|
+
function_name = frame.f_code.co_name
|
|
1362
|
+
# Skip frames inside this module and the generated dataclass __init__.
|
|
1363
|
+
if module_name != __name__ and function_name not in {'__init__'}:
|
|
1364
|
+
namespace = {}
|
|
1365
|
+
namespace.update(frame.f_globals)
|
|
1366
|
+
namespace.update(frame.f_locals)
|
|
1367
|
+
return namespace
|
|
1368
|
+
frame = frame.f_back
|
|
1369
|
+
finally:
|
|
1370
|
+
del frame
|
|
1371
|
+
return {}
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _merge_estimator_namespaces(
|
|
1375
|
+
explicit: Optional[dict] = None,
|
|
1376
|
+
namespace: Optional[dict] = None,
|
|
1377
|
+
) -> dict:
|
|
1378
|
+
"""Merge caller-scope estimator names with explicit estimator_classes.
|
|
1379
|
+
|
|
1380
|
+
Only callable objects are copied from the caller namespace. Explicit
|
|
1381
|
+
estimator_classes wins on name collisions. Keys are kept as written;
|
|
1382
|
+
lookup remains case-insensitive in _get_estimator_class().
|
|
1383
|
+
"""
|
|
1384
|
+
merged = {}
|
|
1385
|
+
for source in (namespace or {},):
|
|
1386
|
+
for key, value in source.items():
|
|
1387
|
+
if isinstance(key, str) and callable(value):
|
|
1388
|
+
merged[key] = value
|
|
1389
|
+
for key, value in (explicit or {}).items():
|
|
1390
|
+
merged[key] = value
|
|
1391
|
+
return merged
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _get_estimator_class(estimator_name, estimator_classes: Optional[dict] = None):
|
|
1395
|
+
"""Resolve an estimator spec to a constructor/factory.
|
|
1396
|
+
|
|
1397
|
+
``estimator_name`` can be:
|
|
1398
|
+
- a built-in method name: ``"ols"``, ``"nls_lmfit"``, ``"nls_eviews"``;
|
|
1399
|
+
- a key in ``estimator_classes`` whose value is either a class or a
|
|
1400
|
+
callable factory;
|
|
1401
|
+
- a callable/factory directly, e.g. ``Estimate_nls.with_defaults(...)``.
|
|
1402
|
+
|
|
1403
|
+
The resolved object is only required to be callable here. After it is
|
|
1404
|
+
called with the equation, the returned object is validated with
|
|
1405
|
+
``isinstance(result, modelestimator_new.EstimatorBackend)``.
|
|
1406
|
+
"""
|
|
1407
|
+
if estimator_name is None or estimator_name is False:
|
|
1408
|
+
raise ModelSpecificationError("Estimator flag was present, but no estimator method was supplied")
|
|
1409
|
+
|
|
1410
|
+
if callable(estimator_name) and not isinstance(estimator_name, str):
|
|
1411
|
+
return estimator_name
|
|
1412
|
+
|
|
1413
|
+
name = str(estimator_name).strip().lower()
|
|
1414
|
+
if not name:
|
|
1415
|
+
raise ModelSpecificationError("Estimator flag was present, but no estimator method was supplied")
|
|
1416
|
+
|
|
1417
|
+
if estimator_classes:
|
|
1418
|
+
# Accept both exact and lower-case keys, so {'LS': ls} and {'ls': ls}
|
|
1419
|
+
# both work after clean_expressions() has upper-cased the DSL.
|
|
1420
|
+
if name in estimator_classes:
|
|
1421
|
+
return estimator_classes[name]
|
|
1422
|
+
for key, value in estimator_classes.items():
|
|
1423
|
+
if str(key).strip().lower() == name:
|
|
1424
|
+
return value
|
|
1425
|
+
|
|
1426
|
+
class_names = {
|
|
1427
|
+
'ols': 'Estimate_ols',
|
|
1428
|
+
'nls_lmfit': 'Estimate_nls_lmfit',
|
|
1429
|
+
'nls_eviews': 'Estimate_nls_eviews',
|
|
1430
|
+
}
|
|
1431
|
+
if name not in class_names:
|
|
1432
|
+
allowed = ', '.join(sorted(class_names))
|
|
1433
|
+
raise ModelSpecificationError(
|
|
1434
|
+
f"Unknown estimator={estimator_name!r}. Expected one of: {allowed}, "
|
|
1435
|
+
"a callable estimator=..., or pass estimator_classes={name: class_or_factory}."
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
try:
|
|
1439
|
+
import modelestimator_new as me
|
|
1440
|
+
except ImportError as exc:
|
|
1441
|
+
raise ModelSpecificationError(
|
|
1442
|
+
"Equations with <estimator=...> require modelestimator_new to be importable."
|
|
1443
|
+
) from exc
|
|
1444
|
+
|
|
1445
|
+
try:
|
|
1446
|
+
return getattr(me, class_names[name])
|
|
1447
|
+
except AttributeError as exc:
|
|
1448
|
+
raise ModelSpecificationError(
|
|
1449
|
+
f"modelestimator_new has no class {class_names[name]!r} for estimator={name!r}"
|
|
1450
|
+
) from exc
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def _maybe_run_estimator_fit(estimator_obj):
|
|
1454
|
+
"""Call the first standard fit/estimate/run method available, if any."""
|
|
1455
|
+
for method_name in ('fit', 'estimate', 'run'):
|
|
1456
|
+
method = getattr(estimator_obj, method_name, None)
|
|
1457
|
+
if callable(method):
|
|
1458
|
+
return method()
|
|
1459
|
+
return None
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _clean_estimated_expression(candidate: str) -> str:
|
|
1463
|
+
"""Turn either an expression or a one-FRML string into a bare expression."""
|
|
1464
|
+
text = str(candidate).strip()
|
|
1465
|
+
if not text:
|
|
1466
|
+
return text
|
|
1467
|
+
if text.upper().lstrip().startswith('FRML '):
|
|
1468
|
+
return split_frml_reqopts(text).expression
|
|
1469
|
+
return text.rstrip('$').strip()
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def _extract_expression_from_estimator(estimator_obj, fit_result=None) -> Optional[str]:
|
|
1473
|
+
"""Extract the baked equation from an estimator backend.
|
|
1474
|
+
|
|
1475
|
+
EstimatorBackend and its subclasses always produce ``org_eq_baked``,
|
|
1476
|
+
which contains the equation with estimated coefficients substituted.
|
|
1477
|
+
|
|
1478
|
+
For custom backends that lack ``org_eq_baked``, falls back to method
|
|
1479
|
+
calls (``to_expression()``, ``to_equation()``, ``to_frml()``).
|
|
1480
|
+
"""
|
|
1481
|
+
# Try the primary source: EstimatorBackend.org_eq_baked
|
|
1482
|
+
baked = getattr(estimator_obj, 'org_eq_baked', None)
|
|
1483
|
+
if isinstance(baked, str) and baked.strip():
|
|
1484
|
+
return _clean_estimated_expression(baked)
|
|
1485
|
+
|
|
1486
|
+
# Fallback for custom backends: try method calls
|
|
1487
|
+
method_names = ('to_expression', 'to_equation', 'to_frml')
|
|
1488
|
+
for obj in (fit_result, estimator_obj):
|
|
1489
|
+
if obj is None:
|
|
1490
|
+
continue
|
|
1491
|
+
for name in method_names:
|
|
1492
|
+
method = getattr(obj, name, None)
|
|
1493
|
+
if callable(method):
|
|
1494
|
+
value = method()
|
|
1495
|
+
if isinstance(value, str) and value.strip():
|
|
1496
|
+
return _clean_estimated_expression(value)
|
|
1497
|
+
return None
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _value_from_param(value):
|
|
1501
|
+
"""Extract a scalar from lmfit/statsmodels/pandas/numpy parameter values."""
|
|
1502
|
+
if hasattr(value, 'value'):
|
|
1503
|
+
value = value.value
|
|
1504
|
+
if hasattr(value, 'item'):
|
|
1505
|
+
try:
|
|
1506
|
+
value = value.item()
|
|
1507
|
+
except Exception:
|
|
1508
|
+
pass
|
|
1509
|
+
return value
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def _dict_from_params(params) -> dict:
|
|
1513
|
+
"""Convert the parameter containers used by modelestimator_new to a plain dict.
|
|
1514
|
+
|
|
1515
|
+
Handles dict / pandas Series / statsmodels params, lmfit.Parameters
|
|
1516
|
+
via valuesdict(), and lmfit Parameter values via .value.
|
|
1517
|
+
"""
|
|
1518
|
+
if params is None:
|
|
1519
|
+
return {}
|
|
1520
|
+
if hasattr(params, 'valuesdict'):
|
|
1521
|
+
params = params.valuesdict()
|
|
1522
|
+
elif hasattr(params, 'to_dict'):
|
|
1523
|
+
params = params.to_dict()
|
|
1524
|
+
if hasattr(params, 'items'):
|
|
1525
|
+
out = {}
|
|
1526
|
+
for k, v in params.items():
|
|
1527
|
+
try:
|
|
1528
|
+
out[str(k).upper()] = float(_value_from_param(v))
|
|
1529
|
+
except Exception:
|
|
1530
|
+
out[str(k).upper()] = _value_from_param(v)
|
|
1531
|
+
return out
|
|
1532
|
+
return {}
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _extract_params_from_estimator(estimator_obj, fit_result=None) -> dict:
|
|
1536
|
+
"""Try common locations for estimated parameters."""
|
|
1537
|
+
params = {}
|
|
1538
|
+
direct_names = (
|
|
1539
|
+
# modelestimator_new.EstimatorBackend contract: every backend stores
|
|
1540
|
+
# coefficients here, keyed as C__1, C__2, ...
|
|
1541
|
+
'coef_estimate_dict',
|
|
1542
|
+
'coef_ser',
|
|
1543
|
+
'estimated_params',
|
|
1544
|
+
'params',
|
|
1545
|
+
'param',
|
|
1546
|
+
'coefficients',
|
|
1547
|
+
'coef',
|
|
1548
|
+
'coefs',
|
|
1549
|
+
'best_values',
|
|
1550
|
+
)
|
|
1551
|
+
nested_names = (
|
|
1552
|
+
'result',
|
|
1553
|
+
'results',
|
|
1554
|
+
'res',
|
|
1555
|
+
'fit_result',
|
|
1556
|
+
'estimation_result',
|
|
1557
|
+
# modelestimator_new keeps the native fitted result here.
|
|
1558
|
+
'regression_model',
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
for obj in (fit_result, estimator_obj):
|
|
1562
|
+
if obj is None:
|
|
1563
|
+
continue
|
|
1564
|
+
for name in direct_names:
|
|
1565
|
+
params.update(_dict_from_params(getattr(obj, name, None)))
|
|
1566
|
+
for nested_name in nested_names:
|
|
1567
|
+
nested = getattr(obj, nested_name, None)
|
|
1568
|
+
if nested is None:
|
|
1569
|
+
continue
|
|
1570
|
+
for name in direct_names:
|
|
1571
|
+
params.update(_dict_from_params(getattr(nested, name, None)))
|
|
1572
|
+
|
|
1573
|
+
return params
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _instantiate_estimator(estimator_constructor, expression: str, kwargs: dict):
|
|
1577
|
+
"""Instantiate a class or local factory with the equation.
|
|
1578
|
+
|
|
1579
|
+
modelestimator_new.with_defaults factories accept either ``org_eq=...`` or
|
|
1580
|
+
the equation as the first positional argument. Custom local factories often
|
|
1581
|
+
choose the positional style, so we support both.
|
|
1582
|
+
"""
|
|
1583
|
+
if not callable(estimator_constructor):
|
|
1584
|
+
raise ModelSpecificationError(
|
|
1585
|
+
f"Estimator {estimator_constructor!r} is not callable. "
|
|
1586
|
+
"Use an estimator class, an estimator factory such as "
|
|
1587
|
+
"Estimate_nls.with_defaults(...), or a local callable returning "
|
|
1588
|
+
"an EstimatorBackend instance."
|
|
1589
|
+
)
|
|
1590
|
+
try:
|
|
1591
|
+
return estimator_constructor(org_eq=expression, **kwargs)
|
|
1592
|
+
except TypeError as first_exc:
|
|
1593
|
+
try:
|
|
1594
|
+
return estimator_constructor(expression, **kwargs)
|
|
1595
|
+
except TypeError:
|
|
1596
|
+
raise first_exc
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def _require_estimator_backend_instance(estimator_obj, estimator_spec):
|
|
1600
|
+
"""Validate the instantiated estimator against the shared backend contract.
|
|
1601
|
+
|
|
1602
|
+
A local estimator specification such as ``Estimate_nls.with_defaults(...)``
|
|
1603
|
+
is a callable factory, not an EstimatorBackend instance. Therefore we first
|
|
1604
|
+
instantiate/call it with the equation, and only then require that the result
|
|
1605
|
+
is an instance of modelestimator_new.EstimatorBackend.
|
|
1606
|
+
"""
|
|
1607
|
+
try:
|
|
1608
|
+
from modelestimator_new import EstimatorBackend
|
|
1609
|
+
except ImportError as exc:
|
|
1610
|
+
raise ModelSpecificationError(
|
|
1611
|
+
"Equations with <estimator=...> require modelestimator_new "
|
|
1612
|
+
"to be importable so returned estimator objects can be checked "
|
|
1613
|
+
"against EstimatorBackend."
|
|
1614
|
+
) from exc
|
|
1615
|
+
|
|
1616
|
+
if not isinstance(estimator_obj, EstimatorBackend):
|
|
1617
|
+
raise ModelSpecificationError(
|
|
1618
|
+
f"Estimator {_estimator_display_name(estimator_spec)!r} returned "
|
|
1619
|
+
f"{type(estimator_obj).__name__}, but it must return an "
|
|
1620
|
+
"instance of modelestimator_new.EstimatorBackend. "
|
|
1621
|
+
"Use Estimate_ols, Estimate_nls_lmfit, Estimate_nls_eviews, "
|
|
1622
|
+
"Estimate_nls.with_defaults(...), or a custom subclass of "
|
|
1623
|
+
"EstimatorBackend."
|
|
1624
|
+
)
|
|
1625
|
+
return estimator_obj
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _estimate_and_bake_expression(
|
|
1629
|
+
expression: str,
|
|
1630
|
+
*,
|
|
1631
|
+
estimator_name,
|
|
1632
|
+
input_df=None,
|
|
1633
|
+
smpl=None,
|
|
1634
|
+
caption: Optional[str] = None,
|
|
1635
|
+
estimator_kwargs: Optional[dict] = None,
|
|
1636
|
+
estimator_classes: Optional[dict] = None,
|
|
1637
|
+
):
|
|
1638
|
+
"""Instantiate an estimator/factory, run it, and return (baked_expression, estimator_obj)."""
|
|
1639
|
+
estimator_constructor = _get_estimator_class(estimator_name, estimator_classes)
|
|
1640
|
+
kwargs = dict(estimator_kwargs or {})
|
|
1641
|
+
|
|
1642
|
+
# Do not pass None defaults into a local with_defaults factory; it may
|
|
1643
|
+
# already carry input_df/smpl/var_description. Explicit constructor kwargs
|
|
1644
|
+
# still override the factory's stored defaults.
|
|
1645
|
+
if input_df is not None and 'input_df' not in kwargs:
|
|
1646
|
+
kwargs['input_df'] = input_df
|
|
1647
|
+
if smpl is not None and 'smpl' not in kwargs:
|
|
1648
|
+
kwargs['smpl'] = smpl
|
|
1649
|
+
if caption is not None and 'caption' not in kwargs:
|
|
1650
|
+
kwargs['caption'] = caption
|
|
1651
|
+
|
|
1652
|
+
estimator_obj = _instantiate_estimator(estimator_constructor, expression, kwargs)
|
|
1653
|
+
estimator_obj = _require_estimator_backend_instance(estimator_obj, estimator_name)
|
|
1654
|
+
fit_result = _maybe_run_estimator_fit(estimator_obj)
|
|
1655
|
+
|
|
1656
|
+
baked = _extract_expression_from_estimator(estimator_obj, fit_result)
|
|
1657
|
+
if not baked:
|
|
1658
|
+
raise ModelSpecificationError(
|
|
1659
|
+
f"Estimator {estimator_obj.__class__.__name__} must expose the baked "
|
|
1660
|
+
f"(coefficient-substituted) equation through 'org_eq_baked' attribute "
|
|
1661
|
+
f"or 'to_expression()/to_equation()/to_frml()' method."
|
|
1662
|
+
)
|
|
1663
|
+
return baked, estimator_obj
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def _markdown_escape_cell(value) -> str:
|
|
1668
|
+
"""Render a value safely inside a simple Markdown table cell."""
|
|
1669
|
+
if value is None:
|
|
1670
|
+
text = ""
|
|
1671
|
+
else:
|
|
1672
|
+
text = str(value)
|
|
1673
|
+
return text.replace("\n", "<br>").replace("|", r"\|")
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def _markdown_format_number(value) -> str:
|
|
1677
|
+
"""Compact numeric formatting for estimation markdown tables."""
|
|
1678
|
+
try:
|
|
1679
|
+
return f"{float(value):.10g}"
|
|
1680
|
+
except Exception:
|
|
1681
|
+
return str(value)
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def _estimator_coefficients_for_markdown(estimator_obj) -> dict:
|
|
1685
|
+
"""Return coefficients from an EstimatorBackend-like object as a dict."""
|
|
1686
|
+
coefs = getattr(estimator_obj, "coef_estimate_dict", None)
|
|
1687
|
+
if hasattr(coefs, "to_dict"):
|
|
1688
|
+
coefs = coefs.to_dict()
|
|
1689
|
+
if isinstance(coefs, dict):
|
|
1690
|
+
return coefs
|
|
1691
|
+
coef_ser = getattr(estimator_obj, "coef_ser", None)
|
|
1692
|
+
if hasattr(coef_ser, "to_dict"):
|
|
1693
|
+
return coef_ser.to_dict()
|
|
1694
|
+
return _extract_params_from_estimator(estimator_obj)
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def _estimation_record_to_markdown(record: dict) -> str:
|
|
1698
|
+
"""Create a compact Markdown report block for one Makemodel estimation."""
|
|
1699
|
+
est = record.get("estimator_object")
|
|
1700
|
+
estimator_name = record.get("estimator") or type(est).__name__
|
|
1701
|
+
requested_smpl = record.get("smpl", "")
|
|
1702
|
+
try:
|
|
1703
|
+
effective_smpl = getattr(est, "estimation_smpl")
|
|
1704
|
+
except Exception:
|
|
1705
|
+
effective_smpl = getattr(est, "_effective_smpl", "")
|
|
1706
|
+
|
|
1707
|
+
original = record.get("original_expression") or getattr(est, "org_eq", "")
|
|
1708
|
+
baked = record.get("baked_expression") or getattr(est, "org_eq_baked", "")
|
|
1709
|
+
|
|
1710
|
+
summary_rows = [
|
|
1711
|
+
("Estimator", estimator_name),
|
|
1712
|
+
("Requested sample", requested_smpl),
|
|
1713
|
+
("Effective sample", effective_smpl),
|
|
1714
|
+
("Original equation", original),
|
|
1715
|
+
("Estimated equation", baked),
|
|
1716
|
+
]
|
|
1717
|
+
|
|
1718
|
+
regression_model = getattr(est, "regression_model", None)
|
|
1719
|
+
for attr in ("success", "message", "chisqr", "redchi", "aic", "bic"):
|
|
1720
|
+
if regression_model is not None and hasattr(regression_model, attr):
|
|
1721
|
+
value = getattr(regression_model, attr)
|
|
1722
|
+
if attr in {"chisqr", "redchi", "aic", "bic"}:
|
|
1723
|
+
value = _markdown_format_number(value)
|
|
1724
|
+
summary_rows.append((attr, value))
|
|
1725
|
+
|
|
1726
|
+
try:
|
|
1727
|
+
if regression_model is not None and hasattr(regression_model, "rsquared"):
|
|
1728
|
+
summary_rows.append(("R-squared", _markdown_format_number(regression_model.rsquared)))
|
|
1729
|
+
if regression_model is not None and hasattr(regression_model, "rsquared_adj"):
|
|
1730
|
+
summary_rows.append(("Adj. R-squared", _markdown_format_number(regression_model.rsquared_adj)))
|
|
1731
|
+
except Exception:
|
|
1732
|
+
pass
|
|
1733
|
+
|
|
1734
|
+
lines = [
|
|
1735
|
+
"",
|
|
1736
|
+
"**Estimation output**",
|
|
1737
|
+
"",
|
|
1738
|
+
"| Item | Value |",
|
|
1739
|
+
"|:--|:--|",
|
|
1740
|
+
]
|
|
1741
|
+
lines.extend(
|
|
1742
|
+
f"| {_markdown_escape_cell(k)} | {_markdown_escape_cell(v)} |"
|
|
1743
|
+
for k, v in summary_rows
|
|
1744
|
+
if v not in (None, "")
|
|
1745
|
+
)
|
|
1746
|
+
|
|
1747
|
+
coefs = _estimator_coefficients_for_markdown(est)
|
|
1748
|
+
if coefs:
|
|
1749
|
+
lines.extend([
|
|
1750
|
+
"",
|
|
1751
|
+
"| Parameter | Estimate |",
|
|
1752
|
+
"|:--|--:|",
|
|
1753
|
+
])
|
|
1754
|
+
for key in sorted(coefs, key=lambda x: (str(x).split("__")[0], int(str(x).split("__")[-1]) if str(x).split("__")[-1].lstrip("-").isdigit() else str(x))):
|
|
1755
|
+
lines.append(
|
|
1756
|
+
f"| {_markdown_escape_cell(key)} | {_markdown_escape_cell(_markdown_format_number(coefs[key]))} |"
|
|
1757
|
+
)
|
|
1758
|
+
|
|
1759
|
+
return "\n".join(lines) + "\n"
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
def _line_has_estimator_tag(line: str) -> bool:
|
|
1763
|
+
"""True when a source line appears to contain an estimator/est flag."""
|
|
1764
|
+
return bool(re.search(r"<[^>]*\b(?:estimator|est)\b[^>]*>", line, flags=re.IGNORECASE))
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
def _markdown_with_estimation_blocks(original_text: str, estimation_records: list[dict]) -> str:
|
|
1768
|
+
"""Insert estimation markdown blocks after estimator-tagged source lines.
|
|
1769
|
+
|
|
1770
|
+
This preserves the original user-facing Markdown as much as possible. For
|
|
1771
|
+
the common notebook syntax, each line beginning with ``>`` and containing
|
|
1772
|
+
``<estimator=...>`` gets the next estimation block inserted immediately
|
|
1773
|
+
after it. If template expansion creates more estimated equations than can
|
|
1774
|
+
be matched to source lines, the remaining blocks are appended at the end.
|
|
1775
|
+
"""
|
|
1776
|
+
records = list(estimation_records or [])
|
|
1777
|
+
if not records:
|
|
1778
|
+
return original_text
|
|
1779
|
+
|
|
1780
|
+
out = []
|
|
1781
|
+
rec_i = 0
|
|
1782
|
+
for line in original_text.splitlines():
|
|
1783
|
+
out.append(line)
|
|
1784
|
+
if rec_i < len(records) and _line_has_estimator_tag(line):
|
|
1785
|
+
out.append(_estimation_record_to_markdown(records[rec_i]).rstrip())
|
|
1786
|
+
rec_i += 1
|
|
1787
|
+
|
|
1788
|
+
if rec_i < len(records):
|
|
1789
|
+
if out and out[-1].strip():
|
|
1790
|
+
out.append("")
|
|
1791
|
+
out.append("## Estimation output")
|
|
1792
|
+
for rec in records[rec_i:]:
|
|
1793
|
+
out.append(_estimation_record_to_markdown(rec).rstrip())
|
|
1794
|
+
|
|
1795
|
+
return "\n".join(out)
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
@dataclass
|
|
1799
|
+
class BaseExplode:
|
|
1800
|
+
"""Common parent for Makemodel and Listmodels."""
|
|
1801
|
+
original_statements : str = field(default="", metadata={"description": "Input expressions"})
|
|
1802
|
+
normal_frml : str = field(default="",init=False, metadata={"description": "Output normalized expressions"})
|
|
1803
|
+
markdown_model : str = field(default="",init=False, metadata={"description": "As markdown"})
|
|
1804
|
+
funks : List[Any] = field(default_factory=list, metadata={"description": "List of user specified functions to be used in model"})
|
|
1805
|
+
var_description: dict = field(default_factory=dict, metadata={"description": "Variable descriptions"})
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
@property
|
|
1809
|
+
def show(self):
|
|
1810
|
+
print (self.normal_frml.strip())
|
|
1811
|
+
|
|
1812
|
+
@property
|
|
1813
|
+
def draw(self):
|
|
1814
|
+
self.mmodel.drawmodel()
|
|
1815
|
+
|
|
1816
|
+
@cached_property
|
|
1817
|
+
def mmodel(self):
|
|
1818
|
+
from modelclass import model
|
|
1819
|
+
return model(self.normal_frml,funks=self.funks,var_description=self.var_description)
|
|
1820
|
+
|
|
1821
|
+
def __str__(self):
|
|
1822
|
+
return self.normal_frml.strip()
|
|
1823
|
+
|
|
1824
|
+
@property
|
|
1825
|
+
def render(self):
|
|
1826
|
+
# display(Markdown(self.markdown_model))
|
|
1827
|
+
|
|
1828
|
+
rendered_md = render_markdown_model(self.markdown_model)
|
|
1829
|
+
display_mixed_markdown(rendered_md)
|
|
1830
|
+
@property
|
|
1831
|
+
def render_est(self):
|
|
1832
|
+
# display(Markdown(self.markdown_model))
|
|
1833
|
+
|
|
1834
|
+
rendered_md = render_markdown_model(self.markdown_model_with_estimation)
|
|
1835
|
+
display_mixed_markdown(rendered_md)
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
def _field_is_showable(self, f) -> bool:
|
|
1839
|
+
"""Dataclass fields are showable unless metadata says otherwise."""
|
|
1840
|
+
return f.metadata.get("showable", True) is not False
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
def _explicit_show_properties(self):
|
|
1844
|
+
"""Return explicit @property/@cached_property names starting with 'show'."""
|
|
1845
|
+
|
|
1846
|
+
result = []
|
|
1847
|
+
|
|
1848
|
+
for name, obj in inspect.getmembers_static(type(self)):
|
|
1849
|
+
if not name.startswith("show"):
|
|
1850
|
+
continue
|
|
1851
|
+
|
|
1852
|
+
if isinstance(obj, property):
|
|
1853
|
+
desc_lines = (obj.__doc__ or "").strip().splitlines()
|
|
1854
|
+
desc = desc_lines[0] if desc_lines else ""
|
|
1855
|
+
result.append((name, desc))
|
|
1856
|
+
|
|
1857
|
+
elif isinstance(obj, functools.cached_property):
|
|
1858
|
+
desc_lines = (getattr(obj.func, "__doc__", "") or "").strip().splitlines()
|
|
1859
|
+
desc = desc_lines[0] if desc_lines else ""
|
|
1860
|
+
result.append((name, desc))
|
|
1861
|
+
|
|
1862
|
+
return result
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def __getattr__(self, attr):
|
|
1866
|
+
if attr.startswith("show"):
|
|
1867
|
+
prop = attr[4:].lower()
|
|
1868
|
+
field_defs = fields(self)
|
|
1869
|
+
field_by_name = {f.name: f for f in field_defs}
|
|
1870
|
+
|
|
1871
|
+
if prop in field_by_name and self._field_is_showable(field_by_name[prop]):
|
|
1872
|
+
value = getattr(self, prop)
|
|
1873
|
+
|
|
1874
|
+
if isinstance(value, list) and all(isinstance(v, nz.Normalized_frml) for v in value):
|
|
1875
|
+
print(f"\n--- {prop.upper()} ({len(value)} items) ---")
|
|
1876
|
+
for i, frml in enumerate(value, 1):
|
|
1877
|
+
print(f"\n[{i}]")
|
|
1878
|
+
print(frml)
|
|
1879
|
+
else:
|
|
1880
|
+
print(f"{prop.capitalize()}: \n{value}")
|
|
1881
|
+
|
|
1882
|
+
return None
|
|
1883
|
+
|
|
1884
|
+
import inspect
|
|
1885
|
+
|
|
1886
|
+
varname = self.__class__.__name__.lower()
|
|
1887
|
+
frame = inspect.currentframe()
|
|
1888
|
+
try:
|
|
1889
|
+
caller = frame.f_back if frame else None
|
|
1890
|
+
if caller:
|
|
1891
|
+
varname = next(
|
|
1892
|
+
(k for k, v in caller.f_locals.items() if v is self),
|
|
1893
|
+
varname,
|
|
1894
|
+
)
|
|
1895
|
+
finally:
|
|
1896
|
+
del frame
|
|
1897
|
+
try:
|
|
1898
|
+
del caller
|
|
1899
|
+
except NameError:
|
|
1900
|
+
pass
|
|
1901
|
+
|
|
1902
|
+
showable_fields = [
|
|
1903
|
+
f for f in field_defs
|
|
1904
|
+
if self._field_is_showable(f)
|
|
1905
|
+
]
|
|
1906
|
+
|
|
1907
|
+
field_items = [
|
|
1908
|
+
(
|
|
1909
|
+
f"show{f.name}",
|
|
1910
|
+
f.metadata.get("description", "")
|
|
1911
|
+
)
|
|
1912
|
+
for f in showable_fields
|
|
1913
|
+
]
|
|
1914
|
+
|
|
1915
|
+
property_items = self._explicit_show_properties()
|
|
1916
|
+
|
|
1917
|
+
items_by_name = {}
|
|
1918
|
+
|
|
1919
|
+
for name, desc in field_items:
|
|
1920
|
+
items_by_name[name] = desc
|
|
1921
|
+
|
|
1922
|
+
for name, desc in property_items:
|
|
1923
|
+
items_by_name[name] = desc
|
|
1924
|
+
|
|
1925
|
+
show_items = sorted(items_by_name.items())
|
|
1926
|
+
|
|
1927
|
+
left_parts = [f"{varname}.{name}" for name, _ in show_items]
|
|
1928
|
+
maxlen = max((len(lp) for lp in left_parts), default=0)
|
|
1929
|
+
|
|
1930
|
+
options = "\n".join(
|
|
1931
|
+
f"{lp.ljust(maxlen)} # {desc}" if desc else lp
|
|
1932
|
+
for lp, (_, desc) in zip(left_parts, show_items)
|
|
1933
|
+
)
|
|
1934
|
+
|
|
1935
|
+
print(f"No such property '{prop}'. Try one of:\n{options}")
|
|
1936
|
+
return None
|
|
1937
|
+
|
|
1938
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{attr}'")
|
|
1939
|
+
|
|
1940
|
+
@dataclass
|
|
1941
|
+
class Makemodel(BaseExplode):
|
|
1942
|
+
# original_statements : str = field(default="", metadata={"description": "Input expressions"})
|
|
1943
|
+
# normal_frml : str = field(default="", metadata={"description": "Output normalized expressions"})
|
|
1944
|
+
|
|
1945
|
+
normal_main : str = field(init=False, metadata={"description": "Normalized frmls"})
|
|
1946
|
+
normal_fit : str = field(init=False, metadata={"description": "Normalized frmls for fitted values"})
|
|
1947
|
+
normal_calc_add : str = field(init=False, metadata={"description": "Normalized frmls to calculate add factors"})
|
|
1948
|
+
replacements : Union[tuple[str, str], list[tuple[str, str]]] = field(default_factory=list, metadata={"description": "list of string tupels with string replacements"})
|
|
1949
|
+
list_defs : str = field(default="", metadata={"description": "Lists definitions"})
|
|
1950
|
+
modelname : str = field(default="", metadata={"description": "A optional name for this (sub) model"})
|
|
1951
|
+
|
|
1952
|
+
clean_frml_statements : str = field(init=False, metadata={"description": "With frml and nice lists"})
|
|
1953
|
+
post_doable : str = field(init=False, metadata={"description": "Expanded after doable"})
|
|
1954
|
+
post_do : str = field(init=False, metadata={"description": "Frmls after do expansion"})
|
|
1955
|
+
post_sum : str = field(init=False, metadata={"description": "Frmls after expanding sums"})
|
|
1956
|
+
expanded_frml : str = field(init=False, metadata={"description": "frmls after expanding "})
|
|
1957
|
+
normal_expressions : List[Any] = field(init=False, metadata={"description": "List of normal expressions"})
|
|
1958
|
+
list_specification : str = field(init=False, metadata={"description": "All list specifications in string "})
|
|
1959
|
+
modellist : str = field(init=False, metadata={"description": "The lists defined in string as a dictionary"})
|
|
1960
|
+
type_input : str = field(default= 'markdown' ,metadata={"description": "Originaal as type modelflow or markdown"})
|
|
1961
|
+
# Optional estimation. An equation is estimated only if its FRML-name
|
|
1962
|
+
# contains <estimator=...>/<est=...> or <estimator>/<est>. If the flag has no value,
|
|
1963
|
+
# the global estimator below is used. Untagged equations remain identities.
|
|
1964
|
+
input_df : Optional[Any] = field(default=None, metadata={"description": "DataFrame used when tagged equations are estimated"})
|
|
1965
|
+
estimator : Optional[Any] = field(default=None, metadata={"description": "Default estimator for tagged equations: method name or callable factory"})
|
|
1966
|
+
smpl : Any = field(default=None, metadata={"description": "Default estimation sample; equation <smpl=start end> overrides it"})
|
|
1967
|
+
estimator_kwargs : dict = field(default_factory=dict, metadata={"description": "Shared kwargs passed to estimator constructors"})
|
|
1968
|
+
estimator_classes : Optional[dict] = field(default=None, metadata={"description": "Optional method-name to estimator-class mapping"})
|
|
1969
|
+
estimator_namespace : Optional[dict] = field(default=None, metadata={"description": "Optional namespace for resolving <estimator=name>; defaults to caller locals/globals"})
|
|
1970
|
+
estimation_records : List[Any] = field(init=False, metadata={"description": "Information about equations estimated during construction"})
|
|
1971
|
+
normal_input_expressions : List[str] = field(init=False, metadata={"description": "Expressions sent to modelnormalize, after optional estimation"})
|
|
1972
|
+
normal_output_frmlnames : List[str] = field(init=False, metadata={"description": "FRML names emitted; estimation flags are preserved"})
|
|
1973
|
+
|
|
1974
|
+
def __post_init__(self):
|
|
1975
|
+
'''prepares a model from a model template.
|
|
1976
|
+
|
|
1977
|
+
Returns a expanded model which is ready to solve.
|
|
1978
|
+
|
|
1979
|
+
If an expanded equation has <estimator=...>/<est=...> (or <estimator>/<est> plus a
|
|
1980
|
+
global self.estimator), the estimator is run here and the estimated
|
|
1981
|
+
coefficients are baked into the expression before modelnormalize sees it.
|
|
1982
|
+
Untagged equations are treated exactly as before.
|
|
1983
|
+
'''
|
|
1984
|
+
|
|
1985
|
+
if self.type_input == 'markdown':
|
|
1986
|
+
extracted_frml = extract_model_from_markdown(self.list_defs+self.original_statements)
|
|
1987
|
+
self.markdown_model = (mfmod_list_to_codeblock(self.original_statements))
|
|
1988
|
+
|
|
1989
|
+
else:
|
|
1990
|
+
extracted_frml = self.list_defs+self.original_statements
|
|
1991
|
+
self.markdown_model = ''
|
|
1992
|
+
|
|
1993
|
+
self.clean_frml_statements = clean_expressions(
|
|
1994
|
+
apply_replacements(extracted_frml,self.replacements)
|
|
1995
|
+
).strip().upper()
|
|
1996
|
+
# debug_var(extract_model_from_markdown(self.original_statements))
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
self.post_doable = doable_unroll(self.clean_frml_statements ,self.funks)
|
|
2000
|
+
# assert 1==2
|
|
2001
|
+
self.post_do = dounloop(self.post_doable) # we unroll the do loops
|
|
2002
|
+
|
|
2003
|
+
self.modellist = list_extract(self.post_do)
|
|
2004
|
+
self.post_sum = sumunroll(self.post_do,listin=self.modellist) # then we unroll the sum
|
|
2005
|
+
|
|
2006
|
+
self.expanded_frml = self.post_sum
|
|
2007
|
+
self.expanded_frml_list = find_frml(self.expanded_frml)
|
|
2008
|
+
self.expanded_frml_split = [split_frml_reqopts(frml) for frml in self.expanded_frml_list]
|
|
2009
|
+
|
|
2010
|
+
self.estimation_records = []
|
|
2011
|
+
self.normal_input_expressions = []
|
|
2012
|
+
self.normal_output_frmlnames = []
|
|
2013
|
+
|
|
2014
|
+
# Cache the estimator flag per equation so we evaluate kw_frml_name
|
|
2015
|
+
# exactly once per equation instead of three times.
|
|
2016
|
+
estimator_flags_per_equation = [
|
|
2017
|
+
_frml_option_value(parts.frmlname, 'ESTIMATOR', default=None)
|
|
2018
|
+
for parts in self.expanded_frml_split
|
|
2019
|
+
]
|
|
2020
|
+
has_estimated_equations = any(flag is not None for flag in estimator_flags_per_equation)
|
|
2021
|
+
|
|
2022
|
+
# Resolve the estimator namespace lazily: only walk frames when we
|
|
2023
|
+
# actually need to look up estimator names AND the caller did not
|
|
2024
|
+
# already supply a namespace. Walking f_globals/f_locals on every
|
|
2025
|
+
# Makemodel(...) call is expensive when no estimation is involved
|
|
2026
|
+
# (the magic always passes user_ns explicitly, so the walk would be
|
|
2027
|
+
# pure overhead in that path).
|
|
2028
|
+
if has_estimated_equations and self.estimator_namespace is None and not self.estimator_classes:
|
|
2029
|
+
namespace_for_resolution = _caller_estimator_namespace()
|
|
2030
|
+
else:
|
|
2031
|
+
namespace_for_resolution = self.estimator_namespace or {}
|
|
2032
|
+
|
|
2033
|
+
# Allow notebook-local factories such as:
|
|
2034
|
+
# ls = Estimate_nls.with_defaults(...)
|
|
2035
|
+
# Makemodel('><estimator=ls> ...') or Makemodel('><est=ls> ...')
|
|
2036
|
+
# Explicit estimator_classes still wins over caller-scope names.
|
|
2037
|
+
self.estimator_classes = _merge_estimator_namespaces(
|
|
2038
|
+
explicit=self.estimator_classes,
|
|
2039
|
+
namespace=namespace_for_resolution,
|
|
2040
|
+
)
|
|
2041
|
+
|
|
2042
|
+
if has_estimated_equations and self.input_df is None and self.estimator is None:
|
|
2043
|
+
# String estimators such as <estimator=nls_lmfit> or <est=nls_lmfit> need input_df
|
|
2044
|
+
# here. A callable/factory estimator may already have input_df
|
|
2045
|
+
# captured through Estimate_nls.with_defaults(...), so that case
|
|
2046
|
+
# is allowed when self.estimator is supplied.
|
|
2047
|
+
explicit_estimators = [flag for flag in estimator_flags_per_equation if flag is not None]
|
|
2048
|
+
if not any(
|
|
2049
|
+
isinstance(flag, str)
|
|
2050
|
+
and self.estimator_classes
|
|
2051
|
+
and str(flag).strip().lower() in {str(k).strip().lower() for k in self.estimator_classes}
|
|
2052
|
+
for flag in explicit_estimators
|
|
2053
|
+
):
|
|
2054
|
+
raise ModelSpecificationError(
|
|
2055
|
+
"input_df is required when any equation has an <estimator=...> or <est=...> flag, "
|
|
2056
|
+
"unless you pass a callable/factory estimator that already carries input_df"
|
|
2057
|
+
)
|
|
2058
|
+
|
|
2059
|
+
# def normal(ind_o,the_endo='',add_add_factor=True,do_preprocess = True,add_suffix = '_A',endo_lhs = True, =False,make_fitted=False,eviews=''):
|
|
2060
|
+
self.normal = []
|
|
2061
|
+
for equation_index, parts in enumerate(self.expanded_frml_split):
|
|
2062
|
+
expression_for_normal = self._expression_after_optional_estimation(
|
|
2063
|
+
parts,
|
|
2064
|
+
equation_index=equation_index,
|
|
2065
|
+
estimator_flag=estimator_flags_per_equation[equation_index],
|
|
2066
|
+
)
|
|
2067
|
+
if kw_frml_name(parts.frmlname, 'DROP'):
|
|
2068
|
+
continue
|
|
2069
|
+
|
|
2070
|
+
self.normal_input_expressions.append(expression_for_normal)
|
|
2071
|
+
self.normal_output_frmlnames.append(parts.frmlname)
|
|
2072
|
+
|
|
2073
|
+
self.normal.append((
|
|
2074
|
+
parts,
|
|
2075
|
+
nz.normal(expression_for_normal,
|
|
2076
|
+
add_add_factor= kw_frml_name(parts.frmlname, 'ADD') or kw_frml_name(parts.frmlname, 'STOC'),
|
|
2077
|
+
add_suffix = kw_frml_name(parts.frmlname, 'ADD_SUFFIX','_A'),
|
|
2078
|
+
make_fixable = kw_frml_name(parts.frmlname, 'EXO')or kw_frml_name(parts.frmlname, 'STOC'),
|
|
2079
|
+
make_fitted = kw_frml_name(parts.frmlname, 'FIT'),
|
|
2080
|
+
the_endo = kw_frml_name(parts.frmlname, 'ENDO'),
|
|
2081
|
+
endo_lhs = False if 'FALSE' == kw_frml_name(parts.frmlname, 'ENDO_LHS',default='1') else True,
|
|
2082
|
+
implicit = kw_frml_name(parts.frmlname, 'IMPLICIT')
|
|
2083
|
+
)
|
|
2084
|
+
))
|
|
2085
|
+
|
|
2086
|
+
self.normal_expressions = [n for p,n in self.normal ]
|
|
2087
|
+
|
|
2088
|
+
# udrullet = lagarray_unroll(udrullet,funks=funks )
|
|
2089
|
+
# udrullet = creatematrix(udrullet,listin=modellist)
|
|
2090
|
+
# udrullet = createarray(udrullet,listin=modellist)
|
|
2091
|
+
# udrullet = argunroll(udrullet,listin=modellist)
|
|
2092
|
+
self.normal_main = '\n'.join([f'FRML {frmlname} {normal.normalized} $'
|
|
2093
|
+
for frmlname, (parts, normal)
|
|
2094
|
+
in zip(self.normal_output_frmlnames, self.normal)
|
|
2095
|
+
])
|
|
2096
|
+
self.normal_fit = '\n'.join([f'FRML <FIT> {normal.fitted } $ '
|
|
2097
|
+
for parts, normal in self.normal if len(normal.fitted)
|
|
2098
|
+
])
|
|
2099
|
+
self.normal_calc_add = '\n'.join([f'FRML <CALC_ADD_FACTOR> {normal.calc_add_factor } $ '
|
|
2100
|
+
for parts, normal in self.normal if len(normal.calc_add_factor)
|
|
2101
|
+
])
|
|
2102
|
+
|
|
2103
|
+
self.normal_frml = '\n'.join( [self.normal_main,self.normal_fit,self.normal_calc_add])
|
|
2104
|
+
|
|
2105
|
+
# Merge var_description from estimated equations; user-supplied takes precedence.
|
|
2106
|
+
estimated_var_desc = {}
|
|
2107
|
+
for record in self.estimation_records:
|
|
2108
|
+
obj = record.get('estimator_object')
|
|
2109
|
+
if obj is not None and hasattr(obj, 'var_description'):
|
|
2110
|
+
estimated_var_desc |= obj.var_description
|
|
2111
|
+
self.var_description = estimated_var_desc | self.var_description
|
|
2112
|
+
|
|
2113
|
+
check_syntax_model(self.normal_frml)
|
|
2114
|
+
|
|
2115
|
+
self.list_specification = self.get_lists()
|
|
2116
|
+
return
|
|
2117
|
+
|
|
2118
|
+
def _expression_after_optional_estimation(self, parts, *, equation_index: Optional[int] = None,
|
|
2119
|
+
estimator_flag: Any = ...) -> str:
|
|
2120
|
+
"""Return parts.expression, or an estimated/baked version when tagged.
|
|
2121
|
+
|
|
2122
|
+
``estimator_flag`` is the cached value from ``__post_init__``'s single
|
|
2123
|
+
pass over ``expanded_frml_split``. It may be passed as ``...`` (the
|
|
2124
|
+
default sentinel) for backward compatibility, in which case the flag
|
|
2125
|
+
is resolved here. Direct callers from the new code path should always
|
|
2126
|
+
pass the cached value to avoid redundant ``kw_frml_name`` work.
|
|
2127
|
+
"""
|
|
2128
|
+
if estimator_flag is ...:
|
|
2129
|
+
estimator_flag = _frml_option_value(parts.frmlname, 'ESTIMATOR', default=None)
|
|
2130
|
+
if estimator_flag is None:
|
|
2131
|
+
return parts.expression
|
|
2132
|
+
|
|
2133
|
+
if isinstance(estimator_flag, str) and estimator_flag.strip().upper() in {'0', 'FALSE', 'NO', 'NONE'}:
|
|
2134
|
+
return parts.expression
|
|
2135
|
+
|
|
2136
|
+
estimator_name = self.estimator if estimator_flag is True or estimator_flag == '' else estimator_flag
|
|
2137
|
+
if not estimator_name:
|
|
2138
|
+
raise ModelSpecificationError(
|
|
2139
|
+
f"{parts.expression!r} has <estimator> but no estimator method was supplied"
|
|
2140
|
+
)
|
|
2141
|
+
|
|
2142
|
+
local_smpl = _frml_option_value(parts.frmlname, 'SMPL', default=None)
|
|
2143
|
+
local_caption = _frml_option_value(parts.frmlname, 'CAPTION', default=None)
|
|
2144
|
+
local_constraints = _frml_option_value(parts.frmlname, 'CONSTRAINTS', default=None)
|
|
2145
|
+
|
|
2146
|
+
# Equation-local smpl must override the estimator factory default.
|
|
2147
|
+
# If Makemodel.input_df is None because the dataframe is captured in a
|
|
2148
|
+
# local factory such as ``ls = Estimate_nls.with_defaults(input_df=npl)``,
|
|
2149
|
+
# read the factory's _estimator_defaults metadata before parsing
|
|
2150
|
+
# ``smpl=2013 2018``. This avoids constructing a dummy estimator,
|
|
2151
|
+
# which would run a real fit in EstimatorBackend.__post_init__.
|
|
2152
|
+
df_for_smpl = self.input_df
|
|
2153
|
+
if df_for_smpl is None:
|
|
2154
|
+
df_for_smpl = _infer_input_df_from_estimator_spec(
|
|
2155
|
+
estimator_name,
|
|
2156
|
+
self.estimator_classes,
|
|
2157
|
+
)
|
|
2158
|
+
|
|
2159
|
+
smpl = _parse_smpl(
|
|
2160
|
+
local_smpl if local_smpl is not None else self.smpl,
|
|
2161
|
+
df=df_for_smpl,
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
# FRML-attribute constraints are appended as a synthetic ST. clause so
|
|
2165
|
+
# the estimator's single parsing path handles both inline and attribute
|
|
2166
|
+
# forms uniformly.
|
|
2167
|
+
expression_to_estimate = parts.expression
|
|
2168
|
+
if local_constraints:
|
|
2169
|
+
expression_to_estimate = (
|
|
2170
|
+
f"{expression_to_estimate.rstrip(' $')} ST. {local_constraints}"
|
|
2171
|
+
)
|
|
2172
|
+
|
|
2173
|
+
baked_expression, estimator_obj = _estimate_and_bake_expression(
|
|
2174
|
+
expression_to_estimate,
|
|
2175
|
+
estimator_name=estimator_name,
|
|
2176
|
+
input_df=self.input_df,
|
|
2177
|
+
smpl=smpl,
|
|
2178
|
+
caption=local_caption,
|
|
2179
|
+
estimator_kwargs=self.estimator_kwargs,
|
|
2180
|
+
estimator_classes=self.estimator_classes,
|
|
2181
|
+
)
|
|
2182
|
+
self.estimation_records.append({
|
|
2183
|
+
'equation_index': equation_index,
|
|
2184
|
+
'frmlname': parts.frmlname,
|
|
2185
|
+
'output_frmlname': parts.frmlname,
|
|
2186
|
+
'estimator': _estimator_display_name(estimator_name),
|
|
2187
|
+
'smpl': smpl,
|
|
2188
|
+
'original_expression': parts.expression,
|
|
2189
|
+
'baked_expression': baked_expression,
|
|
2190
|
+
'estimator_object': estimator_obj,
|
|
2191
|
+
})
|
|
2192
|
+
return baked_expression
|
|
2193
|
+
|
|
2194
|
+
def estimation_report(
|
|
2195
|
+
self,
|
|
2196
|
+
path: str = "html",
|
|
2197
|
+
filename: str = "makemodel_estimation_report.html",
|
|
2198
|
+
plot_format: str = "svg",
|
|
2199
|
+
title: str = "Makemodel Estimation Summary",
|
|
2200
|
+
open_file: bool = False,
|
|
2201
|
+
report_all: bool = False,
|
|
2202
|
+
) -> None:
|
|
2203
|
+
"""Export an HTML report for estimations embedded in this Makemodel.
|
|
2204
|
+
|
|
2205
|
+
The report reuses :func:`modelestimator_new.export_estimation_reports_to_html`.
|
|
2206
|
+
By default, only equations tagged with ``<estimator=...>`` are included.
|
|
2207
|
+
With ``report_all=True``, unestimated identity equations are included as
|
|
2208
|
+
minimal panels alongside the full estimation reports.
|
|
2209
|
+
"""
|
|
2210
|
+
return export_makemodel_estimation_reports_to_html(
|
|
2211
|
+
self,
|
|
2212
|
+
path=path,
|
|
2213
|
+
filename=filename,
|
|
2214
|
+
plot_format=plot_format,
|
|
2215
|
+
title=title,
|
|
2216
|
+
open_file=open_file,
|
|
2217
|
+
report_all=report_all,
|
|
2218
|
+
)
|
|
2219
|
+
|
|
2220
|
+
def report(
|
|
2221
|
+
self,
|
|
2222
|
+
title: str = "",
|
|
2223
|
+
plot_format: str = "svg",
|
|
2224
|
+
report_all: bool = False,
|
|
2225
|
+
) -> "MakeModelReport":
|
|
2226
|
+
"""Return a :class:`~modelhtml.MakeModelReport` for this model.
|
|
2227
|
+
|
|
2228
|
+
The report renders the full model documentation (prose + equations)
|
|
2229
|
+
with a collapsible TOC and inline collapsible estimation panels.
|
|
2230
|
+
|
|
2231
|
+
Parameters
|
|
2232
|
+
----------
|
|
2233
|
+
title : str, optional
|
|
2234
|
+
Report title. Defaults to ``modelname`` or ``"Model Report"``.
|
|
2235
|
+
plot_format : {"svg", "png"}, default ``"svg"``
|
|
2236
|
+
Embedded plot format.
|
|
2237
|
+
report_all : bool, default ``False``
|
|
2238
|
+
Include identity equations as minimal panels.
|
|
2239
|
+
|
|
2240
|
+
Examples
|
|
2241
|
+
--------
|
|
2242
|
+
>>> mm.report().save() # → html/model-report_report.html
|
|
2243
|
+
>>> mm.report(title="My Model").save(open_file=True)
|
|
2244
|
+
"""
|
|
2245
|
+
return MakeModelReport(self, title=title, plot_format=plot_format, report_all=report_all)
|
|
2246
|
+
|
|
2247
|
+
@property
|
|
2248
|
+
def markdown_with_estimation(self) -> str:
|
|
2249
|
+
"""Original Markdown input with compact estimation tables inserted.
|
|
2250
|
+
|
|
2251
|
+
Each source equation line containing an ``<estimator=...>`` or ``<est=...>`` flag gets
|
|
2252
|
+
a Markdown summary table and coefficient table inserted immediately
|
|
2253
|
+
after it. The underlying model equations are not changed; this is only
|
|
2254
|
+
a documentation/reporting string.
|
|
2255
|
+
"""
|
|
2256
|
+
return _markdown_with_estimation_blocks(
|
|
2257
|
+
self.original_statements,
|
|
2258
|
+
self.estimation_records,
|
|
2259
|
+
)
|
|
2260
|
+
|
|
2261
|
+
@property
|
|
2262
|
+
def markdown_model_with_estimation(self) -> str:
|
|
2263
|
+
"""Alias for :attr:`markdown_with_estimation`."""
|
|
2264
|
+
return self.markdown_with_estimation
|
|
2265
|
+
|
|
2266
|
+
@property
|
|
2267
|
+
def clean_frml(self) -> str:
|
|
2268
|
+
"""Normalized equations with add-factors, exogenization, and fitted-value flags stripped."""
|
|
2269
|
+
parts_list = []
|
|
2270
|
+
for frmlname, expr, (parts, _) in zip(
|
|
2271
|
+
self.normal_output_frmlnames,
|
|
2272
|
+
self.normal_input_expressions,
|
|
2273
|
+
self.normal,
|
|
2274
|
+
):
|
|
2275
|
+
clean_norm = nz.normal(
|
|
2276
|
+
expr,
|
|
2277
|
+
add_add_factor=False,
|
|
2278
|
+
make_fixable=False,
|
|
2279
|
+
make_fitted=False,
|
|
2280
|
+
the_endo=kw_frml_name(parts.frmlname, 'ENDO'),
|
|
2281
|
+
endo_lhs=False if 'FALSE' == kw_frml_name(parts.frmlname, 'ENDO_LHS', default='1') else True,
|
|
2282
|
+
implicit=kw_frml_name(parts.frmlname, 'IMPLICIT'),
|
|
2283
|
+
)
|
|
2284
|
+
clean_name = _strip_frml_options(frmlname, {'ADD', 'ADD_SUFFIX', 'EXO', 'FIT', 'STOC'})
|
|
2285
|
+
parts_list.append(f'FRML {clean_name} {clean_norm.normalized} $')
|
|
2286
|
+
return '\n'.join(parts_list)
|
|
2287
|
+
|
|
2288
|
+
@property
|
|
2289
|
+
def clean_model(self):
|
|
2290
|
+
"""A ModelFlow model with only the core equations (no fitted, add-factor, or exogenization equations)."""
|
|
2291
|
+
from modelclass import model
|
|
2292
|
+
return model(self.clean_frml, funks=self.funks, var_description=self.var_description)
|
|
2293
|
+
|
|
2294
|
+
@property
|
|
2295
|
+
def clean_model_draw(self):
|
|
2296
|
+
"""Draw the dependency graph of the clean model."""
|
|
2297
|
+
self.clean_model.drawmodel()
|
|
2298
|
+
|
|
2299
|
+
@property
|
|
2300
|
+
def add_model(self):
|
|
2301
|
+
"""A ModelFlow model containing only the add-factor calculations."""
|
|
2302
|
+
from modelclass import model
|
|
2303
|
+
return model(self.normal_calc_add.replace("<CALC_ADD_FACTOR>", "<INIT_ADD>"))
|
|
2304
|
+
|
|
2305
|
+
def init_addfactors(
|
|
2306
|
+
self,
|
|
2307
|
+
df,
|
|
2308
|
+
start: Union[str, int] = "",
|
|
2309
|
+
end: Union[str, int] = "",
|
|
2310
|
+
show: bool = False,
|
|
2311
|
+
check: bool = False,
|
|
2312
|
+
silent: bool = True,
|
|
2313
|
+
multiplier: float = 1.0,
|
|
2314
|
+
):
|
|
2315
|
+
"""Compute add factors and apply them so the model reproduces ``df``.
|
|
2316
|
+
|
|
2317
|
+
Parameters
|
|
2318
|
+
----------
|
|
2319
|
+
df : pandas.DataFrame
|
|
2320
|
+
Historical data to align with.
|
|
2321
|
+
start, end : str | int, optional
|
|
2322
|
+
Alignment window (defaults to the full range).
|
|
2323
|
+
show : bool, default False
|
|
2324
|
+
If True, print the calculated add factors.
|
|
2325
|
+
check : bool, default False
|
|
2326
|
+
Re-simulate with aligned data and print the residual.
|
|
2327
|
+
silent : bool, default True
|
|
2328
|
+
Silence ModelFlow runtime output.
|
|
2329
|
+
multiplier : float, default 1.0
|
|
2330
|
+
Scale factor applied to the residual check printout.
|
|
2331
|
+
|
|
2332
|
+
Returns
|
|
2333
|
+
-------
|
|
2334
|
+
pandas.DataFrame
|
|
2335
|
+
A copy of ``df`` with add-factor columns filled in.
|
|
2336
|
+
"""
|
|
2337
|
+
am = self.add_model
|
|
2338
|
+
aligned = am(df, start, end, silent=silent)
|
|
2339
|
+
if show:
|
|
2340
|
+
print("\n\nAdd factors to align historic values and model results")
|
|
2341
|
+
print(am["*_A"].df)
|
|
2342
|
+
if check:
|
|
2343
|
+
full = self.mmodel
|
|
2344
|
+
_ = full(aligned, start, end, silent=silent)
|
|
2345
|
+
print("\n\nDifference between historic values and model results")
|
|
2346
|
+
if multiplier != 1.0:
|
|
2347
|
+
print(f"Multiplied by {multiplier}")
|
|
2348
|
+
full.basedf = df
|
|
2349
|
+
display(full["#ENDO"].dif.df * multiplier)
|
|
2350
|
+
return aligned
|
|
2351
|
+
|
|
2352
|
+
def get_lists (self) -> str:
|
|
2353
|
+
"""
|
|
2354
|
+
Extract all LIST statements (ending with $) from clean_frml_statements.
|
|
2355
|
+
Returns them as a single string, separated by newlines.
|
|
2356
|
+
"""
|
|
2357
|
+
if not getattr(self, "clean_frml_statements", ""):
|
|
2358
|
+
return ""
|
|
2359
|
+
|
|
2360
|
+
# split by '$' and strip spaces/newlines
|
|
2361
|
+
statements = [stmt.strip() + " $"
|
|
2362
|
+
for stmt in self.clean_frml_statements.split("$")
|
|
2363
|
+
if stmt.strip()]
|
|
2364
|
+
|
|
2365
|
+
# filter for LIST statements
|
|
2366
|
+
list_statements = [stmt for stmt in statements if stmt.upper().startswith("LIST ")]
|
|
2367
|
+
return "\n".join(list_statements)
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
@property
|
|
2373
|
+
def showlists(self):
|
|
2374
|
+
print ( pformat(self.modellist, width=100, compact=False))
|
|
2375
|
+
|
|
2376
|
+
@property
|
|
2377
|
+
def showmodellist(self):
|
|
2378
|
+
print ( pformat(self.modellist, width=100, compact=False))
|
|
2379
|
+
|
|
2380
|
+
|
|
2381
|
+
def pdf(self,**kwargs):
|
|
2382
|
+
pre= r'''
|
|
2383
|
+
{\setlength{\parskip}{1em}
|
|
2384
|
+
\setlength{\parindent}{0pt}
|
|
2385
|
+
'''
|
|
2386
|
+
|
|
2387
|
+
return LatexRepo(fr'{pre} {self.original_statements} }}').pdf(**kwargs)
|
|
2388
|
+
|
|
2389
|
+
|
|
2390
|
+
def __add__ (self, other):
|
|
2391
|
+
if isinstance(other, Makemodel):
|
|
2392
|
+
return Listmodels(makemodels=[self, other])
|
|
2393
|
+
elif isinstance(other, Listmodels):
|
|
2394
|
+
return Listmodels(makemodels= [self] + other.makemodels)
|
|
2395
|
+
else:
|
|
2396
|
+
return NotImplemented
|
|
2397
|
+
|
|
2398
|
+
def __radd__(self, other):
|
|
2399
|
+
# handle reversed order
|
|
2400
|
+
if other == 0:
|
|
2401
|
+
return self
|
|
2402
|
+
if isinstance(other, Makemodel):
|
|
2403
|
+
return Listmodels(makemodels=[other, self])
|
|
2404
|
+
elif isinstance(other, Listmodels):
|
|
2405
|
+
return Listmodels(makemodels=other.makemodels + [self])
|
|
2406
|
+
else:
|
|
2407
|
+
return NotImplemented
|
|
2408
|
+
|
|
2409
|
+
|
|
2410
|
+
def __repr__(self) -> str:
|
|
2411
|
+
def fmt(value: Any) -> str:
|
|
2412
|
+
# Show multiline strings as real blocks (no quotes)
|
|
2413
|
+
if isinstance(value, str) and '\n' in value:
|
|
2414
|
+
return "\n" + textwrap.indent(value.rstrip(), " ")
|
|
2415
|
+
# Pretty containers
|
|
2416
|
+
if isinstance(value, (list, tuple, set, dict)):
|
|
2417
|
+
return pformat(value, width=100, compact=False)
|
|
2418
|
+
# defaultdict / custom objects get pformat fallback via repr-str mix:
|
|
2419
|
+
try:
|
|
2420
|
+
from collections import defaultdict
|
|
2421
|
+
if isinstance(value, defaultdict):
|
|
2422
|
+
return pformat(value, width=100, compact=False)
|
|
2423
|
+
except Exception:
|
|
2424
|
+
pass
|
|
2425
|
+
# Everything else
|
|
2426
|
+
return repr(value)
|
|
2427
|
+
|
|
2428
|
+
items = vars(self)
|
|
2429
|
+
w = max(len(k) for k in items) if items else 0
|
|
2430
|
+
lines = []
|
|
2431
|
+
for k, v in items.items():
|
|
2432
|
+
rendered = fmt(v)
|
|
2433
|
+
if rendered.startswith("\n"): # multiline block
|
|
2434
|
+
lines.append(f"{k:<{w}} :{rendered}")
|
|
2435
|
+
else:
|
|
2436
|
+
lines.append(f"{k:<{w}} : {rendered}")
|
|
2437
|
+
return f"{self.__class__.__name__}(\n " + "\n ".join(lines) + "\n)"
|
|
2438
|
+
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
@dataclass
|
|
2443
|
+
class Listmodels(BaseExplode):
|
|
2444
|
+
"""Container for multiple Makemodel instances.
|
|
2445
|
+
|
|
2446
|
+
Enables combination of several Makemodel objects using + operations.
|
|
2447
|
+
"""
|
|
2448
|
+
|
|
2449
|
+
makemodels: List['Makemodel'] = field(default_factory=list)
|
|
2450
|
+
|
|
2451
|
+
@property
|
|
2452
|
+
def models(self) -> List['Makemodel']:
|
|
2453
|
+
"""Alias for member Makemodel instances.
|
|
2454
|
+
|
|
2455
|
+
The dataclass field remains ``makemodels`` for backwards
|
|
2456
|
+
compatibility with older notebooks and with the old ``Lexplode``
|
|
2457
|
+
constructor keyword.
|
|
2458
|
+
"""
|
|
2459
|
+
return self.makemodels
|
|
2460
|
+
|
|
2461
|
+
|
|
2462
|
+
# def __post_init__(self):
|
|
2463
|
+
|
|
2464
|
+
# self.normal_frml = '\n'.join(m.normal_frml.strip() for m in self.makemodels)
|
|
2465
|
+
|
|
2466
|
+
@cached_property
|
|
2467
|
+
def normal_frml(self) -> str:
|
|
2468
|
+
"""Compute and cache concatenated normal_frml lazily on first access."""
|
|
2469
|
+
return "\n".join(m.normal_frml.strip() for m in self.makemodels)
|
|
2470
|
+
|
|
2471
|
+
@property
|
|
2472
|
+
def normal_main(self) -> str:
|
|
2473
|
+
"""Concatenated core equations from all member Makemodels (no fitted or add-factor equations)."""
|
|
2474
|
+
return "\n".join(
|
|
2475
|
+
m.normal_main.strip()
|
|
2476
|
+
for m in self.makemodels
|
|
2477
|
+
if m.normal_main.strip()
|
|
2478
|
+
)
|
|
2479
|
+
|
|
2480
|
+
@property
|
|
2481
|
+
def normal_calc_add(self) -> str:
|
|
2482
|
+
"""Concatenated add-factor calculation equations from all member Makemodels."""
|
|
2483
|
+
return "\n".join(
|
|
2484
|
+
m.normal_calc_add.strip()
|
|
2485
|
+
for m in self.makemodels
|
|
2486
|
+
if m.normal_calc_add.strip()
|
|
2487
|
+
)
|
|
2488
|
+
|
|
2489
|
+
@property
|
|
2490
|
+
def clean_frml(self) -> str:
|
|
2491
|
+
"""Concatenated clean equations from all member Makemodels (no add-factors or exogenization)."""
|
|
2492
|
+
return "\n".join(
|
|
2493
|
+
m.clean_frml.strip()
|
|
2494
|
+
for m in self.makemodels
|
|
2495
|
+
if m.clean_frml.strip()
|
|
2496
|
+
)
|
|
2497
|
+
|
|
2498
|
+
@property
|
|
2499
|
+
def clean_model(self):
|
|
2500
|
+
"""A ModelFlow model with only the core equations (no fitted, add-factor, or exogenization equations)."""
|
|
2501
|
+
from modelclass import model
|
|
2502
|
+
combined_var_desc = {}
|
|
2503
|
+
for m in self.makemodels:
|
|
2504
|
+
combined_var_desc |= m.var_description
|
|
2505
|
+
combined_funks = [f for m in self.makemodels for f in m.funks]
|
|
2506
|
+
return model(self.clean_frml, funks=combined_funks, var_description=combined_var_desc)
|
|
2507
|
+
|
|
2508
|
+
@property
|
|
2509
|
+
def clean_model_draw(self):
|
|
2510
|
+
"""Draw the dependency graph of the clean model."""
|
|
2511
|
+
self.clean_model.drawmodel()
|
|
2512
|
+
|
|
2513
|
+
@property
|
|
2514
|
+
def add_model(self):
|
|
2515
|
+
"""A ModelFlow model containing only the add-factor calculations."""
|
|
2516
|
+
from modelclass import model
|
|
2517
|
+
return model(self.normal_calc_add.replace("<CALC_ADD_FACTOR>", "<INIT_ADD>"))
|
|
2518
|
+
|
|
2519
|
+
def init_addfactors(
|
|
2520
|
+
self,
|
|
2521
|
+
df,
|
|
2522
|
+
start: Union[str, int] = "",
|
|
2523
|
+
end: Union[str, int] = "",
|
|
2524
|
+
show: bool = False,
|
|
2525
|
+
check: bool = False,
|
|
2526
|
+
silent: bool = True,
|
|
2527
|
+
multiplier: float = 1.0,
|
|
2528
|
+
):
|
|
2529
|
+
"""Compute add factors and apply them so the combined model reproduces ``df``.
|
|
2530
|
+
|
|
2531
|
+
Parameters
|
|
2532
|
+
----------
|
|
2533
|
+
df : pandas.DataFrame
|
|
2534
|
+
Historical data to align with.
|
|
2535
|
+
start, end : str | int, optional
|
|
2536
|
+
Alignment window (defaults to the full range).
|
|
2537
|
+
show : bool, default False
|
|
2538
|
+
If True, print the calculated add factors.
|
|
2539
|
+
check : bool, default False
|
|
2540
|
+
Re-simulate with aligned data and print the residual.
|
|
2541
|
+
silent : bool, default True
|
|
2542
|
+
Silence ModelFlow runtime output.
|
|
2543
|
+
multiplier : float, default 1.0
|
|
2544
|
+
Scale factor applied to the residual check printout.
|
|
2545
|
+
|
|
2546
|
+
Returns
|
|
2547
|
+
-------
|
|
2548
|
+
pandas.DataFrame
|
|
2549
|
+
A copy of ``df`` with add-factor columns filled in.
|
|
2550
|
+
"""
|
|
2551
|
+
am = self.add_model
|
|
2552
|
+
aligned = am(df, start, end, silent=silent)
|
|
2553
|
+
if show:
|
|
2554
|
+
print("\n\nAdd factors to align historic values and model results")
|
|
2555
|
+
print(am["*_A"].df)
|
|
2556
|
+
if check:
|
|
2557
|
+
full = self.mmodel
|
|
2558
|
+
_ = full(aligned, start, end, silent=silent)
|
|
2559
|
+
print("\n\nDifference between historic values and model results")
|
|
2560
|
+
if multiplier != 1.0:
|
|
2561
|
+
print(f"Multiplied by {multiplier}")
|
|
2562
|
+
full.basedf = df
|
|
2563
|
+
display(full["#ENDO"].dif.df * multiplier)
|
|
2564
|
+
return aligned
|
|
2565
|
+
|
|
2566
|
+
def __add__(self, other):
|
|
2567
|
+
if isinstance(other, Makemodel):
|
|
2568
|
+
return Listmodels(makemodels=self.makemodels + [other])
|
|
2569
|
+
elif isinstance(other, Listmodels):
|
|
2570
|
+
return Listmodels(makemodels=self.makemodels + other.makemodels)
|
|
2571
|
+
else:
|
|
2572
|
+
return NotImplemented
|
|
2573
|
+
|
|
2574
|
+
def __radd__(self, other):
|
|
2575
|
+
if other == 0:
|
|
2576
|
+
return self
|
|
2577
|
+
if isinstance(other, Makemodel):
|
|
2578
|
+
return Listmodels(makemodels=[other] + self.makemodels)
|
|
2579
|
+
elif isinstance(other, Listmodels):
|
|
2580
|
+
return Listmodels(makemodels=other.makemodels + self.makemodels)
|
|
2581
|
+
else:
|
|
2582
|
+
return NotImplemented
|
|
2583
|
+
|
|
2584
|
+
|
|
2585
|
+
def __repr__(self):
|
|
2586
|
+
return f"Listmodels(makemodels={self.makemodels!r})"
|
|
2587
|
+
|
|
2588
|
+
def estimation_report(
|
|
2589
|
+
self,
|
|
2590
|
+
path: str = "html",
|
|
2591
|
+
filename: str = "listmodels_estimation_report.html",
|
|
2592
|
+
plot_format: str = "svg",
|
|
2593
|
+
title: str = "Listmodels Estimation Summary",
|
|
2594
|
+
open_file: bool = False,
|
|
2595
|
+
report_all: bool = False,
|
|
2596
|
+
) -> None:
|
|
2597
|
+
"""Export an HTML report for estimations embedded in all member Makemodels."""
|
|
2598
|
+
return export_makemodel_estimation_reports_to_html(
|
|
2599
|
+
self,
|
|
2600
|
+
path=path,
|
|
2601
|
+
filename=filename,
|
|
2602
|
+
plot_format=plot_format,
|
|
2603
|
+
title=title,
|
|
2604
|
+
open_file=open_file,
|
|
2605
|
+
report_all=report_all,
|
|
2606
|
+
)
|
|
2607
|
+
|
|
2608
|
+
def report(
|
|
2609
|
+
self,
|
|
2610
|
+
title: str = "",
|
|
2611
|
+
plot_format: str = "svg",
|
|
2612
|
+
report_all: bool = False,
|
|
2613
|
+
) -> "MakeModelReport":
|
|
2614
|
+
"""Return a :class:`~modelhtml.MakeModelReport` for this combined model.
|
|
2615
|
+
|
|
2616
|
+
See :meth:`Makemodel.report` for parameter details.
|
|
2617
|
+
"""
|
|
2618
|
+
return MakeModelReport(self, title=title, plot_format=plot_format, report_all=report_all)
|
|
2619
|
+
|
|
2620
|
+
@property
|
|
2621
|
+
def markdown_with_estimation(self) -> str:
|
|
2622
|
+
"""Concatenate member Makemodel markdown-with-estimation strings."""
|
|
2623
|
+
return "\n\n".join(
|
|
2624
|
+
mex.markdown_with_estimation
|
|
2625
|
+
for mex in self.makemodels
|
|
2626
|
+
)
|
|
2627
|
+
|
|
2628
|
+
@property
|
|
2629
|
+
def markdown_model_with_estimation(self) -> str:
|
|
2630
|
+
"""Alias for :attr:`markdown_with_estimation`."""
|
|
2631
|
+
return self.markdown_with_estimation
|
|
2632
|
+
|
|
2633
|
+
# __str__(self):
|
|
2634
|
+
|
|
2635
|
+
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
def _iter_makemodel_report_sources(obj):
|
|
2639
|
+
"""Yield Makemodel instances from a Makemodel or Listmodels report source."""
|
|
2640
|
+
if isinstance(obj, Makemodel):
|
|
2641
|
+
yield obj
|
|
2642
|
+
return
|
|
2643
|
+
if isinstance(obj, Listmodels):
|
|
2644
|
+
for mex in obj.makemodels:
|
|
2645
|
+
yield from _iter_makemodel_report_sources(mex)
|
|
2646
|
+
return
|
|
2647
|
+
raise TypeError(
|
|
2648
|
+
"Expected a Makemodel or Listmodels instance, "
|
|
2649
|
+
f"got {type(obj).__name__}."
|
|
2650
|
+
)
|
|
2651
|
+
|
|
2652
|
+
|
|
2653
|
+
def _is_estimator_backend_instance(eq) -> bool:
|
|
2654
|
+
"""True for concrete modelestimator_new estimator backends.
|
|
2655
|
+
|
|
2656
|
+
This is the canonical way to distinguish estimated equations from
|
|
2657
|
+
identity-only Eq objects. All built-in estimators and local user-defined
|
|
2658
|
+
estimators should subclass :class:`modelestimator_new.EstimatorBackend`.
|
|
2659
|
+
"""
|
|
2660
|
+
try:
|
|
2661
|
+
from modelestimator_new import EstimatorBackend
|
|
2662
|
+
except Exception:
|
|
2663
|
+
return False
|
|
2664
|
+
return isinstance(eq, EstimatorBackend)
|
|
2665
|
+
|
|
2666
|
+
|
|
2667
|
+
def makemodel_report_equations(makemodel_obj, *, report_all: bool = False) -> list:
|
|
2668
|
+
"""Return reportable equation objects from a Makemodel/Listmodels.
|
|
2669
|
+
|
|
2670
|
+
Estimated equations are identified by ``isinstance(eq, EstimatorBackend)``.
|
|
2671
|
+
When ``report_all=True``, unestimated equations are wrapped as lightweight
|
|
2672
|
+
:class:`modelestimator_new.Eq` instances so the shared HTML exporter can
|
|
2673
|
+
render minimal identity panels.
|
|
2674
|
+
"""
|
|
2675
|
+
from modelestimator_new import Eq
|
|
2676
|
+
|
|
2677
|
+
equations = []
|
|
2678
|
+
for mex in _iter_makemodel_report_sources(makemodel_obj):
|
|
2679
|
+
records = list(getattr(mex, "estimation_records", []))
|
|
2680
|
+
records_by_index = {
|
|
2681
|
+
rec.get("equation_index"): rec
|
|
2682
|
+
for rec in records
|
|
2683
|
+
if rec.get("equation_index") is not None
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
# New objects have equation_index in each record, so we can preserve
|
|
2687
|
+
# the expanded-equation order and optionally interleave identities.
|
|
2688
|
+
if records_by_index:
|
|
2689
|
+
normal_inputs = list(getattr(mex, "normal_input_expressions", []))
|
|
2690
|
+
for i, parts in enumerate(getattr(mex, "expanded_frml_split", [])):
|
|
2691
|
+
rec = records_by_index.get(i)
|
|
2692
|
+
if rec is not None:
|
|
2693
|
+
eq = rec.get("estimator_object")
|
|
2694
|
+
if _is_estimator_backend_instance(eq):
|
|
2695
|
+
equations.append(eq)
|
|
2696
|
+
continue
|
|
2697
|
+
if not report_all:
|
|
2698
|
+
continue
|
|
2699
|
+
expression = (
|
|
2700
|
+
normal_inputs[i]
|
|
2701
|
+
if i < len(normal_inputs)
|
|
2702
|
+
else parts.expression
|
|
2703
|
+
)
|
|
2704
|
+
equations.append(Eq(
|
|
2705
|
+
org_eq=expression,
|
|
2706
|
+
frml_name=parts.frmlname,
|
|
2707
|
+
))
|
|
2708
|
+
continue
|
|
2709
|
+
|
|
2710
|
+
# Backwards compatibility for Makemodel objects created before
|
|
2711
|
+
# equation_index was added: report EstimatorBackend instances, but
|
|
2712
|
+
# exact interleaving with identities is not available.
|
|
2713
|
+
equations.extend(
|
|
2714
|
+
eq
|
|
2715
|
+
for eq in (rec.get("estimator_object") for rec in records)
|
|
2716
|
+
if _is_estimator_backend_instance(eq)
|
|
2717
|
+
)
|
|
2718
|
+
|
|
2719
|
+
return equations
|
|
2720
|
+
|
|
2721
|
+
|
|
2722
|
+
def export_makemodel_estimation_reports_to_html(
|
|
2723
|
+
makemodel_obj,
|
|
2724
|
+
path: str = "html",
|
|
2725
|
+
filename: str = "makemodel_estimation_report.html",
|
|
2726
|
+
plot_format: str = "svg",
|
|
2727
|
+
title: str = "Makemodel Estimation Summary",
|
|
2728
|
+
open_file: bool = False,
|
|
2729
|
+
report_all: bool = False,
|
|
2730
|
+
) -> None:
|
|
2731
|
+
"""Export an HTML report for the estimated equations in a Makemodel.
|
|
2732
|
+
|
|
2733
|
+
Parameters mirror :func:`modelestimator_new.export_estimation_reports_to_html`.
|
|
2734
|
+
``makemodel_obj`` may be either a single :class:`Makemodel` or a
|
|
2735
|
+
:class:`Listmodels` combining several of them.
|
|
2736
|
+
"""
|
|
2737
|
+
from modelestimator_new import export_estimation_reports_to_html
|
|
2738
|
+
|
|
2739
|
+
equations = makemodel_report_equations(
|
|
2740
|
+
makemodel_obj,
|
|
2741
|
+
report_all=report_all,
|
|
2742
|
+
)
|
|
2743
|
+
|
|
2744
|
+
if not equations:
|
|
2745
|
+
print(
|
|
2746
|
+
"[Makemodel.estimation_report] No estimated equations found. "
|
|
2747
|
+
"Add <estimator=...> or <est=...> to one or more equations before requesting "
|
|
2748
|
+
"an estimation report."
|
|
2749
|
+
)
|
|
2750
|
+
return
|
|
2751
|
+
|
|
2752
|
+
if not report_all:
|
|
2753
|
+
identity_count = 0
|
|
2754
|
+
for mex in _iter_makemodel_report_sources(makemodel_obj):
|
|
2755
|
+
n_expanded = len(getattr(mex, "expanded_frml_split", []))
|
|
2756
|
+
n_estimated = sum(
|
|
2757
|
+
1
|
|
2758
|
+
for rec in getattr(mex, "estimation_records", [])
|
|
2759
|
+
if _is_estimator_backend_instance(rec.get("estimator_object"))
|
|
2760
|
+
)
|
|
2761
|
+
identity_count += max(0, n_expanded - n_estimated)
|
|
2762
|
+
if identity_count:
|
|
2763
|
+
print(
|
|
2764
|
+
f"[Makemodel.estimation_report] Including {len(equations)} "
|
|
2765
|
+
f"estimated equation(s); skipping {identity_count} identity "
|
|
2766
|
+
"equation(s). Pass report_all=True to include identities too."
|
|
2767
|
+
)
|
|
2768
|
+
|
|
2769
|
+
return export_estimation_reports_to_html(
|
|
2770
|
+
equations=equations,
|
|
2771
|
+
path=path,
|
|
2772
|
+
filename=filename,
|
|
2773
|
+
plot_format=plot_format,
|
|
2774
|
+
title=title,
|
|
2775
|
+
open_file=open_file,
|
|
2776
|
+
report_all=report_all,
|
|
2777
|
+
)
|
|
2778
|
+
|
|
2779
|
+
|
|
2780
|
+
|
|
2781
|
+
# MakeModelReport lives in modelhtml.py; re-exported here for convenience.
|
|
2782
|
+
from modelhtml import MakeModelReport
|
|
2783
|
+
|
|
2784
|
+
|
|
2785
|
+
# -----------------------------------------------------------------------------
|
|
2786
|
+
# Backwards compatibility aliases
|
|
2787
|
+
# -----------------------------------------------------------------------------
|
|
2788
|
+
# ``Makemodel`` is the new public name. These aliases keep older notebooks
|
|
2789
|
+
# importing/constructing ``Mexplode`` or calling the old helper names working
|
|
2790
|
+
# while code is migrated.
|
|
2791
|
+
Mexplode = Makemodel
|
|
2792
|
+
Lexplode = Listmodels
|
|
2793
|
+
mexplode_report_equations = makemodel_report_equations
|
|
2794
|
+
export_mexplode_estimation_reports_to_html = export_makemodel_estimation_reports_to_html
|
|
2795
|
+
|
|
2796
|
+
if __name__ == '__main__' :
|
|
2797
|
+
#%%
|
|
2798
|
+
pass
|
|
2799
|
+
if 0:
|
|
2800
|
+
res3 = Makemodel('''
|
|
2801
|
+
<endo=f,stoc> a = gamma+ f+O
|
|
2802
|
+
<endo=f> a = gamma+ f+O
|
|
2803
|
+
<endo=x,stoc,endo_lhs> a+x = gamma+ f+O
|
|
2804
|
+
<endo=x,stoc> a+x = gamma+ f+O
|
|
2805
|
+
<fit,exo,stoc> c = b*42
|
|
2806
|
+
|
|
2807
|
+
''',replacements=None)
|
|
2808
|
+
|
|
2809
|
+
res3.shownormal_expressions
|
|
2810
|
+
tlists = '''
|
|
2811
|
+
LIST BANKS = BANKS : IB SOREN MARIE /
|
|
2812
|
+
COUNTRY : DENMARK SWEDEN DENMARK /
|
|
2813
|
+
SELECTED : 1 0 1
|
|
2814
|
+
$
|
|
2815
|
+
LIST SECTORS = SECTORS : NFC SME HH $
|
|
2816
|
+
|
|
2817
|
+
'''
|
|
2818
|
+
|
|
2819
|
+
xx = '''
|
|
2820
|
+
doable <HEST,sum=abe> [banks=country=sweden, sektors=sektors] LOSS__{BANKS}__{SECTORs} = HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs}$
|
|
2821
|
+
doable <HEST,sum=goat> [banks=country=denmark,sektors=sektors] LOSS2__{BANKS}__{SECTORs} = HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs}$
|
|
2822
|
+
do sectors $
|
|
2823
|
+
frml <> x_{sectors} = 42 $
|
|
2824
|
+
enddo $
|
|
2825
|
+
|
|
2826
|
+
'''.upper()
|
|
2827
|
+
# xx = 'doabel <HEST,sum=abe> LOSS__{BANKS}__{SECTORs} =HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs} $'.upper()
|
|
2828
|
+
|
|
2829
|
+
# res = Makemodel(tlists+xx)
|
|
2830
|
+
# print(res)
|
|
2831
|
+
# breakpoint()
|
|
2832
|
+
res2 = Makemodel('''
|
|
2833
|
+
LIST BANKS = BANKS : IB SOREN MARIE /
|
|
2834
|
+
COUNTRY : DENMARK SWEDEN DENMARK /
|
|
2835
|
+
SELECTED : 1 1 0
|
|
2836
|
+
LIST SECTORS = SECTORS : NFC SME HH
|
|
2837
|
+
LIST test = t : 100*104
|
|
2838
|
+
a = b
|
|
2839
|
+
c = 33
|
|
2840
|
+
£ test
|
|
2841
|
+
doable <HEST,sum=goat> [banks country=denmark] LOSS2__{BANKS}__{SECTORs} = HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs}
|
|
2842
|
+
do banks
|
|
2843
|
+
£ sector {country}
|
|
2844
|
+
frml <> x_{banks} = 42
|
|
2845
|
+
enddo
|
|
2846
|
+
|
|
2847
|
+
''')
|
|
2848
|
+
|
|
2849
|
+
print(res2+res3)
|
|
2850
|
+
|
|
2851
|
+
if 0:
|
|
2852
|
+
test1= Makemodel('a=1')
|
|
2853
|
+
test2= Makemodel('b=2')
|
|
2854
|
+
test3= Makemodel('c=3')
|
|
2855
|
+
print(test1+(test2+test3))
|
|
2856
|
+
print(test1)
|
|
2857
|
+
|
|
2858
|
+
text = """>list ages = ages : age_0 * age_11
|
|
2859
|
+
>list sexes = sexes : female male /
|
|
2860
|
+
> fertile : 1 0
|
|
2861
|
+
other stuf
|
|
2862
|
+
and yes
|
|
2863
|
+
> dekdkd"""
|
|
2864
|
+
|
|
2865
|
+
|
|
2866
|
+
|
|
2867
|
+
print(text2:=mfmod_list_to_markdown(text))
|
|
2868
|
+
|
|
2869
|
+
#%% latex
|
|
2870
|
+
leq = r'CRF^{l,es,d}=\frac{DiscountRate^{l,es,d}}{1 - \left(1 + DiscountRate^{l,es,d}\right)^{-LifeSpan^{l,es,d}}}'
|
|
2871
|
+
meq = latex_to_doable(leq, '<ii>')
|
|
2872
|
+
print(meq)
|