Rceattle C++ template
The CEATTLE likelihood: src/TMB/ceattle.cpp and its headers
Loading...
Searching...
No Matches
comp_osa.hpp
Go to the documentation of this file.
1/**
2 * @file comp_osa.hpp
3 * @brief One-step-ahead (OSA) residual helpers for composition data.
4 *
5 * TMB's oneStepPredict() needs every observation in a flat `obsvec`, its likelihood
6 * term multiplied by its own `keep` element, which multivariate discrete
7 * composition data cannot supply directly. Following Trijoulet et al. (2023) and
8 * WHAM (src/age_comp_osa.hpp), a composition of A bins is decomposed into A-1
9 * univariate conditionals -- binomials for the multinomial, beta-binomials for the
10 * Dirichlet-multinomial -- each gated by its keep element. The last bin is fixed by
11 * the sum-to-N constraint, contributes nothing, and is reported as NA on the R side.
12 * With every keep at 1 the conditional log-densities sum to the joint log-density,
13 * so OSA changes how the likelihood is *decomposed*, not its value. The `_osa`
14 * suffix and the data_indicator argument keep these clear of TMB's dmultinom() and
15 * of the ddirmultinom() in helper_functions.hpp that ordinary fitting uses.
16 *
17 * The file also holds the conditional CDFs oneStepPredict(method = "cdf") reads --
18 * continuous binomial for a composition bin, normal / left-truncated normal for an
19 * aggregate series. That residual is Phi^-1(F(x)), formed without a conditional
20 * mean, so it cannot leave the support of a bounded observation.
21 *
22 * Shared contract of the CDF terms below -- the two `_cdf_terms` helpers and
23 * dmultinom_osa()'s `do_cdf` path. Each contributes to the log-likelihood
24 * `keep.cdf_lower` times log F plus `keep.cdf_upper` times log of its complement,
25 * which is 0 whenever the cdf gates are 0: every evaluation except the two
26 * oneStepPredict(method = "cdf") makes per observation. The terms are added only when
27 * the caller asks (`do_cdf`, from osa_mode == 2), so ordinary fitting and the Gaussian
28 * OSA methods are unchanged and pay nothing for them. Every F is mapped onto the open
29 * interval by osa_squeeze_cdf() first, because an exactly 0 or 1 CDF puts a -Inf on
30 * the tape and a -Inf times a zero gate is a NaN.
31 */
32
33// Reorder categories so kept (keep > 0.5) entries come first, preserving the
34// original order within the kept and dropped groups.
35template<class Type>
36vector<int> osa_order(vector<Type> k){
37 int n = k.size();
38 vector<int> ord(n);
39 ord.setZero();
40 int at = -1;
41 for(int i = 0; i < n; ++i){
42 if(k(i) > 0.5){ ord(++at) = i; }
43 }
44 at = n;
45 for(int i = n - 1; i >= 0; --i){
46 if(k(i) < 0.5){ ord(--at) = i; }
47 }
48 return ord;
49}
50
51// Map the closed [0, 1] interval onto the open interval. Identical to TMB's
52// convenience.hpp squeeze() (eps = machine epsilon), which WHAM's OSA composition
53// likelihood uses, so the two agree to the last digit.
54template<class Type>
55Type osa_squeeze(Type u){
56 Type eps = std::numeric_limits<double>::epsilon();
57 return (Type(1.0) - eps) * (u - Type(0.5)) + Type(0.5);
58}
59
60/**
61 * @brief Map a CDF onto the open interval before its logarithm is taken.
62 *
63 * Four machine epsilons wide where osa_squeeze() uses one: oneStepPredict recovers F
64 * from 1/(1 + exp(nlcdf.lower - nlcdf.upper)) in double precision, and at one epsilon
65 * that denominator sits on the round-half-to-even tie above 1 -- where it rounds to 1
66 * so does F, and the residual is infinite. Measured on BS2017SS: 6 of 4538
67 * composition residuals infinite at one epsilon, none at four; the cost is where
68 * |residual| saturates -- 8.04 against 8.13 -- a tail no Q-Q plot resolves.
69 *
70 * It maps rather than clamps, so a caller that forms F by subtracting two CDFs relies
71 * on it to absorb the cancellation: osa_trunc_norm_cdf_terms()'s numerator can round
72 * to about -2e-16 at an observation near zero, which this shift carries back above 0
73 * before the log. Narrowing it would return NaN there.
74 */
75template<class Type>
76Type osa_squeeze_cdf(Type u){
77 Type eps = Type(4.0) * std::numeric_limits<double>::epsilon();
78 return (Type(1.0) - eps) * (u - Type(0.5)) + Type(0.5);
79}
80
81/**
82 * @brief Conditional binomial CDF P(X <= x), X ~ Binomial(n, p), at fractional x.
83 *
84 * Written through the regularized incomplete beta, 1 - I_p(x + 1, n - x), which equals
85 * the binomial CDF at whole counts and stays smooth at the fractional ones composition
86 * data carry: a bin count is proportion * effective sample size, an integer only by
87 * coincidence. Same expression as the Trijoulet et al. (2023) reference implementation
88 * TMB ships (contrib/OSA_multivariate_dists-main/distr.hpp, `dists::pbinom`).
89 *
90 * @param x Count in this bin.
91 * @param n Count still unplaced across this bin and every later one.
92 * @param p Probability of landing in this bin rather than a later one.
93 */
94template<class Type>
95Type osa_pbinom(Type x, Type n, Type p){
96 // n - x is the count left for the later bins. comp_offset (default 1e-5) is added to
97 // every bin, so it is positive unless comp_offset is 0, where pbeta at a zero shape
98 // returns NaN rather than the limit -- and a NaN times a zero gate is still a NaN.
99 Type nx = CppAD::CondExpLt(n - x, Type(1e-12), Type(1e-12), n - x);
100 return Type(1.0) - pbeta(p, x + Type(1.0), nx);
101}
102
103/**
104 * @brief One-step-ahead CDF terms for a normal observation.
105 *
106 * Every aggregate series in CEATTLE is normal on the scale `obsvec` stores it in --
107 * log for lognormal catch and index, natural scale for a "Normal" index, the whitened
108 * L^-1 scale for "MVN" -- so its conditional CDF is `pnorm` of the standardized
109 * residual.
110 *
111 * @param x Observation, on the scale it is stored in.
112 * @param mu Predicted value on that same scale.
113 * @param sd Standard deviation on that same scale.
114 * @param cdf_lower,cdf_upper The observation's `keep.cdf_lower`/`keep.cdf_upper`.
115 */
116template<class Type>
117Type osa_norm_cdf_terms(Type x, Type mu, Type sd, Type cdf_lower, Type cdf_upper){
118 Type F = osa_squeeze_cdf(pnorm((x - mu) / sd));
119 return cdf_lower * log(F) + cdf_upper * log(Type(1.0) - F);
120}
121
122/**
123 * @brief One-step-ahead CDF terms for a normal left-truncated at zero.
124 *
125 * With z = (x - mu)/sd and m = mu/sd, the truncated CDF is
126 * F(x) = [Phi(z) - Phi(-m)] / Phi(m) and its complement is 1 - F(x) = Phi(-z) / Phi(m),
127 * because Phi(m) + Phi(-m) = 1. Both are written in that form rather than as 1 - F, so
128 * the upper tail keeps its precision. This is exact wherever the truncation carries
129 * mass, which is what the Gaussian OSA methods cannot see: the truncation enters the
130 * density only through log Phi(m), a function of the prediction and not of the
131 * observation.
132 *
133 * @param x Observation (natural scale, > 0).
134 * @param mu Predicted index (natural scale).
135 * @param sd Observation standard deviation (absolute, natural scale).
136 * @param cdf_lower,cdf_upper The observation's `keep.cdf_lower`/`keep.cdf_upper`.
137 */
138template<class Type>
139Type osa_trunc_norm_cdf_terms(Type x, Type mu, Type sd,
140 Type cdf_lower, Type cdf_upper){
141 Type m = mu / sd;
142 Type Zm = pnorm(m);
143 Type F = osa_squeeze_cdf((pnorm((x - mu) / sd) - pnorm(-m)) / Zm);
144 Type G = osa_squeeze_cdf(pnorm(-(x - mu) / sd) / Zm);
145 return cdf_lower * log(F) + cdf_upper * log(G);
146}
147
148/**
149 * @brief Keep-aware multinomial log-density via conditional binomials.
150 *
151 * @param x Observed counts (length A; may be non-integer, e.g. Neff * prop).
152 * @param p Predicted proportions (length A; normalized internally).
153 * @param keep Data indicator for these A bins (from keep.segment(...)).
154 * @param do_osa 1 = conditional-binomial decomposition; 0 = plain multinomial.
155 * @param give_log 1 = return the log-density.
156 * @param do_cdf 1 = also add the CDF terms (osa_mode == 2); 0 skips the `pbeta`.
157 */
158template <class Type>
159Type dmultinom_osa(vector<Type> x, vector<Type> p,
160 data_indicator<vector<Type>, Type> keep,
161 int do_osa, int give_log, int do_cdf = 0)
162{
163 Type logres = 0;
164 // Normalize predicted proportions, guarding the denominator so a bin vector with no
165 // predicted mass (sum(p) == 0, e.g. a growth-transition row exactly zero in the
166 // tails) gives p_x == 0 rather than 0/0 = NaN, matching WHAM for vanishing p.
167 vector<Type> p_x = p / (sum(p) + Type(1e-300));
168
169 if(do_osa){
170 vector<Type> k = keep;
171 // The cdf gates are separate data_indicator members, so they are reordered by
172 // hand alongside keep; keep.segment() at the call site already carries them
173 // (tmb_core.hpp), and gating the wrong bin is silent.
174 //
175 // osa_order() branches on keep, a PARAMETER under oneStepPredict, so the sort is
176 // taped once at the initial values -- 1 for every bin that will ever be
177 // residualized or conditioned on -- and later per-observation keep changes do not
178 // re-sort. Within a composition that pattern is 1 on every bin but the last, so
179 // the order is the identity and this reorder is a no-op on every sequence the
180 // package generates. Kept because it is what makes the decomposition right for an
181 // arbitrary keep (test-likelihood-osa-cdf.R supplies one directly).
182 vector<Type> cl = keep.cdf_lower;
183 vector<Type> cu = keep.cdf_upper;
184 vector<int> o = osa_order(k);
185 x = x(o); p_x = p_x(o); k = k(o); // kept bins first
186 if(do_cdf){ cl = cl(o); cu = cu(o); }
187 Type nUnused = asDouble(sum(x));
188 Type pUsed = 0;
189 Type cdf = 1;
190 for(int i = 0; i < x.size(); ++i){
191 if(i != (x.size() - 1)){
192 // Bin i versus all remaining bins: a binomial written as a 2-category
193 // multinomial. Conditional success probability p_i / (1 - pUsed).
194 vector<Type> x2(2), p2(2);
195 x2(0) = x(i);
196 x2(1) = nUnused - x(i);
197 p2(0) = osa_squeeze(p_x(i)) / osa_squeeze(Type(1) - pUsed);
198 p2(0) = osa_squeeze(p2(0));
199 // When one bin holds ~all remaining mass the separately-squeezed numerator and
200 // denominator can push the ratio marginally above 1; clamp so p2(1) = 1 - p2(0)
201 // stays non-negative: dmultinom of a negative probability is log(negative) = NaN.
202 Type eps_clamp = std::numeric_limits<double>::epsilon();
203 p2(0) = CppAD::CondExpGt(p2(0), Type(1) - eps_clamp, Type(1) - eps_clamp, p2(0));
204 p2(1) = Type(1) - p2(0);
205 logres += k(i) * dmultinom(x2, p2, true);
206 // Same conditional binomial the density above scores, read as a CDF.
207 // Taken before nUnused is decremented, so the size matches the density.
208 if(do_cdf) cdf = osa_pbinom(x(i), nUnused, p2(0));
209 nUnused -= x(i);
210 pUsed += p_x(i);
211 // Keep pUsed inside (0, 1) so 1 - pUsed never reaches 0 or goes negative. WHAM
212 // squeezes only 1 - pUsed, so this extra squeeze is redundant protection that
213 // contracts pUsed toward 0.5 by one machine epsilon per bin: a composition of A
214 // bins sits O(A * eps), ~1e-15 relative, from WHAM's value. Removing it would
215 // match WHAM exactly at the cost of re-pinning the OSA references.
216 pUsed = osa_squeeze(pUsed);
217 } else {
218 logres += k(i) * Type(0); // last bin: fixed by sum-to-N
219 cdf = Type(1); // ... so it sits at the top of its own CDF
220 }
221 if(do_cdf){
222 // Onto the open interval before the logs; the width matters (osa_squeeze_cdf()).
223 cdf = osa_squeeze_cdf(cdf);
224 logres += cl(i) * log(cdf);
225 logres += cu(i) * log(Type(1) - cdf);
226 }
227 }
228 } else {
229 logres = dmultinom(x, p_x, true);
230 }
231 return give_log ? logres : exp(logres);
232}
233
234/**
235 * @brief Keep-aware Dirichlet-multinomial log-density via conditional beta-binomials.
236 *
237 * No `do_cdf` counterpart: a beta-binomial has no elementary CDF, and the reference
238 * route -- summing the pmf over 0..floor(x)
239 * (contrib/OSA_multivariate_dists-main/distr.hpp, `pbetabinom`) -- is a step function
240 * of a fractional count costing O(x) beta functions per bin, thousands of times the
241 * multinomial path at CEATTLE sample sizes. So a Dirichlet-multinomial fleet cannot be
242 * residualized with oneStepPredict(method = "cdf"); osa_residuals() sends it to a
243 * Gaussian method instead.
244 *
245 * @param obs Observed counts (length A).
246 * @param alpha Dirichlet-multinomial concentration parameters (length A).
247 * @param keep Data indicator for these A bins.
248 * @param do_osa 1 = conditional beta-binomial decomposition; 0 = plain D-M.
249 * @param give_log 1 = return the log-density.
250 */
251template<class Type>
252Type ddirmultinom_osa(vector<Type> obs, vector<Type> alpha,
253 data_indicator<vector<Type>, Type> keep,
254 int do_osa, int give_log)
255{
256 Type ll = 0.0;
257 if(do_osa){
258 vector<Type> k = keep;
259 vector<int> o = osa_order(k);
260 obs = obs(o); alpha = alpha(o); k = k(o); // kept bins first
261 int dim = obs.size();
262 Type alp_sum = alpha.sum();
263 Type obs_sum = asDouble(obs.sum());
264 for(int a = 0; a < dim - 1; ++a){
265 // Bin a versus all remaining bins: a beta-binomial written as a
266 // 2-category Dirichlet-multinomial (alpha_a vs sum of remaining alphas).
267 obs_sum -= obs(a);
268 alp_sum -= alpha(a);
269 vector<Type> obs_a(2), alphas_a(2);
270 obs_a(0) = obs(a); obs_a(1) = obs_sum;
271 alphas_a(0) = alpha(a); alphas_a(1) = alp_sum;
272 ll += k(a) * ddirmultinom(obs_a, alphas_a, 1);
273 }
274 } else {
275 ll = ddirmultinom(obs, alpha, 1);
276 }
277 return give_log ? ll : exp(ll);
278}
Type osa_pbinom(Type x, Type n, Type p)
Conditional binomial CDF P(X <= x), X ~ Binomial(n, p), at fractional x.
Definition comp_osa.hpp:95
Type ddirmultinom_osa(vector< Type > obs, vector< Type > alpha, data_indicator< vector< Type >, Type > keep, int do_osa, int give_log)
Keep-aware Dirichlet-multinomial log-density via conditional beta-binomials.
Definition comp_osa.hpp:252
Type dmultinom_osa(vector< Type > x, vector< Type > p, data_indicator< vector< Type >, Type > keep, int do_osa, int give_log, int do_cdf=0)
Keep-aware multinomial log-density via conditional binomials.
Definition comp_osa.hpp:159
Type osa_norm_cdf_terms(Type x, Type mu, Type sd, Type cdf_lower, Type cdf_upper)
One-step-ahead CDF terms for a normal observation.
Definition comp_osa.hpp:117
Type osa_trunc_norm_cdf_terms(Type x, Type mu, Type sd, Type cdf_lower, Type cdf_upper)
One-step-ahead CDF terms for a normal left-truncated at zero.
Definition comp_osa.hpp:139
Type osa_squeeze(Type u)
Definition comp_osa.hpp:55
Type osa_squeeze_cdf(Type u)
Map a CDF onto the open interval before its logarithm is taken.
Definition comp_osa.hpp:76
vector< int > osa_order(vector< Type > k)
Definition comp_osa.hpp:36
Type ddirmultinom(vector< Type > obs, vector< Type > alpha, int do_log)