Render full symbolic expressions for Deterministics and Potentials (plain text and LaTeX) - #8408
Render full symbolic expressions for Deterministics and Potentials (plain text and LaTeX)#8408drbenvincent wants to merge 11 commits into
Conversation
Add a `deterministic_exprs` flag to str_for_model and
str_for_potential_or_deterministic. When enabled, Deterministics and
Potentials render their full symbolic expression body instead of an
opaque f(inputs) placeholder, in both plain text and LaTeX.
The rendering is a pure traversal of the existing PyTensor graph (no
rewrites, compilation, or evaluation):
- Plain text clones the global pytensor printer with a named-variable
leaf rule, so nested deterministics reference each other by name.
- LaTeX uses a fresh printer registering LaTeX operators for common ops
(arithmetic, exp/log/sqrt, trig, dot, sum) with graceful
\\operatorname{...} fallback for unregistered ops.
- Variable names are escaped consistently with the rest of the model
repr; no implicit greek conversion.
Default output is unchanged. Addresses the unimplemented half of pymc-devs#7538;
closes pymc-devs#8407.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #8408 +/- ##
==========================================
+ Coverage 91.87% 91.94% +0.06%
==========================================
Files 128 128
Lines 21256 21560 +304
==========================================
+ Hits 19530 19824 +294
- Misses 1726 1736 +10
🚀 New features to boost your workflow:
|
- Table-driven TestDeterministicExprsParametric covering five model families (linear regression, nonlinear ops, matrix ops, indexing, potentials) with output invariants plus exact anchors per family. - _BodyLeafPrinter now unwraps unnamed ViewOp wrappers before reading the leaf name; previously a named variable referenced through an unary view_op crashed with AttributeError on None.name. - Regression test for the view-op path; sqrt LaTeX uses PatternPrinter.
Shared subexpressions (bound to Python variables) exercise the printers' memo paths; pt.sigmoid covers the unregistered-Elemwise operatorname fallback. Patch coverage for pymc/printing.py is now complete.
named_names.discard(var.name) requires str; guard against None names.
Cramer-von Mises p-values at n=5 are too coarse for a 0.001 threshold: even a perfect sampler (drawing from the reference truncnorm directly) fails ~0.1% of runs. Use 1000 draws so the comparison is meaningful. Unrelated to the printing changes; fixes the flaky ubuntu-numba CI leg.
This reverts commit 67662d0.
Non-centered partial-pooling model: verifies the pooled deterministic stops at named parents (mu, tau, z) in both plain text and LaTeX.
|
Note on There are no traditional What is deliberately unchanged (opt-in keyword only):
What we could do later, if desired:
None of these are included — happy to take (1) forward here or in a follow-up depending on maintainer preference. |
…alls An inline prior such as pm.Normal.dist(0, 1) inside a Deterministic or Potential body previously leaked graph internals in plain text (including a nondeterministic RNG memory address) and crashed the LaTeX printer on ownerless rng/size inputs. Both body printers now delegate to str_for_dist, so anonymous dists render like their named counterparts (e.g. Normal(0, 1)), with parameters that reference model variables rendered by name. Ownerless non-constant leaves (unnamed shared variables) terminate LaTeX rendering gracefully as their type. Adds a fixed_vs_prior_params parametric case covering scalar constants, named priors, an inline anonymous prior, and unnamed shared leaves; parametric tests now also assert no RNG internals leak into either format.
- _is_random_op reduces to a single isinstance against pytensor's RNGConsumerOp, which covers both RandomVariable and PyMC's SymbolicRandomVariable; drops the lazy-import cycle workaround. - DimShuffle is not mere 'broadcasting noise' (axis manipulation can be semantically meaningful, e.g. elementwise vs outer product); reworded to 'adds clutter without changing the expression'.
Adversarial review of #8408Tested at Two themes: a small number of outright bugs where the printed expression is wrong, and a design gap around unbounded verbosity. The second is the bigger one. The design gap: no length budget on the string reprs
Shared subexpressions expand exponentially. Nothing memoizes a node used more than once, so it is re-walked and re-printed each time: v = s
for _ in range(20):
v = v + v6.3M chars plain / 13.6M LaTeX in ~1.2s. 25 iterations is gigabytes. This is not just an adversarial toy. A realistic adstock + saturation block: acc = x
for l in range(1, 8):
acc = acc + (alpha ** l) * shifted(x, l)
sat = pm.Deterministic("sat", acc / (acc + lam))prints the entire 8-term adstock chain twice, once in the numerator and once in the denominator, because Deep chains hit the recursion limit. Printing is recursive, so the flag turns a working repr into a crash: v = s
for _ in range(6000):
v = v + 1.0
pm.Deterministic("d", v)
m.str_repr() # fine
m.str_repr(deterministic_exprs=True) # RecursionErrorThe cliff is around 4500-5000 chained ops. Graphs built in Python loops (unrolled AR, adstock, iterative link functions) get there. The mitigating factor, and I think the lever for the fix: recursion stops at named variables. A model that names its intermediates prints beautifully (50 chained named Deterministics gives 50 tidy one-liners). It is unnamed intermediate graph that blows up. Pointers, roughly in order of value:
Related design question you raised: array constants. Every one renders Correctness bugs1. Transposes are silently deleted. Rendering
2. The LaTeX fallback collapses distinct ops onto the same output.
Pointer: carry the distinguishing op parameter into the fallback name, and unwrap 3. LaTeX escaping is incomplete, and 4. Allocation machinery on display, inner function body never shown. Worth noting Minor
|
Correctness:
- DimShuffle no longer rendered transparently when it permutes axes;
full reversals render as transpose (X.T / ^{T}), other permutations
show their axis order, so transposed expressions print faithfully
- LaTeX fallback carries distinguishing op parameters: Blockwise/Elemwise
unwrap to their core/scalar op, casts show dtype, Sum shows reduction
axes (\sum_{0}), so eigh/cholesky/solve/sum-axis no longer collapse
- scan/OpFromGraph bodies (and slices thereof) render as opaque
f(named inputs) placeholders instead of leaking allocation machinery
- plain-text matmul renders @ instead of pytensor's \dot LaTeX escape
Minor:
- named leaves matched by identity rather than name string; dict-keyed
registrations shared with a named leaf's owner are shielded so leaves
still win over condition rules
- str_for_potential_or_deterministic defaults named_vars to all named
variables reachable from the graph, so standalone calls stop at named
intermediates instead of inlining distributions
- ownerless leaves render consistently across formats (name if present,
otherwise the type)
- docstrings document include_params=False precedence
Response to the adversarial review — correctness bugs and minor pointsAddressed in d8efa89. All scenarios below were re-run against the review's own reproductions, and Correctness bugs1. Transposes silently deleted — fixed. 2. LaTeX fallback collapsing distinct ops — fixed.
3. Incomplete LaTeX escaping ( 4. No Minor points
Not yet addressed
|
The deterministic_exprs rendering had no length budget: shared unnamed
subexpressions duplicated their text once per occurrence (exponential
for v = v + v loops or adstock chains reused in numerator and
denominator), and deep unrolled chains crashed the recursive printer
with RecursionError around 4500 ops.
_print_plan now walks the leaf-pruned graph breadth-first and computes,
iteratively, each node's depth and its number of occurrences in the
rendered tree. Nodes one level deeper than _MAX_EXPR_DEPTH are rendered
as the existing opaque f(inputs) placeholder by pre-seeding the print
memo, bounding recursion depth; if more than _MAX_EXPR_NODES nodes would
still be rendered, the whole expression degrades to a single placeholder.
Naming intermediates (or using pt.scan instead of Python loops) keeps
large models fully expanded.
Array constants in bodies now describe their geometry: scalars keep
their value, short 1-D arrays inline their values, and larger arrays
render as <constant float64 (20, 20)> in plain text and
<constant float64> \in \mathbb{R}^{20 x 20} in LaTeX. This is opt-in
only; default str_for_model output stays unchanged.
The design gap — addressed in 1cb318eBoth halves of the unbounded-verbosity gap are now implemented. Length / depth budget
Re-running the review's own reproductions:
The "never truncate" framing is amended: expressions are bounded, and naming intermediates (or using Array constants
This lives only in the opt-in body printer; Is the adversarial review fully addressed?Correctness bugs (1, 2, 4): yes — fixed in d8efa89 with regression tests each. Design gap: yes — length/depth budget, graceful degradation, recursion safety, and shape-aware constants as proposed above. Two deliberate remainders, for the record:
|
Summary
Closes #8407. Implements the unimplemented half of #7538: rendering the actual symbolic expression of Deterministics and Potentials instead of the opaque
f(inputs)placeholder, in both plain text and LaTeX.LaTeX output
The same model rendered as LaTeX — a full aligned equation block, ready for notebooks (
_repr_latex_), docs, or papers:Deterministic bodies render as proper math (
\frac,\log,\cdot, exponentiation); names are escaped consistently with the rest of the model repr; unknown ops degrade gracefully to\operatorname{name}(args)instead of crashing.Design decisions (from #8407 discussion)
deterministic_exprs=Falsedefault):print(m)output is byte-identical to before; no global config.etarenders asmu / (1 + mu)sincemu's definition appears on its own line.x.mean()shows as its true graph form (sum / shape), matching what is actually computed. This keeps the feature a pure graph traversal — no folding/compilation, avoiding the cost concern raised in the Richer textual representation: show Data variables, fold constants, and use semantic separators #8205 review.model_to_graphvizuntouched: node labels have tight space constraints and their own truncation machinery; can build on this later.Implementation notes
PPrinter.process_graph), inheriting its broad op coverage for free.PPrinter: registrations for arithmetic,exp/log/sqrt, trig,dot,sumviaPatternPrinter/OperatorPrinter/FunctionPrinter(precedence handling comes free), plus a graceful\operatorname{name}(args)fallback so unknown ops never crash.ViewOpwrappers (view_op(var, name=...)), so identity-unwrapping must stop at named nodes; andDimShuffleaxis manipulation is rendered transparently (its dict registration in the global printer outranks condition-based rules, so it is overridden by type key). Anonymous distributions (including PyMC'sSymbolicRandomVariables) are detected via pytensor'sRNGConsumerOpbase class._str_for_constant_valueformatting for consistency with the rest of the repr.Validation
tests/test_printing.py::TestDeterministicExprscover default-output invariance, plain bodies, nested-deterministic stopping, potentials, LaTeX bodies with\frac/\log, underscore escaping inside bodies, an unnamedViewOpwrapper around a named leaf (plain and LaTeX), the unregistered-Elemwise\operatornamefallback with memoized shared subexpressions, and standalonestr_for_potential_or_deterministic.tests/test_printing.py::TestDeterministicExprsParametricparametrize over seven model families — linear regression, nonlinear scalar ops (exp/sqrt/tanh), matrix ops (dot,sum), indexing/slicing subtensors, potentials only, a non-centered hierarchical model (partial pooling across groups), and fixed-value vs prior-valued parameters/hyperparameters (including an inline anonymous prior) — asserting per-family exact anchors plus general invariants: no leftover placeholders, no graph-internals leakage (DimShuffle{,ViewOp,RNG(), and no unescaped underscores anywhere in the LaTeX.pm.Normal.dist(0, 1)) render as distribution calls — like their named counterparts on RV lines — instead of leaking RNG internals; previously this crashed LaTeX rendering outright.pytest tests/test_printing.py: 46 passed.pre-commit run --files pymc/printing.py tests/test_printing.py: passes.python scripts/run_mypy.py:pymc/printing.pyclean.