Skip to content

Issue1313 param bounds#1314

Open
bob-carpenter wants to merge 3 commits intoepiforecasts:mainfrom
bob-carpenter:issue1313-param-bounds
Open

Issue1313 param bounds#1314
bob-carpenter wants to merge 3 commits intoepiforecasts:mainfrom
bob-carpenter:issue1313-param-bounds

Conversation

@bob-carpenter
Copy link

@bob-carpenter bob-carpenter commented Feb 11, 2026

Description

This PR closes #1313

Initial submission checklist

  • My PR is based on a package issue and I have explicitly linked it.
  • I have tested my changes locally (using devtools::test() and devtools::check() using Stan).
  • I have added or updated unit tests where necessary.
  • I have updated the documentation if required and rebuilt docs if yes (using devtools::document()).
  • I have followed the established coding standards (and checked using lintr::lint_package() for Stan).
  • I have added a news item linked to this PR.

I made up some data and made sure all three model choices ran without errors and that the data fixes really caught the errors. If there are places to add tests for Stan code like this, I can do that.

After the initial Pull Request

  • I have reviewed Checks for this PR and addressed any issues as far as I am able.

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Fixed parameter bounds constraints in the statistical distribution fitting model to enforce data validity and prevent estimation errors.
    • Enhanced likelihood calculations and per-item contributions across exponential, lognormal, and gamma distributions for more robust parameter inference.
    • Refined prior specifications, constraint validations, and parameter transformations to improve overall model consistency and statistical accuracy.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Fixed bounds constraints on data variables in the Stan model file. Refactored parameter declarations by introducing a raw parameter with [0,1] bounds and transforming it to the required range. Restructured model branches to use per-item likelihood contributions via log-difference of cumulative distribution functions.

Changes

Cohort / File(s) Summary
Documentation
NEWS.md
Added entry documenting the bounds error fix in the Stan distribution fitting model.
Stan Model
inst/stan/dist_fit.stan
Added <lower=0> constraints to lam_mean, prior_mean, and prior_sd data declarations. Replaced unconstrained lambda parameter with lambda_raw constrained to [0, 1] and computed bounded lambda in transformed parameters block. Restructured model blocks to compute per-item likelihood contributions using log_diff_exp for exponential, lognormal, and gamma distributions. Changed priors for alpha_raw and beta_raw to std_normal() and removed unnecessary truncation on sigma prior.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Issue1313 param bounds' is directly related to the main change, which focuses on fixing parameter bounds in the Stan model file as described in issue #1313.
Linked Issues check ✅ Passed All primary coding objectives from issue #1313 are met: positivity constraints added to data declarations [#1313], lambda bounds issue fixed with lambda_raw transformation [#1313], unnecessary truncation removed [#1313], and distribution-choice branching consolidated [#1313].
Out of Scope Changes check ✅ Passed All changes are scoped to issue #1313: parameter bounds fixes, lambda transformation logic, and model structure consolidation in dist_fit.stan, plus a corresponding NEWS.md entry documenting the fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@inst/stan/dist_fit.stan`:
- Around line 42-47: The comments claiming alpha and beta "imply: ... ~
normal(prior_*, par_sigma)" are wrong because alpha_raw and beta_raw are
declared lower=0 and drawn with std_normal(), producing a half-normal(0,1) on
the raw scale; after transform alpha = prior_alpha + par_sigma * alpha_raw (and
similarly for beta) the implied prior is a shifted, scaled half-normal with
support [prior_alpha, ∞), not a symmetric normal. Fix by either (A) updating the
comments next to alpha, beta (and the analogous block using std_normal()) to
state the correct prior: "implies: alpha[1] ~
shifted_scaled_half_normal(prior_alpha[1], par_sigma[1]) with support
[prior_alpha[1], ∞)" (and same for beta), or (B) if a symmetric normal was
intended, remove the lower=0 constraints on alpha_raw and beta_raw so
std_normal() yields a full normal on the raw scale (ensuring any positivity
constraints on alpha/beta are handled differently); adjust the corresponding
sampling statements and comments accordingly.
- Around line 35-40: The code computes lb and ub by dividing by lam_mean[1],
which can be zero due to lam_mean's lower=0 bound and causes division by zero;
update the model to guard against zero by either enforcing a strictly positive
lower bound on lam_mean (e.g., change its declaration to real<lower=1e-10>
lam_mean[...] ) or add a runtime check before computing lb/ub (e.g., when dist
== 0 verify lam_mean[1] > 0 and handle or throw) and ensure any use of
lambda_raw/lambda (symbols lambda_raw, lambda, lam_mean, and the dist branch)
uses the safe nonzero value.
🧹 Nitpick comments (2)
NEWS.md (1)

61-61: Prefer a user-facing description over an internal file path.

