88import math
99import matplotlib .pyplot as plt
1010import numpy as np
11+ import re
1112from pathlib import Path
1213from scipy .stats import gaussian_kde
1314from typing import TYPE_CHECKING
1920
2021_CONTINUOUS_COLOR = "steelblue"
2122_CATEGORICAL_COLOR = "steelblue"
22- _MEAN_COLOR = "firebrick "
23+ _PRIOR_COLOR = "grey "
2324
2425
2526def plot_marginals (
@@ -32,7 +33,8 @@ def plot_marginals(
3233
3334 A pure renderer: it draws already-sampled posterior draws and does not run inference.
3435 One panel per factor — a density curve for continuous factors, a probability bar chart
35- for categorical ones, wrapped into a grid.
36+ for categorical ones, wrapped into a grid. Panels for components of the same vector
37+ variation share a y-axis, so their densities compare directly.
3638
3739 Args:
3840 samples: ``(num_samples, num_factors)`` posterior draws in the dataset's factor
@@ -52,17 +54,30 @@ def plot_marginals(
5254 num_rows = math .ceil (len (factors ) / num_columns )
5355 figure , axes = plt .subplots (num_rows , num_columns , figsize = (6.0 * num_columns , 4.5 * num_rows ), squeeze = False )
5456 flat_axes = axes .flatten ()
57+ continuous_axes_by_variation : dict [str , list ] = {}
5558 for axis_index , factor in enumerate (factors ):
5659 ax = flat_axes [axis_index ]
5760 factor_samples = samples [:, dataset .factor_columns [factor .name ]].squeeze (- 1 )
5861 if factor .type == "continuous" :
5962 _draw_continuous_marginal (ax , factor , factor_samples )
63+ # Components of one vector variation (name[0], name[1], ...) share a scale.
64+ variation_name = re .sub (r"\[\d+\]$" , "" , factor .name )
65+ continuous_axes_by_variation .setdefault (variation_name , []).append (ax )
6066 else :
6167 _draw_categorical_marginal (ax , factor , factor_samples )
6268 ax .set_title (factor .name , fontsize = 11 )
6369 for unused_index in range (len (factors ), len (flat_axes )):
6470 flat_axes [unused_index ].axis ("off" )
6571
72+ # Give the components of a vector variation a common y-axis so their densities compare directly.
73+ # A standalone scalar factor keeps its own scale, since unrelated factors can differ in magnitude.
74+ for grouped_axes in continuous_axes_by_variation .values ():
75+ if len (grouped_axes ) < 2 :
76+ continue
77+ shared_top = max (grouped_ax .get_ylim ()[1 ] for grouped_ax in grouped_axes )
78+ for grouped_ax in grouped_axes :
79+ grouped_ax .set_ylim (0 , shared_top )
80+
6681 observation_label = ", " .join (
6782 f"{ name } ={ value :g} " for name , value in zip (dataset .outcome_names , observation .tolist ())
6883 )
@@ -80,21 +95,32 @@ def plot_marginals(
8095
8196
8297def _draw_continuous_marginal (ax , factor : FactorSpec , factor_samples : np .ndarray ) -> None :
83- """Smooth posterior density (filled KDE curve) of a continuous factor, with a mean line .
98+ """Posterior density of a continuous factor over its swept range .
8499
85- A KDE line over the posterior samples reads the shape of a continuous posterior better
86- than a binned histogram. Falls back to a single line at the mean when the samples have
87- no spread (KDE bandwidth is then undefined).
100+ Draws the KDE of the posterior samples, the uniform prior as a flat reference, and shades
101+ the central 5-95% of the posterior. Reading the posterior against the prior shows whether
102+ conditioning on the outcome concentrated the factor, which a mean alone would miss for a
103+ factor swept symmetrically around its nominal value.
88104 """
89105 range_low , range_high = factor .range
90- sample_mean = float (np .mean (factor_samples ))
106+ span = range_high - range_low
107+
91108 if float (np .std (factor_samples )) >= 1e-9 :
92109 grid = np .linspace (range_low , range_high , 200 )
93110 density = gaussian_kde (factor_samples )(grid )
94- ax .plot (grid , density , color = _CONTINUOUS_COLOR , linewidth = 2 )
111+ ax .plot (grid , density , color = _CONTINUOUS_COLOR , linewidth = 2 , label = "posterior" )
95112 ax .fill_between (grid , 0 , density , color = _CONTINUOUS_COLOR , alpha = 0.2 )
96113 ax .set_ylim (bottom = 0 )
97- ax .axvline (sample_mean , color = _MEAN_COLOR , linestyle = "--" , linewidth = 2 , label = f"mean = { sample_mean :.3g} " )
114+ low_percentile , high_percentile = np .percentile (factor_samples , [5 , 95 ])
115+ ax .axvspan (low_percentile , high_percentile , color = _CONTINUOUS_COLOR , alpha = 0.15 , label = "5-95%" )
116+ else :
117+ ax .axvline (float (np .mean (factor_samples )), color = _CONTINUOUS_COLOR , linewidth = 2 , label = "constant" )
118+ ax .set_ylim (bottom = 0 )
119+
120+ if span > 0 :
121+ # The uniform prior is the "no effect" reference the posterior is read against.
122+ ax .axhline (1.0 / span , color = _PRIOR_COLOR , linestyle = "--" , linewidth = 1.5 , label = "prior (uniform)" )
123+
98124 ax .set_xlim (range_low , range_high )
99125 ax .set_xlabel (factor .name )
100126 ax .set_ylabel ("posterior density" )
0 commit comments