Source code for biogeme.latent_variables.latex_report

"""Generate a full scientific LaTeX report from a resolved model."""

from __future__ import annotations

from pathlib import Path

from .model_spec import MeasurementModel
from .resolved import ResolvedModel
from .tex_utils import tex_escape, tex_identifier


def _combo_to_latex(combo) -> str:
    parts: list[str] = []
    if combo.intercept is not None:
        if hasattr(combo.intercept, 'final_name'):
            parts.append(tex_identifier(combo.intercept.final_name))
        else:
            parts.append(tex_escape(str(combo.intercept.value)))
    for term in combo.terms:
        coef = (
            tex_identifier(term.coefficient.final_name)
            if hasattr(term.coefficient, 'final_name')
            else str(term.coefficient.value)
        )
        parts.append(f'{coef}\\,{tex_identifier(term.variable_name)}')
    return ' + '.join(parts) if parts else '0'


[docs] def generate_latex_report(resolved: ResolvedModel) -> str: lines: list[str] = [] lines.append(r'\section{Latent Variable Model}') lines.append(r'\subsection{Model overview}') lines.append( rf'The model contains {resolved.metadata.n_latent_variables} latent variables, {resolved.metadata.n_indicators} indicators, and {resolved.metadata.n_threshold_systems} ordinal threshold systems.' ) lines.append(r'\subsection{Structural equations}') for latent_name, latent in resolved.latent_variables.items(): latent_name_tex = tex_identifier(latent_name) eq = latent.structural_equation if eq.sigma is None: raise ValueError( f"Structural equation for latent variable '{latent_name}' is missing a resolved sigma parameter." ) sigma = tex_identifier(eq.sigma.final_name) rhs = _combo_to_latex(eq.systematic_part) lines.append(r'\[') lines.append( rf'{latent_name_tex} = {rhs} + {sigma}\,\omega_{{{latent_name_tex}}}' ) lines.append(r'\]') lines.append(r'\subsection{Measurement equations}') for indicator_name, equation in resolved.measurement_equations.items(): indicator_name_tex = tex_escape(indicator_name) mu = _combo_to_latex(equation.systematic_part) if equation.sigma is None: raise ValueError( f"Measurement equation for indicator '{indicator_name}' is missing a resolved sigma parameter." ) sigma = tex_identifier(equation.sigma.final_name) lines.append(rf'\paragraph{{Indicator {indicator_name_tex}}}') lines.append(r'\[') lines.append( rf'I^*_{{{indicator_name_tex}}} = {mu} + {sigma}\,\varepsilon_{{{indicator_name_tex}}}' ) lines.append(r'\]') if equation.measurement_model == MeasurementModel.GAUSSIAN: lines.append(r'\[') lines.append(rf'I_{{{indicator_name_tex}}} = I^*_{{{indicator_name_tex}}}') lines.append(r'\]') elif equation.measurement_model == MeasurementModel.ORDERED_PROBIT: lines.append(r'\[') lines.append( rf'P(I_{{{indicator_name_tex}}}=j_m\mid x^*) = \Phi\!\left(\frac{{\tau_m-{mu}}}{{{sigma}}}\right) - \Phi\!\left(\frac{{\tau_{{m-1}}-{mu}}}{{{sigma}}}\right)' ) lines.append(r'\]') else: lines.append(r'\[') lines.append( rf'P(I_{{{indicator_name_tex}}}=j_m\mid x^*) = \Lambda\!\left(\frac{{\tau_m-{mu}}}{{{sigma}}}\right) - \Lambda\!\left(\frac{{\tau_{{m-1}}-{mu}}}{{{sigma}}}\right)' ) lines.append(r'\]') if resolved.threshold_systems: lines.append(r'\subsection{Threshold systems}') for type_name, system in resolved.threshold_systems.items(): lines.append(rf'\paragraph{{Threshold system {tex_escape(type_name)}}}') lines.append(r'\begin{align*}') for i, cutpoint in enumerate(system.cutpoints): expr = tex_escape(cutpoint.expression_text) end = r'\\' if i < len(system.cutpoints) - 1 else '' lines.append(rf'{cutpoint.symbol_name} &= {expr} {end}') lines.append(r'\end{align*}') lines.append(r'\subsection{Normalization}') if resolved.normalization.rules: lines.append(r'\begin{itemize}') for rule in resolved.normalization.rules: lines.append( rf'\item {tex_escape(rule.reason)} ({tex_escape(rule.target_name)} = {rule.value})' ) lines.append(r'\end{itemize}') else: lines.append(r'No explicit normalization plan was provided.') if resolved.normalization.warnings: lines.append(r'\paragraph{Warnings}') lines.append(r'\begin{itemize}') for warning in resolved.normalization.warnings: lines.append(rf'\item {tex_escape(warning)}') lines.append(r'\end{itemize}') lines.append(r'\subsection{Parameter table}') lines.append(r'\begin{tabular}{lllll}') lines.append(r'\hline') lines.append(r'Name & Role & Status & Bounds & Notes \\') lines.append(r'\hline') for name in sorted(resolved.parameters): param = resolved.parameters[name] lower = '-\\infty' if param.lower_bound is None else str(param.lower_bound) upper = '+\\infty' if param.upper_bound is None else str(param.upper_bound) bounds = f'[{lower}, {upper}]' notes = '; '.join(tex_escape(n) for n in param.notes) lines.append( rf'{tex_escape(name)} & {tex_escape(param.role.value)} & {tex_escape(param.status.value)} & {tex_escape(bounds)} & {notes} \\' ) lines.append(r'\hline') lines.append(r'\end{tabular}') return '\n'.join(lines) + '\n'
[docs] def save_latex_report(report: str, path: str | Path) -> None: """Save LaTeX report to a file.""" Path(path).write_text(report, encoding='utf-8')