Skip to content

Commit 18cf2e1

Browse files
add LogitNormalVariable as pre-specified RandomVariable (#864)
* added logit normal RV, tests, updated docs * cleanup * changes per code review * changes per code review
1 parent 7009f7b commit 18cf2e1

4 files changed

Lines changed: 235 additions & 0 deletions

File tree

docs/tutorials/random_variables.qmd

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ from pyrenew.randomvariable import (
4646
DistributionalVariable,
4747
StaticDistributionalVariable,
4848
DynamicDistributionalVariable,
49+
LogitNormalVariable,
4950
TransformedVariable,
5051
)
5152
from pyrenew.observation import PopulationCounts, NegativeBinomialNoise
@@ -150,6 +151,29 @@ with numpyro.handlers.seed(rng_seed=0):
150151
print(f"Sum: {effect.sum():.1f}")
151152
```
152153

154+
`LogitNormalVariable` is a convenience factory for a common transformed prior: a Normal distribution on the log-odds scale, transformed to a probability in `(0, 1)`.
155+
The `median` is specified on the probability scale, while `scale` is the standard deviation on the log-odds scale.
156+
It returns a `TransformedVariable`, rather than introducing another `RandomVariable` implementation.
157+
158+
```{python}
159+
#| label: logit-normal-example
160+
161+
iedr = LogitNormalVariable(
162+
name="iedr",
163+
base_name="logit_iedr",
164+
median=0.004,
165+
scale=0.3,
166+
)
167+
168+
with numpyro.handlers.seed(rng_seed=0):
169+
value = iedr()
170+
print(f"IEDR: {value:.4%}")
171+
print(f"Latent sample name: {iedr.base_rv.name}")
172+
```
173+
174+
The underlying Normal sample is named `logit_iedr`; the transformed variable is named `iedr`.
175+
An optional NumPyro reparameterizer can be supplied with `reparam` when a hierarchical model would benefit from different sampling coordinates.
176+
153177
### Interchangeability
154178

155179
Because all implementations share the `sample()` interface, you can swap them freely.
@@ -478,6 +502,7 @@ The right panel shows the additional weekly oscillation introduced by day-of-wee
478502
| Fixed known PMF | `DeterministicPMF` |
479503
| Sample from a fixed distribution | `DistributionalVariable` (static) |
480504
| Sample from a distribution parameterized at sample time | `DistributionalVariable` (dynamic, pass a callable) |
505+
| Probability with a Normal prior on its log-odds | `LogitNormalVariable` |
481506
| Deterministic transformation of another RV | `TransformedVariable` |
482507
| Multiple sample statements, custom validation, or derived computation | Custom `RandomVariable` subclass |
483508

pyrenew/randomvariable/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
DynamicDistributionalVariable,
66
StaticDistributionalVariable,
77
)
8+
from pyrenew.randomvariable.logitnormalvariable import LogitNormalVariable
89
from pyrenew.randomvariable.transformedvariable import TransformedVariable
910
from pyrenew.randomvariable.vectorizedvariable import VectorizedVariable
1011

1112
__all__ = [
1213
"DistributionalVariable",
1314
"StaticDistributionalVariable",
1415
"DynamicDistributionalVariable",
16+
"LogitNormalVariable",
1517
"TransformedVariable",
1618
"VectorizedVariable",
1719
]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Factory for logit-normal random variables."""
2+
3+
import jax.numpy as jnp
4+
import numpyro.distributions as dist
5+
from jax.typing import ArrayLike
6+
from numpyro.infer.reparam import Reparam
7+
from numpyro.util import not_jax_tracer
8+
9+
import pyrenew.transformation as transformation
10+
from pyrenew.randomvariable.distributionalvariable import DistributionalVariable
11+
from pyrenew.randomvariable.transformedvariable import TransformedVariable
12+
13+
14+
def LogitNormalVariable(
15+
name: str,
16+
median: ArrayLike,
17+
scale: ArrayLike,
18+
base_name: str | None = None,
19+
reparam: Reparam = None,
20+
) -> TransformedVariable:
21+
"""Create a logit-normal distributed random variable.
22+
23+
Parameters
24+
----------
25+
name
26+
Name of the random variable.
27+
median
28+
Median of the random variable on the probability scale. All values
29+
must be finite and strictly between 0 and 1.
30+
scale
31+
Standard deviation of the Normal distribution on the logit scale. All
32+
values must be finite and strictly positive.
33+
base_name
34+
Name of the underlying Normal random variable. Defaults to
35+
``f"logit_{name}"``.
36+
reparam
37+
If not None, reparameterize sampling from the underlying Normal
38+
distribution according to the given NumPyro reparameterizer.
39+
40+
Returns
41+
-------
42+
TransformedVariable
43+
A transformed variable whose underlying Normal random variable has
44+
location ``logit(median)`` and the specified scale.
45+
46+
Raises
47+
------
48+
ValueError
49+
If any statically available median is not finite and strictly between
50+
0 and 1, or any statically available scale is not finite and positive.
51+
"""
52+
median = jnp.asarray(median)
53+
invalid_median = jnp.any(~jnp.isfinite(median) | (median <= 0) | (median >= 1))
54+
if not_jax_tracer(invalid_median) and bool(invalid_median):
55+
raise ValueError(
56+
"median must contain only finite values strictly between 0 and 1"
57+
)
58+
59+
scale = jnp.asarray(scale)
60+
invalid_scale = jnp.any(~jnp.isfinite(scale) | (scale <= 0))
61+
if not_jax_tracer(invalid_scale) and bool(invalid_scale):
62+
raise ValueError("scale must contain only finite positive values")
63+
64+
sigmoid_transform = transformation.SigmoidTransform()
65+
return TransformedVariable(
66+
name=name,
67+
base_rv=DistributionalVariable(
68+
name=base_name or f"logit_{name}",
69+
distribution=dist.Normal(
70+
sigmoid_transform.inv(median),
71+
scale,
72+
),
73+
reparam=reparam,
74+
),
75+
transforms=sigmoid_transform,
76+
)

test/test_logit_normal_rv.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Tests for the LogitNormalVariable factory."""
2+
3+
import jax
4+
import jax.numpy as jnp
5+
import numpyro
6+
import numpyro.distributions as dist
7+
import pytest
8+
from numpy.testing import assert_allclose
9+
from numpyro.infer import Predictive
10+
from numpyro.infer.reparam import LocScaleReparam
11+
12+
import pyrenew.transformation as transformation
13+
from pyrenew.randomvariable import (
14+
LogitNormalVariable,
15+
StaticDistributionalVariable,
16+
TransformedVariable,
17+
)
18+
19+
20+
def test_logit_normal_variable_construction():
21+
"""The factory constructs the expected transformed Normal variable."""
22+
median = 0.004
23+
scale = 0.3
24+
25+
rv = LogitNormalVariable(name="iedr", median=median, scale=scale)
26+
27+
assert isinstance(rv, TransformedVariable)
28+
assert isinstance(rv.base_rv, StaticDistributionalVariable)
29+
assert isinstance(rv.base_rv.distribution, dist.Normal)
30+
assert_allclose(
31+
rv.base_rv.distribution.loc,
32+
transformation.SigmoidTransform().inv(median),
33+
)
34+
assert_allclose(rv.base_rv.distribution.scale, scale)
35+
assert len(rv.transforms) == 1
36+
assert isinstance(rv.transforms[0], transformation.SigmoidTransform)
37+
38+
39+
def test_logit_normal_variable_samples_are_probabilities():
40+
"""Samples from a logit-normal variable lie strictly between zero and one."""
41+
rv = LogitNormalVariable(name="iedr", median=0.004, scale=0.3)
42+
43+
def model(): # numpydoc ignore=GL08
44+
return rv.sample(record=True)
45+
46+
samples = Predictive(model, num_samples=100)(jax.random.key(0))
47+
values = samples["iedr"][0]
48+
49+
assert (values > 0).all()
50+
assert (values < 1).all()
51+
52+
53+
@pytest.mark.parametrize(
54+
("base_name", "expected_base_name"),
55+
[
56+
(None, "logit_iedr"),
57+
("iedr_unconstrained", "iedr_unconstrained"),
58+
],
59+
)
60+
def test_logit_normal_variable_names(base_name, expected_base_name):
61+
"""Default and explicit names are used for object and trace sites."""
62+
rv = LogitNormalVariable(
63+
name="iedr",
64+
median=0.004,
65+
scale=0.3,
66+
base_name=base_name,
67+
)
68+
69+
assert rv.name == "iedr"
70+
assert rv.base_rv.name == expected_base_name
71+
72+
with numpyro.handlers.seed(rng_seed=0), numpyro.handlers.trace() as trace:
73+
rv.sample(record=True)
74+
75+
assert trace[expected_base_name]["type"] == "sample"
76+
assert trace["iedr"]["type"] == "deterministic"
77+
78+
79+
def test_logit_normal_variable_reparameterization():
80+
"""A reparameterizer is applied to the underlying Normal sample site."""
81+
reparam = LocScaleReparam(0)
82+
rv = LogitNormalVariable(
83+
name="iedr",
84+
median=0.004,
85+
scale=0.3,
86+
reparam=reparam,
87+
)
88+
89+
assert rv.base_rv.reparam_dict == {"logit_iedr": reparam}
90+
91+
with numpyro.handlers.seed(rng_seed=0), numpyro.handlers.trace() as trace:
92+
value = rv.sample()
93+
94+
assert trace["logit_iedr_decentered"]["type"] == "sample"
95+
assert trace["logit_iedr"]["type"] == "deterministic"
96+
assert 0 < value < 1
97+
98+
99+
@pytest.mark.parametrize(
100+
"median",
101+
[
102+
-0.1,
103+
0.0,
104+
1.0,
105+
1.1,
106+
float("-inf"),
107+
float("inf"),
108+
float("nan"),
109+
jnp.array([0.2, 1.0]),
110+
],
111+
)
112+
def test_logit_normal_variable_rejects_invalid_median(median):
113+
"""Medians must be finite and strictly inside the unit interval."""
114+
with pytest.raises(ValueError, match="median must contain only finite values"):
115+
LogitNormalVariable(name="invalid", median=median, scale=0.3)
116+
117+
118+
@pytest.mark.parametrize(
119+
"scale",
120+
[
121+
-0.1,
122+
0.0,
123+
float("-inf"),
124+
float("inf"),
125+
float("nan"),
126+
jnp.array([0.3, 0.0]),
127+
],
128+
)
129+
def test_logit_normal_variable_rejects_invalid_scale(scale):
130+
"""Scales must be finite and strictly positive."""
131+
with pytest.raises(ValueError, match="scale must contain only finite positive"):
132+
LogitNormalVariable(name="invalid", median=0.4, scale=scale)

0 commit comments

Comments
 (0)