Skip to content

Commit ff27ff3

Browse files
authored
Merge pull request #112 from adolgert/feature/test-narrow
Feature/test narrow
2 parents a2e5a74 + 319df4d commit ff27ff3

File tree

15 files changed

+509
-137
lines changed

15 files changed

+509
-137
lines changed

docs/notes/distributions.tex

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,15 @@ \subsection{Probability Evaluation}
135135
Julia Function & Equation & Statistics name \\ \hline
136136
\texttt{ccdf} & $S(x) = e^{-\int^x\lambda(s)ds}$ & Survival \\
137137
\texttt{cdf} & $F(x) = 1 - S(x) = 1-e^{-\int^x\lambda(s)ds}$ & Cumulative distribution function \\
138-
\texttt{pdf} & $f(x) = \lambda(x)e^{-\int^x\lambda(s)ds}$ & Probability distribution function \\
139-
\texttt{logpdf} & $\ln\:f(x)$ & Log-likelihood \\
138+
\texttt{pdf} & $f(x) = \lambda(x)e^{-\int^x\lambda(s)ds}$ & Probability density function \\
139+
\texttt{logpdf} & $\ln\:f(x)$ & Log probability density \\
140140
\texttt{logcdf} & $\ln\:F(x)$ & \\
141141
\texttt{logdiffcdf} & $\ln\left(F(x_2)-F(x_1)\right)$ & \\
142142
\texttt{logccdf} & $-\int^x\lambda(s)ds$ & Integrated hazard (negated) \\
143143
\texttt{quantile} & $y = F^{-1}(x)$ & Inverse cumulative distribution function \\
144144
\texttt{cquantile} & $y = F^{-1}(1-x)$ so $x = S(y)$ & Inverse Survival \\
145145
\texttt{invlogcdf} & $x = \ln\:F(y)$ so $e^x = F(y)$ & \\
146-
\texttt{invlogccdf} & $x = -\int_0^y\lambda(s)ds$ & Inverse integrated hazard \\ \hline
146+
\texttt{invlogccdf} & $x = -\int_0^y\lambda(s)ds$ & Inverse negative integrated hazard \\ \hline
147147
\end{tabular}
148148
\caption{This translates between Julia functions and hazard-based notation. We can use this to find
149149
the shortest path to our calculation in code.\label{julia-translation}}
@@ -153,13 +153,13 @@ \subsection{Probability Evaluation}
153153

154154
\texttt{invlogccdf(d::UnivariateDistribution, x::Real)} can be translated into
155155
\begin{equation}
156-
e^x = G(y) = e^{\int_0^y\lambda(s)ds}.
156+
e^x = G(y) = e^{-\int_0^y\lambda(s)ds}.
157157
\end{equation}
158158
That means this function is the inverse of the integrated hazard.
159159
\begin{equation}
160160
x = \Lambda(y)
161161
\end{equation}
162-
In this package, the integrated hazard is called \texttt{logccdf}.
162+
In this package, the integrated hazard is the negative of \texttt{logccdf}.
163163

164164

165165
\section{Next Reaction for Non-Markov Processes}\label{sec:nextreaction}
@@ -644,4 +644,58 @@ \section{Piecewise Deterministic Markov Processes}
644644

645645
Show that a delta-function can be included in CompetingClocks.
646646