The NEWS entry references the internal Stan file inst/stan/dist_fit.stan, which is an implementation detail. Per the coding guidelines, NEWS items should describe user-facing changes. Consider rephrasing to focus on what users of dist_fit() will experience, e.g.:

Suggested rewording
-- Fixed bounds error in parameters for `inst/stan/dist_fit.stan` and added necessary data bounds.
+- Fixed parameter bounds and added necessary data constraints in the `dist_fit()` model, ensuring valid prior and likelihood computations.

As per coding guidelines, NEWS.md: "Describe user-facing changes, never reference issues or PRs."

inst/stan/dist_fit.stan (1)

56-63: Clarify the comment about truncation removal.

Line 58's comment // T[0, ] only needed if loc or scale arguments are params is technically correct — the normalizing constant for truncation at 0 depends only on data (prior_sd[1] and 10), making it a constant that doesn't affect sampling. However, readers could misread "loc or scale" as referring to the lognormal's mu/sigma rather than the prior normal's arguments.

A slightly clearer comment might be:

Suggested wording
-    // T[0, ] only needed if loc or scale arguments are params
+    // T[0, ] omitted: truncation normaliser is constant (args are all data)

The interval-censored lognormal likelihood via log_diff_exp on lines 60–62 is correctly implemented.

Comment on lines +35 to +40
if (dist == 0) {
real lb = 0.2 / lam_mean[1];
real ub = 5 / lam_mean[1];
lambda[1] = lb + (ub - lb) * lambda_raw[1];
// implies: lambda[1] ~ uniform(lb, ub)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Division by zero if lam_mean[1] is exactly zero.

The lower=0 bound on lam_mean (line 6) permits a value of exactly 0, which would cause division by zero here when computing lb and ub. Consider using lower=0 with a runtime check or tightening to a small positive lower bound (e.g. real<lower=1e-10>).

This is likely only a theoretical concern since passing a zero mean for an exponential distribution is degenerate, but it leaves an unguarded code path.

🤖 Prompt for AI Agents
In `@inst/stan/dist_fit.stan` around lines 35 - 40, The code computes lb and ub by
dividing by lam_mean[1], which can be zero due to lam_mean's lower=0 bound and
causes division by zero; update the model to guard against zero by either
enforcing a strictly positive lower bound on lam_mean (e.g., change its
declaration to real<lower=1e-10> lam_mean[...] ) or add a runtime check before
computing lb/ub (e.g., when dist == 0 verify lam_mean[1] > 0 and handle or
throw) and ensure any use of lambda_raw/lambda (symbols lambda_raw, lambda,
lam_mean, and the dist branch) uses the safe nonzero value.

Comment on lines 42 to 47
if (dist == 2) {
alpha[1] = prior_alpha[1] + par_sigma[1] * alpha_raw[1];
beta[1] = prior_beta[1] + par_sigma[1] * beta_raw[1];
// implies: alpha[1] ~ normal(prior_alpha[1], par_sigma[1])
// implies: beta[1] ~ normal(prior_beta[1], par_sigma[1])
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Comments claim normal but effective prior is shifted half-normal.

alpha_raw and beta_raw are declared with lower=0 (lines 26–27), and the sampling statements use std_normal() (lines 65–66). This gives a half-normal(0, 1) prior on the raw parameters, not a standard normal. After the transform alpha = prior_alpha + par_sigma * alpha_raw, the implied prior on alpha is a shifted, scaled half-normal with support [prior_alpha, ∞), not a symmetric normal(prior_alpha, par_sigma) as the comments on lines 45–46 state.

If the asymmetric (half-normal) prior is intentional, the comments should be corrected:

Suggested fix
-    // implies: alpha[1] ~ normal(prior_alpha[1], par_sigma[1])
-    // implies: beta[1] ~ normal(prior_beta[1], par_sigma[1])
+    // implies: alpha[1] ~ prior_alpha[1] + par_sigma[1] * half_normal(0, 1)
+    // implies: beta[1] ~ prior_beta[1] + par_sigma[1] * half_normal(0, 1)

If a symmetric normal was intended instead, the lower=0 constraints on alpha_raw/beta_raw should be removed (and positivity of alpha/beta ensured via rejection or a different parameterisation).

Also applies to: 64-66

🤖 Prompt for AI Agents
In `@inst/stan/dist_fit.stan` around lines 42 - 47, The comments claiming alpha
and beta "imply: ... ~ normal(prior_*, par_sigma)" are wrong because alpha_raw
and beta_raw are declared lower=0 and drawn with std_normal(), producing a
half-normal(0,1) on the raw scale; after transform alpha = prior_alpha +
par_sigma * alpha_raw (and similarly for beta) the implied prior is a shifted,
scaled half-normal with support [prior_alpha, ∞), not a symmetric normal. Fix by
either (A) updating the comments next to alpha, beta (and the analogous block
using std_normal()) to state the correct prior: "implies: alpha[1] ~
shifted_scaled_half_normal(prior_alpha[1], par_sigma[1]) with support
[prior_alpha[1], ∞)" (and same for beta), or (B) if a symmetric normal was
intended, remove the lower=0 constraints on alpha_raw and beta_raw so
std_normal() yields a full normal on the raw scale (ensuring any positivity
constraints on alpha/beta are handled differently); adjust the corresponding
sampling statements and comments accordingly.

@github-actions
Copy link
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if e1294f8 is merged into main:

  • ✔️default: 24.1s -> 23.5s [-11.18%, +6.08%]
  • ✔️no_delays: 33.9s -> 25.8s [-72.88%, +24.92%]
  • ✔️random_walk: 26.2s -> 9.64s [-159.74%, +33.47%]
  • ✔️stationary: 15.2s -> 13.3s [-51.29%, +25.3%]
  • ✔️uncertain: 52.2s -> 35.2s [-95.69%, +30.67%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

@bob-carpenter
Copy link
Author

A test is now failing because the Stan program is being given a negative standard deviation as data, and I have now included a lower=0 constraint on the data variable declaration, which checks that standard deviations are positive on ingestion.

This is really impressive test coverage!

@github-actions
Copy link
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if 01a0ea1 is merged into main:

  • ✔️default: 22.8s -> 22.7s [-10.63%, +9.56%]
  • ✔️no_delays: 24.5s -> 24.5s [-9.52%, +9.56%]
  • ✔️random_walk: 11.5s -> 20.6s [-138.94%, +297.16%]
  • ✔️stationary: 12s -> 12.6s [-17.94%, +26.96%]
  • ✔️uncertain: 39.4s -> 36.5s [-22.21%, +7.67%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

@bob-carpenter
Copy link
Author

bob-carpenter commented Feb 12, 2026

[edit: finishing---didn't mean to submit earlier.]

Here's the error that's coming up from the unit tests after I enforced a lower bound of 0 for a variable called prior_sd.

Error : Exception: model_dist_fit_namespace::model_dist_fit: prior_sd[1] is -0.0196371, but must be greater than or equal to 0.000000 (in 'dist_fit', line 8, column 2 to column 41)

This prior_sd is being used like a prior scale, so I had assumed it should be constrained to be positive.

Let me try to summarize the statistical problem with the model as it currently exists:

https://github.com/epiforecasts/EpiNow2/blob/main/inst/stan/dist_fit.stan

There are data declarations here that do not constrain prior_mean or prior_sd to be positive (the dist > 0 is just choosing which distribution to implement). I'll cut down to just the beta case.

data {
  array[dist > 0] real prior_mean;
  array[dist > 0] real prior_sd;
  array[dist == 2] real<lower = 0> par_sigma;
  ...
transformed data {
  array[dist == 2] real prior_beta;
  if (dist == 2) {
    prior_beta[1] = prior_mean[1] / prior_sd[1]^2;
  }
  ...
parameters {
  array[dist == 2] real<lower = 0> beta_raw;

  ...
transformed parameters {
  array[dist == 2] real<lower = 0> beta;

  if (dist == 2) {
    beta[1] = prior_beta[1] + par_sigma[1] * beta_raw[1];
  }
  ...
model {
  beta_raw[1] ~ normal(0, 1);
  ...
        gamma_lcdf(up[i] | alpha, beta),
  ...

Working backwards, we need beta[1] to be positive because it's a requirement of gamma distribution parameters. Therefore, we need

prior_beta[1] + par_sigma[1] * beta_raw[1] > 0

par_sigma[1] * beta_raw[1] > -prior_beta[1]

beta_raw[1] > -prior_beta[1] / par_sigma[1]

Because beta_raw[1] > 0 by construction and par_sigma[1] > 0 by constraint on the input, as long as prior_beta[1] > 0, this is guaranteed. But if it's not, then we should really be constraining beta_raw to satisfy this constraint.

The usual way I see something like this done is to not constrain prior_beta[1] or beta_raw[1], but to add a log link, so that you have the inverse:

beta[1] = exp(prior_beta[1] + par_sigma[1] * beta_raw[1]);

As CodeRabbit pointed out, it's not really clear what distribution is being implied for beta[1] and alpha[1] under this transform due to the constraints. If everything is positive, the minimum values they can take will be prior_beta[1] and prior_alpha[1], so it's not quite a half normal.

@sbfnk
Copy link
Contributor

sbfnk commented Feb 13, 2026

Thanks - moving this to be modelled with a log link makes sense to me. Just noting that we're planning to remove this model in the not-too-distant future anyway (will put a comment on the linked Issue).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stan/dist_fit.stan missing constraint, out-of-range construct, unnecessary truncation

2 participants