Changelog
Source:NEWS.md
BJM 0.3.0
Breaking changes
-
dynamicPrediction()has been renamed topredictRisk(), to read as a verb-first pair with the newpredictLongitudinal()(below) rather than as one specifically-named function alongside a generically-named one. Existing code that callsdynamicPrediction()positionally or by name needs to switch topredictRisk(); the argument list, return value, and"predictRisk.BJM"(formerly"dynamicPrediction.BJM") class are otherwise unchanged.checkBandcountConvergence()’spredict_funargument acceptspredictRiskin place of the olddynamicPrediction. - The objects returned by
predictLongitudinal()are now classed"predictLongitudinal.BJM"(one biomarker) and"predictLongitudinalAll.BJM"(several biomarkers), formerly"dynamicPredictionBio.BJM"and"dynamicPredictionBioAll.BJM", so the class names match the exported function rather than the internal helper behind it. Code that checksinherits(x, "dynamicPredictionBio.BJM")(or theAllvariant) needs to switch to the new names; the object contents and theprint()/summary()output are unchanged. -
dynamicPredictionBio()is no longer exported:predictLongitudinal()replaces it, with the same arguments (predictLongitudinal(bio_i = 1, ...)predicts one biomarker, asdynamicPredictionBio(bio_i = 1, ...)did). -
printBJM()is no longer exported. Print the two sub-model fits separately instead:print(long_fit_all)andprint(survival_fit_all)(or just type their names at the console).
New features
Interval-censored event times – experimental.
survivalSub()now acceptsSurv(L, R, type = "interval2")outcomes (event known only to lie in(L, R], e.g. between two clinic visits) and fits them with a proportional hazards model by maximum likelihood (new argumentsevent_time,baselineanddf). The baseline is by default a Royston-Parmar spline for the log cumulative hazard in log time (baseline = "spline",df = 3); a piecewise-constant hazard (baseline = "piecewise") is also available, but in simulations its flat hazard within each visit interval biased the imputed event times, and sofitIntervalBJM()’s longitudinal fit, when the true hazard was increasing. Because no event time is known exactly, the longitudinal sub-modelf(Y | T)cannot be fit on the observed events as it is for right-censored data; the newfitIntervalBJM()fillsTin by stochastic EM and multiple imputation, drawing each subject’sTfromf(Y | T) f(T)on(L, R]– the same conditional density dynamic prediction already evaluates – and pools the resultinglongitudinalSub()fits withpoolLongitudinalSub(). Itssurvival_fit_allandlong_fit_allplug intopredictRisk(),predictLongitudinal()andsimulateTrajectory()unchanged. Astrata()term gives each stratum its own baseline hazard, as in a stratified Cox model. Competing risks are supported with the event type taken as known once the event is detected:fitIntervalBJM(form_conditional_cr = )drawsTfromf(Y | T, D) P(D | T) f(T)and refits the event-type model, which usesT, on every draw.performancePlot()evaluates interval-censored fits with the landmark AUC and Brier score of Yang, Rizopoulos, Newcomb and Erler (2026, Biometrical Journal 68:e70108), by default with model-based case and control probabilities (interval_method = "model"), or by IPCW over the subjects whose status in the window is certain ("ipcw"). In simulations the IPCW Brier score was far too low when visits were sparse (about 0.03 instead of 0.13, with visits every 1-3 years), while the model-based measures stayed within about 0.003 of the truth, also with a misspecified survival sub-model;calibrationPlot()estimates each risk group’s observed risk nonparametrically from the intervals (Turnbull-type, left-truncated at the last visit; Hudgens, Satten and Longini, 2001, with competing risks). Because an event is only detected at a visit,predictRisk()andpredictLongitudinal()condition an interval-censored prediction on being event-free at each patient’s last visit rather than atprediction_time, andpredictRisk()also returnsprob_undetected_1(and, with competing risks,prob_undetected_2), the probability that the event already happened in between;simulateTrajectory()requiresprediction_timeto be the last visit.print()of an interval-censored fit reports how many events fell before the subject’s first visit, where the baseline hazard is extrapolated rather than estimated (see?survivalSub). Right-censored fits are unaffected: every prediction helper now reads the survival model through internal accessors, and a new reference test checks their output is unchanged to 1e-12.New
simulateTrajectory()draws complete futures from the fitted backward joint model – an event time, an event type under competing risks, and every biomarker’s values at chosen times – conditional on a patient’s history up toprediction_time, by sampling the event time from its posterior, the random effects from theirs, and the biomarkers given both. A patient with no biomarker measurements is drawn from their baseline covariates alone, as a synthetic patient. Ordinal biomarkers of a Gaussian-copula fit are supported. Event times past the last follow-up time are reported as event-free throughmax_event_time(by default that time) rather than extrapolated. Covariates are carried forward from the patient’s last row, or follow a path given infuture_covariates(e.g. ascites from year 6 on). The draws agree withpredictRisk()andpredictLongitudinal()within Monte Carlo error.New
plot()method forsimulateTrajectory()results draws the simulated biomarker trajectories (sampled paths, median and interval band, and the history conditioned on; category shares for an ordinal biomarker) or the cumulative incidence of each event type from the drawn event times.New
spaghettiPlot()draws each subject’s observed biomarker trajectory, optionally colored by eventual outcome with a smoothed mean per group, and withalign = "event"plots against the time remaining until the event (survival time - time, so the event is at 0), matching the backward model’s view of the biomarker.New
performancePlot()evaluates the dynamic risk predictions at a set of landmark times: at each landmark it predicts every event-free subject’s risk from their history so far withpredictRisk()and plots the time-dependent AUC and Brier score of that risk over the prediction window, with inverse probability of censoring weighting, separately per event type under competing risks. Pass held-out data to estimate out-of-sample performance.New
calibrationPlot()checks whether the predicted risks are numerically right: at each landmark it groups the event-free subjects by predicted risk (deciles by default) and plots each group’s mean predicted risk against its observed risk in the window (one minus Kaplan–Meier, or the Aalen–Johansen cumulative incidence per event type under competing risks), with 95% confidence intervals and the diagonal for reference.New
plot()methods for fitted and predicted objects, each returning aggplot:plot(long_fit_all)draws residual, Q-Q and random-effect diagnostics per biomarker (which = "residuals","qq","ranef") and a heat map of the random-effects correlation across biomarkers (which = "corr");plot(survival_fit_all)draws a forest plot of the hazard ratios (and the competing-risks odds ratios) or, withwhich = "basehaz", the baseline cumulative hazard; andplot(predictLongitudinal(...))draws each patient’s predicted biomarker density with the point prediction marked (category probabilities for an ordinal biomarker).New
cifPlot()plots the Aalen–Johansen cumulative incidence of each event type (one minus Kaplan–Meier for a single event type), with pointwise confidence bands and optional stratification by a subject-level group. It accepts long-format data and either a numeric censoring code orcensor_value = NA(as inpbc3$status4).New
imputeLongitudinal()fills in missing longitudinal biomarker values beforelongitudinalSub()runs, instead of relying on complete-case analysis.longitudinalSub()drops rows missing a biomarker’s own covariates, and then keeps only subjects who have at least one observation of every biomarker – so with irregular/interrupted follow-up (missing at random given the observed covariates and biomarkers), a subject missing just one of several biomarkers at a visit, or missing a biomarker’s measurements entirely, is dropped from every biomarker’s fit, not just the one it is missing.imputeLongitudinal()fits a MIWAE (Missing data Importance-Weighted AutoEncoder; Mattei & Frellsen, 2019) jointly across the supplied biomarkers and draws one or more plausible completions via self-normalized importance resampling; the completed data can be passed straight intolongitudinalSub()in place of the originaldata_fit_all. A second backend, selected withmethod = "diffusion", fits a conditional denoising diffusion probabilistic model (DDPM; Ho, Jain & Abbeel, 2020) instead of a VAE, trained with the same observed-entries-only masking principle as the MIWAE objective (matching MissDiff, Ouyang et al., 2023), and imputes missing cells with a RePaint-style (Lugmayr et al., 2022) reverse- diffusion sampler that reproduces observed values exactly and only extrapolates the missing ones. Both backends share the same call shape, return shape, anddiagnosticsoutput, so switching between them is a one-argument change. Requires the optionaltorchpackage (listed inSuggests, notImports, so installing/using BJM without it is unaffected); every call intotorchis fully namespaced. The returneddiagnosticsalso report a classical single-regression-imputation baseline alongside the deep-generative completions, so all of these can be compared on a given dataset – deep generative imputation needs enough data to fit reliably, and is not automatically the better choice at every sample size.-
New
poolLongitudinalSub()combineslongitudinalSub()fits across the multiple completed datasets returned byimputeLongitudinal(..., impute = "multiple"), using Rubin’s rules (Rubin, 1987) with the Barnard & Rubin- small-sample degrees-of-freedom adjustment.
impute = "single"(the default) fillsdata_fit_allwith the across-draw mean of the generative model’s completions and returns one completed dataset – fast, but fittinglongitudinalSub()on it treats every imputed value as if it had been observed, so the resulting standard errors do not reflect the uncertainty from not actually knowing the missing values. FittinglongitudinalSub()once per completion indata_fit_all_list(impute = "multiple") and combining the fixed-effect estimates withpoolLongitudinalSub()instead propagates that uncertainty into the pooled standard errors, degrees of freedom, and p-values, and reports a fraction-of-missing-information (fmi) per coefficient so it is visible which estimates were affected most by the missingness.print()-ing the result shows a coefficient table per biomarker alongside the FMI values.
- small-sample degrees-of-freedom adjustment.
longitudinalSub()now accepts categorical (binary/ordinal) biomarkers alongside continuous ones, via a Gaussian-copula extension. Every biomarker’s type –"continuous"or"ordinal"– is auto-detected from whether its response column indata_fit_allis a factor, or can be set explicitly with the newbiomarker_typeargument (which always takes priority over auto-detection when supplied). If every biomarker is continuous, fitting is completely unchanged: this is the samenlme::lme()-based code path as before, byte-for-byte. If at least one biomarker is ordinal, it is instead fit as a probit cumulative link mixed model viaordinal::clmm()(requiring the optionalordinalpackage, listed inSuggests, notImports), representing each observed category as an interval on an underlying continuous latent Gaussian score – mathematically the same object as a Gaussian copula linking a discrete margin to the model’s other (continuous or latent) margins. The shared random-effects covariance matrix across every biomarker, continuous and ordinal alike, is then re-estimated jointly by an ECM (Expectation-Conditional-Maximization) algorithm: one new “inner” E-step imputes each ordinal observation’s latent score (its truncated-normal conditional mean given the fitted thresholds and current random-effect prediction) before the existing “outer” E-step/M-step – unchanged fromlongitudinalSub()’s original EM – re-estimates the covariance matrix. This inner-imputation step is a deterministic moment-matching plug-in (not a full Bayesian/MCEM draw), a documented v1 simplification.predictRisk()now also works directly on a fitted mixed-type model: its internal conditional-density calculation dispatches to a Gaussian-copula-aware variant (conditionalYTCopula()/conditionalYDTCopula(), internal, not exported) wheneverlong_fit_all$biomarker_typerecords at least one ordinal marker. Given a patient’s observed continuous values and observed ordinal categories, this variant factors the joint density as the exact multivariate-normal density of the continuous sub-vector times the multivariate-normal box probability (mvtnorm::pmvnorm()) of the ordinal latent scores falling in their category-implied intervals, evaluated at the ordinal block’s distribution conditional on the continuous observations (standard multivariate-normal conditioning). For an all-continuous fit (biomarker_typeisNULL), dispatch falls through unchanged to the originalconditionalYT()/conditionalYDT(), so no existingpredictRisk()behavior is affected.predictLongitudinal()(predicting a future biomarker value, rather than event risk) now also dispatches to copula-aware variants the same way – both its own conditional-density denominator (conditionalYTCopula()/conditionalYDTCopula(), shared withpredictRisk()) and newconditionalYTBioCopula()/conditionalYDTBioCopula()numerators (internal, not exported) – when predicting either a continuous or an ordinal biomarker from a mixed-type fit; the other jointly-fit biomarkers may freely be continuous or ordinal either way. Predicting the future category of an ordinal biomarker needs no separate code path:conditionalYTBioCopula()/conditionalYDTBioCopula()already bracket the candidate category’s row between its cumulative-link thresholds via the samebiomarker_type-driven machinery used for every other ordinal row in the joint density, andpredictLongitudinal()represents an ordinalbio_i’sY_all/Y_predictas integer category codes (1:K, in threshold order) rather than a numeric grid, withY_allcarrying a"category_labels"attribute giving the matching level names;Y_densityis then that category’s predicted probability rather than a density.bandcount3(the candidate-grid resolution) does not apply to an ordinalbio_i, whose grid is fixed at its category count, and is ignored in that case.New
predictLongitudinal()is now the single entry point for predicting future biomarker value(s), replacing the two previously separate functionsdynamicPredictionBio()/dynamicPredictionBioAll()(now internal, un-exported helpers behind it – direct calls to either from existing scripts will need to switch topredictLongitudinal()). Pass abio_inaming exactly one biomarker to predict just that one, which returns a singlepredictLongitudinal.BJMobject; passbio_inaming more than one biomarker, or leave it at the defaultNULL(meaning every biomarker inlong_fit_all), to predict several at once, which instead returns a named list of such objects (one per biomarker, named by that biomarker’s response variable, classedpredictLongitudinalAll.BJM) and computes the bio_i-independent pipeline stages (restricting to at-risk patients, the survival-side integration grid, and the denominator conditional density) only once, reusing it across every requested biomarker instead of recomputing it once per biomarker – redundant work that is especially costly under the Gaussian-copula path above, where that denominator involvesmvtnorm::pmvnorm()Monte-Carlo evaluations.bio_imay name continuous and/or ordinal biomarkers, in any combination.bandcount2(the shared survival grid) is auto-tuned once, from a single representative biomarker, when left at its default"auto", for the multi-biomarker case;bandcount3(each biomarker’s own candidate-value grid) is then auto-tuned separately per biomarker, reusing the already-computed shared stage rather than rebuilding it on every doubling round – except for any ordinal biomarker, whose candidate grid is fixed at its category count rather than controlled bybandcount3, so tuning is skipped for it and the returned"bandcount3"attribute recordsNAfor that biomarker.checkBandcountConvergence()’spredict_funargument now acceptspredictLongitudinal(in place of the now-internaldynamicPredictionBio) for the single-biomarker case.predictLongitudinal()now builds a continuous biomarker’s candidate-value grid (Y_all) in two passes: a cheap coarse pass (step = the biomarker’s residual SD) over the old wide range finds where any patient’s predictive density is not negligible, and thebandcount3points are then placed there only. The grid used to span a fixed 5 times the observed range on either side, so most points (about 90% in a simulated example) fell where every density was practically 0. The samebandcount3now gives a 2-5 times finer grid, and the"auto"starting value ofbandcount3was lowered from 300 to 100.Y_allis still a single grid shared by all patients predicted, so the returned structure is unchanged.
Performance
-
predictRisk()andpredictLongitudinal()evaluate each patient’s joint longitudinal density much faster when there are many biomarkers or many visits. The patient’s covariance (random effects plus residual error, across all biomarkers) is now factored once per patient instead of at every survival-time grid point, and when the patient has at least three observations per random effect it is factored through the Woodbury identity, so the full observations-by-observations matrix is never formed or inverted. The predictions are unchanged up to floating-point rounding (relative differences around 1e-14 onpbc3).
Bug fixes
imputeLongitudinal()now uses the visit time (time_variable) as a covariate of the imputation model wheneverlong_sub_fixeduses it, as documented. It was left out along with the subject id (both serve to align rows across biomarkers), so values were imputed without knowing when they were measured, which pulled a biomarker with a time trend towards its overall level (in a simulated linear trend: correlation of the imputed values with time -0.24 before, 0.99 now).fitIntervalBJM()’slong_fit_all, the fit its predictions use, now combines the imputations – the Rubin’s-rules pooled fixed effects and the averaged residual and random-effects variances (pooled$long_fit_all) – instead of being the first imputation’s fit, so a prediction no longer carries one imputation’s randomness. With competing risks the event-type model insurvival_fit_alllikewise has its coefficients averaged over the imputations. A newn_thinargument (default 5) saves only everyn_thin-th iteration after the burn-in, so the imputations, which come from one chain, are less correlated and the between-imputation variance less understated;n_thin = 1gives the previous behaviour, and the run takesn_burnin + n_imputations * n_thiniterations in all.predictRisk()computes the normalizing denominator as the numerator’s own integral over the prediction window plus the integral beyond it. Before, the denominator was a separate integral fromprediction_timeon, evaluated on its own grid, which discretized the window differently from the numerator: near the jumps of the Cox model’s step-function baseline hazard the two disagreed by several percent, even with fine grids. For pbc3 patient 2 atprediction_time = 5,horizon = 3the risk was 0.263 against 0.280 from a brute-force integral (now 0.281), and atprediction_time = 13the ratio exceeded 1 and was reported as a risk of exactly 1 (now 0.942). Risks change accordingly, typically by a few percent; thebandcount2grid now spans[prediction_time + horizon, upper_bound].riskPlot(),predictPlot(),performancePlot()andcalibrationPlot()use the corrected risks.-
predictLongitudinal()now predicts the biomarker atprediction_time + horizonconditional on survival through that time, as documented (“a future biomarker value conditional on survival”). The event time was integrated fromprediction_timeon, so the prediction also averaged over event times before the biomarker’s own measurement time; it is now integrated fromprediction_time + horizonon, in the numerator and the normalizing denominator alike. Predicted values change accordingly (for pbc3 patient 2 atprediction_time = 5,horizon = 1, the serBilir mode moves from 1.206 to 1.193);predictRisk()is unchanged. For an interval-censored fit the probability of an undetected event beforeprediction_timeno longer enters, since survival past `prediction_time- horizon
rules it out.predictPlot()`’s predicted biomarker curve uses the same computation.
- horizon
longitudinalSub()now rejects a transformed response such aslog(y) ~ ...with a clear error. Such a model used to fit, and then fail inpredictRisk()and the other prediction functions with “undefined columns selected”; add the transformed variable to the data as a column instead. A non-numeric response treated as continuous (e.g. an ordered factor withbiomarker_type = "continuous") is also rejected up front.longitudinalSub()matched subjects across biomarkers byby(..., c(factor)), which before R 4.1.0 grouped by the factor’s integer codes instead of its labels, so with ids other than1..nthe random-effects design of one subject could be paired with another subject’s data. It now groups by the factor itself on every R version.survivalSub()now takes the subjects with an observed event (the data of the competing-risks event-type model) from the evaluatedSurv()status rather than from a column named after its second argument. That failed for e.g.Surv(time, status == 1)(“undefined columns selected”), and with a 1 = censored / 2 = event coding put every subject in the event-type model.The event-type probabilities
P(D | T)are computed withplogis().1 / (1 + exp(eta))andexp(eta)times that gaveNaNonceexp(eta)overflowed (linear predictor above about 709).performancePlot()andcalibrationPlot()leave out, with a message, the at-risk subjectspredictRisk()cannot predict (e.g. no measurement of some biomarker by the landmark). TheirNArisk used to make that landmark’s AUC and Brier scoreNAfor everyone, reported as “no cases or no event-free subjects in the window”.The event-type probabilities
P(D | T)use only the right-hand side ofform_conditional_cr, so the data of the patients to predict no longer need an event-type column (“undefined columns selected” before), and a factor event type no longer warns “invalid factor level, NA generated” on every prediction.With
bandcount1/bandcount2/bandcount3 = "auto", the warning about a patient without a measurement of some biomarker is given once instead of once per tuning round.predictRisk()withhorizon = 0and an interval-censored fit now also returnsprob_undetected_1/prob_undetected_2, as it does for any other horizon; the risk in the empty window is still 0.imputeLongitudinal()checks that torch’s LibTorch backend is installed, not just the torch package, and says how to install it; the torch examples and tests are likewise skipped without the backend.The error for a
long_sub_fixedformula without the survival-time variable now names the biomarker and the variable, instead of “Error: Condition is false”.longitudinalSub()no longer fails whennlme::lme()stops at its default 50 optimizer iterations (“optim problem, convergence error code = 1”), which happened when a random-effect variance was close to zero – e.g. a random slope for data with few, sparse visits per subject. Such a fit is now redone withmsMaxIter = 1000, with a warning; if that fails too, the error suggests a simplerlong_sub_random. Fits that converged before are unchanged, and otherlme()errors are passed on as before.predictPlot()andriskPlot()set line widths withlinewidthinstead ofsize, so they no longer trigger ggplot2’s “Usingsizeaesthetic for lines was deprecated” warning; the plots are unchanged. BJM now requires ggplot2 >= 3.4.0, wherelinewidthwas introduced.Past the last follow-up time, the baseline cumulative hazard used by
predictRisk()/predictLongitudinal()now continues from its last tabulated value with the slope of the least-squares line through the table. It used to switch onto that line itself, which does not pass through the last value, so the cumulative hazard jumped at the last follow-up time: up inpbc3(risks there move by about 1.5%), and down whenever the hazard increases over follow-up (e.g. Weibull data), which gave the interval spanning the last follow-up time a negative probability mass.With competing risks, the event-type variable (the response of
form_conditional_cr) may now appear inlong_sub_fixed, as the backward model allows. A patient still at risk necessarily has an unknown (NA) event type, and thatNAused to be treated as a missing covariate: every such patient was dropped andpredictRisk()/predictLongitudinal()failed with “replacement has 1 row, data has 0”.predictRisk()/predictLongitudinal()now stop with a clear error when no patient indata_predict_allis at risk atprediction_time, or none has a measurement of every biomarker.predictRisk()used to return an empty result without comment andpredictLongitudinal()failed with “‘from’ must be a finite number”.The warning that the integration upper limit was capped (at 20 times the last follow-up time) is now only given when some patient’s conditional survival probability beyond the cap exceeds 1%, and reports how much; it used to fire whenever it exceeded 0.01%, e.g. for ordinary simulated Weibull data. The cap is also never below
prediction_time + horizon.riskPlot()failed to draw (“Discrete values supplied to continuous scale”) for a patient whose survival time is unknown (NA), the usual case for a new patient: the vertical line marking the event time is now left out then.riskPlot()/predictPlot()drew risks on the biomarker’s axis scaled by twice the largest biomarker value, which is negative or zero when every value is<= 0(e.g. a log-scale biomarker such asserBilir), flipping or collapsing the risk axis. They now scale by twice the largest absolute value. Both also failed (“‘max’ not meaningful for factors”) when the plotted biomarker was ordinal; its history is now plotted as the category codes1:KthatpredictLongitudinal()predicts on.With an ordinal biomarker,
predictRisk()andpredictLongitudinal()took each observation’s category code from the prediction data’s own factor levels, not the categories the model was fit with. Dropping unused levels (e.g.droplevels()), reordering them, or passing a character column therefore bracketed the latent score between the wrong thresholds, silently: in one check a risk of 0.53 instead of 0.10. Ordinal responses are now matched to the fitted categories by label, and a value that is not one of them is an error.Under competing risks, a survival time entering a biomarker’s
long_sub_fixedthrough a transformation (e.g.log(years),I(years^2),poly(years, 2)) was evaluated at the first integration grid point for the whole integral, sopredictRisk()andpredictLongitudinal()were wrong for such models (without competing risks they were correct). Plainyearsand interactions such asyears:yearwere not affected.predictLongitudinal()’s candidate grid for a continuous biomarker spans the observed values plus five times their range on either side. With a single observation, or all observed values equal, the range is 0, the grid was a single point, and that point – the observed value – was returned as the prediction. The grid now uses at least the biomarker’s standard deviation in the training data as its span.poolLongitudinalSub()failed (“no applicable method for ‘fixef’”) on fits with an ordinal biomarker. An ordinal biomarker’s thresholds and slopes are now pooled with Rubin’s rules (with an infinite complete-data df, asordinal::clmm()’s inference is asymptotic), and the pooledlong_fit_allcarries them where prediction reads them.longitudinalSub(biomarker_type = "ordinal")failed (“response needs to be a factor”) when the response was numeric, e.g. a 0/1/2 score. A numeric response is now treated as ordered by value.imputeLongitudinal(seed = )seeded only torch, but the MIWAE backend draws its completions with R’s random number generator, so results were not reproducible.seednow seeds both, and restores R’s random number state afterwards so the caller’s own stream is unaffected.imputeLongitudinal()’s documentation now recommendsimpute = "multiple", and explains that the"single"(mean-of-draws) completion and row-by-row imputation tend to understate the residual and random-effects variances, including theSigma_fitused for prediction.The Breslow baseline cumulative hazard was read at the tabulated time nearest to each integration grid point instead of as the step function it is. An event just after
prediction_time(e.g. at 5.002 forprediction_time = 5) was then counted before the prediction window instead of inside it. With few events per unit of time this matters: inpbc3, patient 2’s one-year risk from year 5 rises from 0.037 to 0.039, and the two readings converge to these different values as the grid is refined, so the difference is not discretization error.longitudinalSub()failed with “arguments imply differing number of rows” when a variable used only inlong_sub_randomhad a missing value. Such rows are now dropped, aslme()already did.predictRisk()returned zero risks for a negativehorizon; it is now an error.predictRisk()andpredictLongitudinal()now document that predictions extrapolate the baseline hazard and the longitudinal sub-model beyond the last follow-up time, that ordinal biomarkers add small Monte Carlo variation (useset.seed()for exact reproducibility), and that results depend slightly on which patients are predicted together.predictRisk(),predictLongitudinal(), anddynamicPredictionBio()now drop rows ofdata_predict_allmeasured afterprediction_time(time_variable > prediction_time), with a warning, instead of silently conditioning on them. The documentation always required the history to stop atprediction_time, but nothing enforced it, and the README and vignette quick-start examples themselves passed patient 2’s full history (4 measurements afteryear = 5) to a prediction atprediction_time = 5, which uses information that would not be available at that time. Those examples now subset toyear <= 5.predictPlot()andriskPlot()already truncated per landmark time and are unaffected.predictRisk()andpredictLongitudinal()failed with “wrong sign in ‘by’ argument” when a patient’s survival time was only slightly afterprediction_time(so the baseline-hazard extrapolation grid ended before the last training time). The grid is now only extended when it reaches past the last training time.predictRisk()andpredictLongitudinal()can now predict for a patient whose event time and status are not yet known (NA), as for a genuinely new patient. Previously a missing status gave “non-conformable arrays” and a missing survival time gave “‘to’ must be a finite number”. The marginal survival model now uses only the right-hand side ofform_marginal_surv, and a patient with a missing survival time is treated as at risk (see also the integration upper limit, below).A missing biomarker value (or covariate) in one row of
data_predict_allmade that patient’s whole predictionNA, silently. Each biomarker’s rows with a missing response or covariate are now dropped before predicting, so the prediction conditions on the measurements that were actually observed. A patient left with no observation of some biomarker still cannot be predicted, and a warning now names them.longitudinalSub()failed with “no applicable method for ‘droplevels’” unless the patient id column was a factor (as inpbc3); numeric and character ids now work too. Relatedly,predictLongitudinal()looked patients up by a factor id’s internal integer codes rather than its labels, so with factor ids whose codes differ from their labels (e.g. ids"101","205") it could use another patient’s covariance; it now matches ids by value.Biomarkers can now have different random-effects structures (e.g.
list(~ 1 | id, ~ year | id)), or any random-effects formula, not just “all random intercepts” or “all random intercept + slope ontime_variable”. Previously any other structure failed at prediction time with “non-conformable arguments”. The random-effects design is now built from each biomarker’s ownlong_sub_randomformula.A competing-risks event-type model that does not include the survival time (e.g.
status4 ~ age + sex) made every predictionNA, silently. It is now handled as event type not depending on event time.survivalSub()now also rejects a survival time that entersform_conditional_crthrough a transformation or interaction (e.g.log(years),years:age), which prediction would otherwise have evaluated incorrectly; it must enter as a plain main effect.A stratified Cox model (
strata()inform_marginal_surv) failed at prediction time. Each patient’s marginal survival now uses the baseline hazard of their own stratum.predictRisk()andpredictLongitudinal()no longer use the predicted patient’s own survival time. The denominator integrates over every event time afterprediction_time; its grid used to stop at twice the largest recorded survival time of the patients being predicted – their future outcome, not available atprediction_time. The prediction therefore changed with that recorded time (and for a patient whose event came soon afterprediction_timethe integral stopped early and the risk was inflated several-fold, e.g. 0.70 instead of 0.10). The upper limit is now the earliest time by which every at-risk patient’s model-based probability of still being event-free has fallen below 1e-4, and the grid’s intervals are spaced to hold equal probability mass (so the wider range does not slow convergence inbandcount2) and start atprediction_timerather than half an interval before it. Converged predictions are unchanged in the cases checked; at coarse bandcounts results move by a few percent. Beyond the last follow-up time the baseline hazard is, as before, a linear extrapolation.predictRisk()’s numerator grid integrated over the prediction window plus one extra grid interval (half beforeprediction_time, half afterprediction_time + horizon), inflating risks by roughly1 / bandcount1– about 10-18% atbandcount1 = 10. It now tiles exactly(prediction_time, prediction_time + horizon]. This was the main reasonbandcount1/bandcount2 = "auto"reported “had not converged” on essentially everypbc3prediction; auto-tuned risks now converge and agree with a very fine grid to about 0.5%.predictLongitudinal()’s predicted value (the mode of the predictive density) snapped to thebandcount3grid, so it moved in whole grid steps as the grid changed – 5% off atbandcount3 = 50in the package’s own example – andbandcount3 = "auto"reported non-convergence even on a fine grid. The mode is now refined between grid points (a parabola through the log-density at the peak), and auto-tuning compares a predicted biomarker value on the scale of its predictive standard deviation rather than relative to the value itself (which exploded for values near 0). The predictive density is also no longer passed through the[0, 1]clamp meant for risk probabilities, which would have flattened any density peak above 1.predictRisk()’srisk_prob_1/risk_prob_2andpredictLongitudinal()’sY_predict(andY_density’s columns) are now named by patient id. Only patients still at risk atprediction_timeare predicted, and they used to come back as an unnamed vector, so a patient dropped as not at risk silently shifted every later value.print()/summary()show the ids. Withhorizon <= 0,predictRisk()now returns a zero for every at-risk patient (and for both causes under competing risks) instead of a single0andrisk_prob_2 = NULL.longitudinalSub()estimatedSigma_fitwith a design matrix rebuilt from the raw formula on the subjects retained in the joint fit, rather than with the basislme()was fit with. Forpoly(),splines::ns(),splines::bs()orfactor()terms this gave a different basis (or factor coding) than the coefficients were estimated on whenever some subjects were excluded. The design now reuseslme()‘s own terms and the factor levels of the full fitting data. Consequently such terms in a continuous biomarker’slong_sub_fixedno longer trigger a warning (the training basis is reused both here and at prediction time); the warning now applies tolong_sub_randomand to ordinal biomarkers’ fixed effects, where the design is still rebuilt from the data at hand.The EM estimate of
Sigma_fithad no iteration limit (all-continuous fits), and the mixed continuous/ordinal ECM stopped silently at 100 iterations – before converging on the package’s own example, which needs about 150. They are now capped at 1000 and 500 iterations, with a warning if the cap is reached. A singular random-effects covariance, which is replaced by the identity matrix, is now reported with a warning instead of a bare “An error occurred” message.The baseline cumulative hazard past the last follow-up time is now evaluated directly from its linear extrapolation instead of being tabulated on a fixed grid of step 0.005, which assumed time was measured in years: with time in days, one prediction took about 7 seconds (and the table ran to millions of rows); it now takes the same 0.1 seconds as in years, and gives the same risk.
poolLongitudinalSub()now uses each coefficient’s own complete-data degrees of freedom fromlme()(between-subject covariates such as age have far fewer than within-subject time terms) instead of the intercept’s for every coefficient, which overstated the df of between-subject effects; computes the fraction of missing information with the pooled Barnard-Rubin df, as inmice::pool(), rather than the complete-data df; and returnslong_fit_all, alongitudinalSub.BJMobject built from the pooled point estimates (fixed effects, residual variances, and averagedSigma_fit) that can be passed topredictRisk()/predictLongitudinal().parallelis no longer imported into the namespace (it is only called asparallel::mclapply()).Predictions no longer depend on the biomarkers’ units or break down with many observations. The conditional densities behind
predictRisk()andpredictLongitudinal()carry a factordet(2 * pi * Sigma)^(-1/2)that shrinks rapidly as the biomarkers’ scale or the number of observations grows. In the no-competing-risk case a+ 1e-20added to the denominator to avoid dividing by zero then dominated it: multiplying two biomarkers by 100 (a change of units) took onepbc3patient’s risk from 0.35 to 1e-26. At larger scalesdet()overflowed toInf, giving a risk of 0 (orNaNwith competing risks), and a patient with an outlying value could underflow every density to 0 the same way. The densities are now computed as log densities and exponentiated after a per-patient shift that cancels in every ratio, and the+ 1e-20is gone: the risk is the same in any units (0.3481 from x1 to x1e8 in that example).pbc3results are unchanged.longitudinalSub()underestimated the joint random-effects covarianceSigma_fitwhenever some subject was excluded from the joint fit (a subject is kept only with at least one complete observation of every biomarker – e.g. a subject never measured for one biomarker is dropped). The EM update averaged the retained subjects’E[b b^T]but divided by the number of subjects in the first biomarker’s raw data, and EM compounded the shortfall over iterations: with 50 of 169pbc3subjects never measured for albumin, the diagonal ofSigma_fitcame out at 7%-62% of its correct value, and predicted risks changed by up to a factor of 2 once corrected. Fits in which every subject is retained (including all of the package’spbc3examples) are unchanged.conditionalYT()andconditionalYDT()– the internal density functions behindpredictRisk()’s risk-probability output whenever 2+ longitudinal biomarkers are jointly fit – computed the joint Gaussian density of the stacked observation vector across all markers via a “trace trick”: reducing the quadratic form(Y - mu)' Sigma_all_solve (Y - mu)tosum(diag(...))of a smaller (number-of-markers-sized) matrix. That trace only sums the diagonal (same-marker) blocks of the reduced matrix, which silently discarded every cross-marker contribution ofSigma_all_solve– i.e. it implicitly treated the biomarkers as conditionally independent given the random effects, even thoughSigma_allis deliberately built from the fitted joint random-effects covariance (Sigma_fit) specifically to capture correlation between markers. SinceSigma_fitis generally not block-diagonal across markers, this under- or over-statedpredictRisk()’s risk probabilities whenever the jointly-fit biomarkers had correlated random effects – the ordinary case for a joint model, not an edge case.dynamicPredictionBio()was not affected:conditionalYTBio()/conditionalYDTBio()already computed this density directly viamvtnorm::dmvnorm()on the full covariance. Fixed by computing the quadratic form directly on the full stacked mean/observation vectors instead of via the trace reduction; verified against an independentmvtnorm::dmvnorm()reference on a real two-biomarkerpbc3fit with correlated random effects (test-conditional-density-correctness.R). This changes the numeric value (but not the sign, scale of magnitude, or validity) ofpredictRisk()’s output for any existing multi-marker fit with correlated random effects; the golden-master regression baselines (testdata/baseline.rds,testdata/baseline_noCR.rds) have been regenerated to reflect the corrected values.survivalSub()’s internalcoxph()call did not passx = TRUE, y = TRUE, so the fitted model did not carry its own design matrix and response. Downstream functions that callsurvival::basehaz()orsurvival::survfit()on this fit (e.g.predictRisk()at prediction time) can, in that case, need to reconstruct the model frame by re-evaluating the fit’s captured call – but they do so in the environment of the fit’s formula, i.e. the caller’s environment, notsurvivalSub()’s own execution environment. SincesurvivalSub()passes itsdata_survival_fittingargument tocoxph()by that same name, this happened to work whenever the caller’s data object was also literally nameddata_survival_fitting(as in every example, vignette, and test shipped with the package) and failed withobject 'data_survival_fitting' not foundfor any other variable name. Fixed by passingx = TRUE, y = TRUEto the internalcoxph()call, so the fit is self-contained and this reconstruction is never needed.predictRisk()/dynamicPredictionBio()failed with"non-conformable arrays"when predicting for a subject who had exactly one longitudinal observation to condition on, in a single-biomarker (univariate) model. The internal helpersbuild_conditional_design()(R/conditionalDesign.R) andprocess_variance()(R/processVariance.R) both built the residual covariance piece of the conditional variance asdiag(Sigma_vector), whereSigma_vectorholds one residual variance per observed row for that subject. Base R’sdiag()is ambiguous on a length-1 numeric vector: instead of returning the intended 1x1 diagonal matrix, it interprets the single number as a dimension and returns an NxN identity matrix, which silently corrupted the dimensions of the downstream covariance matrix for exactly this one-observation case (any subject with 2+ observations was unaffected). Fixed by callingdiag(Sigma_vector, length(Sigma_vector))in both places, which is unambiguous for every length, including 1.
BJM 0.2.0
CRAN release: 2026-09-25
Breaking changes
-
All function arguments have been renamed to a single consistent
snake_caseconvention. The package previously mixeddot.case,camelCase, andPascalCasefor the same underlying concepts (and in a few cases used different names for the same concept in different functions). Code that calls these functions using positional arguments is unaffected; code that uses named arguments must update the names. The renames are:Old name New name data.survival.fittingdata_survival_fittingdata.fit.alldata_fit_alldata.plot.alldata_plot_alldata.predict.alldata_predict_alldata.predict.all.one(predictPlot)data_predict_all_onedata.predict.all.pre(riskPlot)data_predict_all_preprediction.timeprediction_timeformMarginalSurvform_marginal_survformConditionalCRform_conditional_crsurvivalVariableAllsurvival_variable_allsurvivalTransFunctionsurvival_trans_functionLongSubFixedlong_sub_fixedLongSubRandomlong_sub_random Removed the
pbc2example dataset. It was never referenced by any exported function, vignette, or test, andpbc3– the dataset actually used throughout the package’s examples and tests – is not a duplicate of it (pbc3recodessex, log-transforms several biomarkers, and adds the competing-risk/transformed-time columnsstatus3,status4,status5, andTyears1-Tyears4). Code that calleddata(pbc2)should switch todata(pbc3)and account for these differences.Removed the exported
print_survivalSub(),print_longitudinalSub(),print_dynamicPrediction(), andprint_dynamicPredictionBio()functions. Each only forwarded to the corresponding S3print.*.BJMmethod, so callingprint()on these objects (or letting them print at the console) is unaffected; only direct calls to the removedprint_*functions need to switch toprint().print_BJM()has been renamed toprintBJM(), since it prints a pair of independently fit sub-model objects together and does not participate in S3 dispatch on a singleBJM-classed object.
New features
-
survivalSub(),longitudinalSub(),dynamicPrediction(), anddynamicPredictionBio()now return named lists (e.g.risk_prob_1/risk_prob_2,Y_predict/Y_density/Y_all) with the fields documented in each function’s@returnblock, instead of anonymous positional lists. Existing code indexing results withx[[1]],x[[2]], etc. continues to work unchanged. -
survivalSub(),longitudinalSub(),dynamicPrediction(), anddynamicPredictionBio()now validate their arguments up front and fail with a specific, actionable message (naming the offending argument) instead of a cryptic error from deep inside model-fitting or indexing code.predictPlot(),riskPlot(), andcmtPlot()got the same treatment. - New
survivalTrans()helper builds thesurvival_variable_all/survival_trans_functionpair directly from a vector of cut points (e.g.survivalTrans(c(1, 3, 5, 7))), instead of requiring two hand-written, easy-to-misalign parallel lists. - Every
data_*_allargument across the pipeline (data_fit_all,data_predict_all, and now alsodata_predict_all_oneinpredictPlot()anddata_predict_all_preinriskPlot()) accepts a single baredata.frame, reused for every biomarker, instead of a repeated list, when all biomarkers share the same measurement data. -
longitudinalSub()now warns when along_sub_fixedformula containspoly()(in its default orthogonal mode),splines::ns(),splines::bs(), orfactor(). These terms recompute their basis/contrasts from whatever data they are given, butdynamicPrediction()/dynamicPredictionBio()rebuild the design matrix from a small, patient-specific slice of data at every point on the internal prediction grid, not the data the model was fit on – so the basis silently disagrees with the one used at fitting time (wrong predictions) ormodel.matrix()fails outright when there are too few distinct values. Usepoly(..., raw = TRUE),I(x^2),log(),sqrt(), or other terms that do not depend on the surrounding data instead. -
bandcount1/bandcount2/bandcount3(indynamicPrediction(),dynamicPredictionBio(),predictPlot(), andriskPlot()) no longer need to be chosen by hand: they now default to"auto"instead of a fixed number. Under"auto", the value is started small and doubled, comparing the returned predictions to the previous round, until the largest relative change drops below 1%, or 2 doublings have been tried (so resolving a bandcount costs at most 3 prediction calls, not an open-ended loop).predictPlot()/riskPlot()resolve their"auto"bandcount(s) once, using a representative probe call, rather than repeating the search on every point in their internalhorizon/landmark-time loop. If a bandcount has still not converged after hitting this cap, a warning reports it and the result at the largest value tried is returned anyway (not an error). Pass an explicit number, as in previous package versions, to skip auto-tuning and use a fixed value instead. - New
checkBandcountConvergence()helper gives direct, manual control over the same doubling check that now runs automatically by default (e.g. to use a tighter tolerance, or more doublings, than the built-in"auto"search): it runsdynamicPrediction()/dynamicPredictionBio()once at the bandcount value(s) you supply and once more with those value(s) scaled up (by default, doubled), and reports the largest relative change in the returned predictions – at the cost of exactly one extra prediction call.
Bug fixes
-
riskPlot()built its internaldata_predict_allaccumulator without initializing it first, so it could silently pick up a leftover object of the same name from the caller’s environment. Fixed to initialize it explicitly, matchingpredictPlot(). - The “wrap a single
data.frameas a list” convenience documented forlongitudinalSub(),conditionalYT(),conditionalYTBio(),conditionalYDT(),conditionalYDTBio(), andprocess_variance()never actually triggered, becauseis.list()isTRUEfor data frames in R. Fixed the guard in all six places to also checkis.data.frame(). -
cmtPlot()’sid_variableargument was dead code: three internal deduplication steps always looked up a literal column named"id_variable"instead of the column named by the argument, so a customid_variablesilently had no effect. Fixed to look up the specified column. -
cmtPlot(condi_time2event = NULL)crashed withobject 'plot_data' not found, because the fallback that picks the midpoint oftime_variablereferenced an undefined variable instead of the actualdata_plot_allargument. Fixed, socondi_time2event = NULLworks as documented. -
dynamicPrediction(),dynamicPredictionBio(),predictPlot(), andriskPlot()only checked that each element ofsurvival_trans_functionwas a function, never that it actually worked. A transform that throws an error, or returns a character value, a vector of the wrong length, or a non-finite value (e.g.log(x)atx <= 0), previously only surfaced as a cryptic failure deep inside the per-patient prediction grid, or, in the non-finite case, as silently corrupted predictions with no error at all. Fixed by test-calling every transform once, up front, on the suppliedprediction_timeand validating its output, before any of the (potentially expensive) prediction machinery runs.
Internal changes
-
longitudinalSub()’s documentation now includes a workedpoly()/splines::ns()/factor()example, matched by an equivalent example in the package’s example scripts, showing that the warning described above is a prompt to double-check the fitted basis, not a sign that predictions from these terms are wrong. - Fixed
pbc3’s documentation, which had copied@usage/@formattags frompbc2(data(pbc2), “20 variables”) instead of describingpbc3itself (data(pbc3), 27 variables); the variable-by-variable\describelist was unaffected and already documented all 27pbc3columns correctly. - Added a
tests/testthatsuite, including golden-master characterization tests captured from the package’s ownpbc3example data, covering both the competing-risk and no-competing-risk code paths. - Extracted shared per-patient design-matrix construction out of
conditionalYT()/conditionalYDT()andconditionalYTBio()/conditionalYDTBio()into internal helpers inR/conditionalDesign.R. - Extracted shared at-risk subsetting, integration-grid setup, and risk-probability clamping logic out of
dynamicPrediction()anddynamicPredictionBio()into internal helpers inR/dynamicPredictionShared.R. - No numeric behavior change is intended by the internal changes in this release; all changes were verified against golden-master baselines captured before refactoring.