647+
\section{Testing Samplers}
648+
649+
\subsection{Doob-Meyer}
650+
651+
Doob-Meyer is a conceptually simple way to test the times from a sampler.
652+
For every draw from a sampler, take the time of the draw and use the currently-enabled
653+
distributions to calculate the cumulant. That is, had you sampled the time
654+
by drawing a value $U ~ [0, 1]$ and using inversion, what would the $U$ have been?
655+
656+
Once you have the transformed values, a Kolmogorov-Smirnov test will compare the
657+
draws with a Uniform distribution and give you a p-value.
658+
659+
You can do this for holding times, $P[T|K]$ for clock $K$, or waiting times, $P[T]$.
660+
661+
\subsection{Mark Calibration}
662+
663+
The mark is the clock chosen for a particular sampling time, so it's $P[K|T]$.
664+
Given a time, this is a multinomial choice. We evaluate the correctness of this
665+
choice using a Brier score. The simpler Brier score is a yes-no choice, so we use
666+
a multi-class Brier score.
667+
668+
The multi-class Brier score is an average of successes. For one draw $j$ from $K$
669+
classes, the Brier score
670+
\begin{equation}
671+
B = \frac{1}{2K}\sum_{k=1}^K (p_k - 1_{k=j})^2
672+
\end{equation}
673+
The $1/2$ in the equation is because a worst score is assigning a 1 where it should
674+
be a 0 and assigning a 0 where it should be a 1, which gives 2.
675+
A lower Brier score is better.
676+
677+
We have a wonky version of this because over the course of a simulation the number
678+
of classes changes. One way to handle that is to not normalize by $K$.
679+
If we label each step of the simulation by $i$, then it's a sum.
680+
\begin{equation}
681+
B = \frac{1}{2N}\sum_{i=1}^N\sum_{k=1}^K (p_{ik} - 1_{ik=ij})^2
682+
\end{equation}
683+
I'm having trouble with notation, but the point is that you put a 1 on the right
684+
when the probability matches the chosen clock.
685+
686+
How do we know when the Brier score is good? There are several approaches, but
687+
one nice approach is to take the known multinomial probability for clocks
688+
at time $t$ and sample from it to get a parametric null. We know that the probability
689+
of choosing clock $k$ at time $t$ is the ratio of hazards, $\lambda_k(t)/\sum_j\lambda_j(t)$.
690+
Draw from this $B=1999$ times and calculate the Brier score for each draw. Keep a running
691+
tally of all 1999 Brier scores as you simulate. At the end, calculate a one-sided
692+
p-value.
693+
\begin{equation}
694+
p_{\mbox{bad}} = \frac{1 + \#\{b: B^{(b)}\ge B_{\mbox{obs}}\}}{B + 1}
695+
\end{equation}
696+
That is, how often is a Brier score for a correct set of draws greater than
697+
the measured Brier score? You want this number to be low.
698+
For our purposes, we would use $1-p_{\mbox{bad}}$ so that a low value is worse,
699+
in line with what we would get from Doob-Meyer and other p-values.
700+
647701
\end{document}

src/sample/pssa_cr.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ end
263263
# Required interface: `fire!` has no internal scheduled time to remove.
264264
# We simply invalidate the cached next event.
265265
function fire!(s::PSSACR{K,T}, clock::K, when::T) where {K,T}
266-
_invalidate!(s)
266+
disable!(s, clock, when)
267267
return s
268268
end
269269

src/sample/rssa.jl

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,6 @@
33
# Exact Rejection-based Stochastic Simulation Algorithm (RSSA)
44
# for continuous-time Markov jump processes (exponential clocks).
55
#
6-
# Algorithmic core:
7-
# - Maintain per-clock *true* rate a_i and a certified upper bound \bar a_i >= a_i.
8-
# - Maintain Ā = sum_i \bar a_i and a Fenwick tree over { \bar a_i } for O(log N) sampling.
9-
# - Draw candidate times from Exp(Ā). Select candidate clock i by Categorical(\bar a_i/Ā).
10-
# - Accept with probability a_i / \bar a_i; otherwise reject and continue thinning.
11-
#
12-
# Exactness: standard thinning of a Poisson process with rate Ā, with acceptance a_i/\bar a_i,
13-
# yields the target Markov jump process (homogeneous propensities). See Thanh et al. (2014, 2015).
14-
#
15-
# Notes:
16-
# - This implementation targets time-homogeneous propensities (Exponential only).
17-
# For time-dependent rates (tRSSA), you need piecewise-time envelopes and an integral sampler;
18-
# these can be added as a thin extension without changing the public interface.
19-
#
206
using Random
217
using Distributions: UnivariateDistribution, Exponential, rate
228

@@ -29,6 +15,20 @@ Rejection-based SSA with global Fenwick tree for candidate selection.
2915
This is for exponential distributions only not time-dependent rates.
3016
- `bound_factor` ≥ 1.0 controls default upper bounds: \\bar a_i ← max(\\bar a_i, bound_factor * a_i).
3117
Set to 1.0 for no rejections (reduces to direct-method timing with tree selection).
18+
19+
# Algorithmic core:
20+
- Maintain per-clock *true* rate a_i and a certified upper bound \bar a_i >= a_i.
21+
- Maintain Ā = sum_i \bar a_i and a Fenwick tree over { \bar a_i } for O(log N) sampling.
22+
- Draw candidate times from Exp(Ā). Select candidate clock i by Categorical(\bar a_i/Ā).
23+
- Accept with probability a_i / \bar a_i; otherwise reject and continue thinning.
24+
25+
Exactness: standard thinning of a Poisson process with rate Ā, with acceptance a_i/\bar a_i,
26+
yields the target Markov jump process (homogeneous propensities). See Thanh et al. (2014, 2015).
27+
28+
Notes:
29+
- This implementation targets time-homogeneous propensities (Exponential only).
30+
For time-dependent rates (tRSSA), you need piecewise-time envelopes and an integral sampler;
31+
these can be added as a thin extension without changing the public interface.
3232
"""
3333
mutable struct RSSA{K,T} <: SSA{K,T}
3434
idx_of::Dict{K,Int} # key → index (stable; indices are never reused)
@@ -141,13 +141,25 @@ function _ensure_index!(s::RSSA{K,T}, key::K) where {K,T}
141141
if idx != 0
142142
return idx
143143
end
144+
145+
# Append new, disabled clock
144146
push!(s.keys_vec, key)
145147
push!(s.present, false)
146148
push!(s.a, zero(T))
147149
push!(s.abar, zero(T))
148150
push!(s.bit, zero(T))
149151
idx = length(s.keys_vec)
150152
s.idx_of[key] = idx
153+
154+
# Rebuild Fenwick tree over current bounds for enabled clocks
155+
fill!(s.bit, zero(T))
156+
for j in 1:idx
157+
if s.present[j] && s.abar[j] > zero(T)
158+
_bit_add!(s.bit, j, s.abar[j])
159+
end
160+
end
161+
# Note: we do *not* touch s.Abar here; it is still the sum of abar over enabled clocks.
162+
151163
return idx
152164
end
153165

@@ -172,20 +184,26 @@ end
172184
function set_global_bound_factor!(s::RSSA{K,T}, bf) where {K,T}
173185
s.bound_factor = convert(T, bf)
174186
s.bound_factor < one(T) && (s.bound_factor = one(T))
175-
# rebuild BIT and Abar
176187
fill!(s.bit, zero(T))
177188
s.Abar = zero(T)
178189
for idx in 1:length(s.keys_vec)
179190
if s.present[idx]
180-
s.abar[idx] = max(s.bound_factor * s.a[idx], eps(T))
181-
_bit_add!(s.bit, idx, s.abar[idx])
182-
s.Abar += s.abar[idx]
191+
if s.a[idx] <= zero(T)
192+
s.abar[idx] = zero(T)
193+
else
194+
s.abar[idx] = s.bound_factor * s.a[idx]
195+
end
196+
if s.abar[idx] > zero(T)
197+
_bit_add!(s.bit, idx, s.abar[idx])
198+
s.Abar += s.abar[idx]
199+
end
183200
end
184201
end
185202
_invalidate!(s)
186203
return s
187204
end
188205

206+
189207
# ---- interface methods ----
190208

191209
# No scheduled times to perturb; just drop cached sample.
@@ -214,7 +232,8 @@ function enable!(s::RSSA{K,T},
214232
s.a[idx] = λ
215233

216234
# Choose a default bound if needed
217-
newabar = max(oldabar, s.bound_factor * λ)
235+
# If λ is zero, force abar to zero to avoid infinite loops in next()
236+
newabar = λ > zero(T) ? max(oldabar, s.bound_factor * λ) : zero(T)
218237
if !old_enabled
219238
# enable
220239
s.present[idx] = true
@@ -259,7 +278,7 @@ end
259278

260279
# After firing, nothing to remove; just invalidate cached sample.
261280
function fire!(s::RSSA{K,T}, key::K, when::T) where {K,T}
262-
_invalidate!(s)
281+
disable!(s, key, when)
263282
return s
264283
end
265284

@@ -275,7 +294,10 @@ function next(s::RSSA{K,T}, when::T, rng::AbstractRNG) where {K,T}
275294
end
276295

277296
t = when
297+
iteration = 0
278298
while true
299+
iteration += 1
300+
279301
# candidate time from Exp(Abar)
280302
Δ = rand(rng, Exponential(inv(s.Abar)))
281303
t += Δ
@@ -286,6 +308,7 @@ function next(s::RSSA{K,T}, when::T, rng::AbstractRNG) where {K,T}
286308

287309
# in case of numerical corner cases, resample
288310
if j < 1 || j > length(s.keys_vec) || !s.present[j] || s.abar[j] <= zero(T)
311+
289312
continue
290313
end
291314

src/trace/track.jl

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,72 @@ end
132132
"""
133133
steploglikelihood(tw::TrackWatcher, now, when_fires, which_fires)
134134
135-
Calculate the log-likelihood of a single step in which the `which_fires`
135+
Calculate the log probability density of a single step in which the `which_fires`
136136
transition fires next. `now` is the current time. `when_fires` is the time when
137137
`which_fires` happens so `when > now`. You have to call this before the transition fires so that
138138
it is before transitions are enabled and disabled from the previous step.
139+
140+
One way to compute a marginal likelihood of a particular clock firing ``P[K]``
141+
is to integrate:
142+
```julia
143+
Using QuadGK
144+
quadgk(t -> exp(steploglikelihood(tw, t0, t, clock)), t0, Inf)[1]
145+
```
146+
It would be slow but could be done.
139147
"""
140148
function steploglikelihood(tw::EnabledWatcher{K,T}, t0, t, which_fires) where {K,T}
141149
_steploglikelihood(values(tw.enabled), t0, t, which_fires)
142150
end
143151

144152

153+
"""
154+
stepcumulant(tw::EnabledWatcher{K,T}, t0, t)
155+
156+
Given a firing time, return the cumulant of the waiting time. Each sample is from a
157+
distributon `P[K,T]` where `K` is the clock. This tells you the cumulant
158+
of the marginal `P[T]`. This calculation is used for a Doob-Meyer test of
159+
sampler correctness. The step-cumulant should be uniformly-distributed.
160+
This value is ``U=1-\\exp(-H)`` where ``H`` is the integrated hazard of the
161+
waiting time.
162+
"""
163+
function stepcumulant(tw::EnabledWatcher{K,T}, t0, t) where {K,T}
164+
@assert t>= t0
165+
return one(Float64) - exp(sum(
166+
function (entry)
167+
t < entry.te && return zero(Float64)
168+
t0 < entry.te && return logccdf(entry.distribution, t - entry.te)
169+
logccdf(entry.distribution, t - entry.te) - logccdf(entry.distribution, t0 - entry.te)
170+
end,
171+
values(tw.enabled)
172+
))
173+
end
174+
175+
176+
"""
177+
stepconditionalprobability(tw::EnabledWatcher, t)
178+
179+
This is the probability that any particular clock fires at a given time, ``P[K|T]``.
180+
This returns a dictionary from clock to probability such that the sum is one.
181+
It is the probability over the space of clocks conditional on the firing time. If
182+
all distributions are Exponential, this won't depend on the time, but in other
183+
cases it will. This is useful for mark calibration testing.
184+
185+
Note that `t0` isn't required because the hazard depends only on enabling times.
186+
"""
187+
function stepconditionalprobability(tw::EnabledWatcher{K,T}, t) where {K,T}
188+
marginal = Dict{K,Float64}(
189+
entry.clock => hazard(entry.distribution, entry.te, t)
190+
for entry in values(tw.enabled)
191+
)
192+
denominator = sum(values(marginal))
193+
denominator == zero(Float64) && return marginal
194+
for k in keys(marginal)
195+
marginal[k] /= denominator
196+
end
197+
return marginal
198+
end
199+
200+
145201
mutable struct MemorySampler{S,K,T}
146202
sampler::S
147203
track::TrackWatcher{K,T}

test/Project.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6"
99
HypothesisTests = "09f84164-cd44-5f33-b23f-e6b0d136a0d5"
1010
InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
1111
LibGit2 = "76f85450-5226-5b5a-8eaa-529ad045b433"
12+
LocalCoverage = "5f6e1e16-694c-5876-87ef-16b5274f298e"
1213
QuadGK = "1fd47b50-473d-5c70-9696-f719f8f3bcdc"
1314
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
1415
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"

test/gauntlet/anderson_darling.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function ad_two_sample(draws_a::Vector{ClockDraw}, draws_b::Vector{ClockDraw}, c
3131
times_b = [x[2] for x in draws_b]
3232
result = KSampleADTest(times_a, times_b)
3333
pv = pvalue(result)
34-
push!(results, (; test="ad-two-sample", clock=0, pvalue, result))
34+
push!(results, (; test="ad-two-sample", clock=0, pvalue=pv, result))
3535

3636
if verbose
3737
ad_diagnostic_report(result)

test/gauntlet/doob_meyer.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function doob_meyer(times::Vector{Float64}, distributions::Vector{DistributionSt
1919
Gamma_0 = Gamma(when)
2020
uniform_draws = similar(times)
2121
for idx in eachindex(times)
22-
uniform_draws[idx] = 1.0 - exp(-Gamma(times[idx]) - Gamma_0)
22+
uniform_draws[idx] = 1.0 - exp(-Gamma(times[idx]) + Gamma_0)
2323
end
2424
test = ApproximateOneSampleKSTest(uniform_draws, Uniform(0, 1))
2525
p = pvalue(test)

0 commit comments

Comments
 (0)