Changelog
Source:NEWS.md
TemporalHazard 1.2.8
New features
-
New
hzr_theta_names()returns the names of a multiphasethetavector, in the orderthetarequires, before any fit runs. Use it to check a hand-written starting vector against the specification it belongs to:phases <- list(early = hzr_phase("cdf"), late = hzr_phase("g3")) stopifnot(length(theta0) == length(hzr_theta_names(phases))) setNames(theta0, hzr_theta_names(phases))thetais positional and its entries are not on a common scale – the late phase logsmuandtaubut carriesgamma,alphaandetanaturally – and wrapping the wrong element inlog()produces a fit, not an error. A comment describing the order is therefore not enough for a template that ships to many studies, which is what prompted this: the order is a property of the phase specification and changes the moment an author adds, removes or retypes a phase.The function is a thin wrapper over the naming the optimizer itself uses, not a second implementation of it.
hazard()’s optimizer, the score test’s re-expansion and this function now all go through one internal helper, so the order documented here cannot drift from the order a fit produces — a test asserts the two are identical for three different phase specifications. Phase validation is shared too, so unnamed phases get the samephase_1/phase_2labels a fit will give them. -
hzr_stepwise()’s$stepsframe gains astat_typecolumn, saying what thestaton that row is and so which reference distribution recomputes its p-value:"score_q"(chi-square ondf),"wald_z"(standard normal) or"wald_chisq"(chi-square ondf).dfcould not tell these apart. A scalar Wald is reported as a z, not as its square, so it and a score Q are both recorded atdf = 1while calling for different distributions. The gap was widest on a candidate rescued by the Wald fallback added in 1.2.7: that row carries a Wald z undercriterion = "score", where every neighbouring row carries a Q. The selection was right andp_valuewas right, but a reader recomputing a p-value fromstatthe way the neighbouring rows permit got an answer wrong by dozens of orders of magnitude. Undercriterion = "score"the rows reading"wald_z"are exactly the ones the fallback rescued. hzr_bootstrap()reports Wald fallbacks in select mode, through$n_wald_fallback_replicatesand$n_wald_fallbacks, with a warning when either is non-zero. Every replicate runs undersuppressWarnings(), so a run in which the fallback fired throughout previously reported nothing at all – and the bootstrap is where a wholesale substitution matters most, since those entries drive the pooled selection frequencies.The nomogram parser accepts
lines =as well aspath =. A multi-fit listing needs each nomogram attributed to the fit whose block contains it, not to the file. Previously a caller who had already split the listing by fit had to write each block back out to a temporary file to parse it, which is a workaround rather than an interface. Passing the lines directly now works, and is what makes the multiple-nomogram warning actionable.
Bug fixes
-
A candidate that neither criterion could test is now reported as such.
criterion = "score"declines a candidate whose observed information is indefinite atbeta = 0– which happens when the effect is large – and then refits it and tests it by Wald instead. That rescue can converge, producing a perfectly good point estimate, while its Hessian is singular: no standard error, so the Wald test cannot be computed either.The fallback dropped that case silently. The row kept the score’s reason,
information_indefinite, which describes the first of two independent failures and says nothing about the second, and no counter moved. A strongly predictive variable vanished and the screen rendered as an honest “nothing metslentry” – the same failure this package has twice fixed elsewhere, where a clean-looking screen and an honest null result cannot be told apart.Such rows now report
fallback_no_variance, distinct frominformation_indefinite(the rescue errored or did not converge, and is listed in$criteria$refit_failures).hzr_stepwise()andhzr_bootstrap()both warn on either, saying the candidate was tested by neither criterion and which mechanism applied. Theinformation_indefiniteprose claimed “that refit also failed”, which was wrong for the new case and sent readers to an emptyrefit_failures; both it and the bootstrap warning now describe the two mechanisms separately.No behaviour changed in what gets selected: a candidate that could not be tested is still not entered. What changed is that the run says so.
-
fit$weaknow distinguishes “no ridge” from “not checked”. The weak-identification diagnostic introduced in 1.2.7 returnedNULLboth when a fit had been examined and found well identified and when it could not be examined at all – most importantly when no Hessian was available, which happens on an install without the suggested numDeriv package and on fits whose rows are left- or interval-censored, where the analytic Hessian declines by design.NEWSofferedfit$weakas the programmatic check, so on those installs it certified as well identified a fit nothing had looked at.The field now takes three values: a list when a ridge was found,
NULLwhen the fit was examined and is well identified, andNAwhen the check could not run. Test it withis.list(fit$weak)rather than!is.null().summary()prints a note in theNAcase saying the check did not run, and a fit imported withhzr_read_outhaz()– which has no R Hessian to examine – now reportsNArather than reading as certified clean. A fit with more than one flat direction says so. The detector reported a single direction, which invited reading every parameter it did not name as identified. It now reports
n_directions, the number of distinct near-flat directions found, and the warning says when there is more than one. Distinctness is counted over the parameters spanning each direction, not over eigenvectors: a ridge between two parameters clears the gate twice, once on the flat direction and once on its stiff partner, so counting eigenvectors would report one ridge as two.A rescued candidate that goes on to win is no longer fitted twice. The Wald fallback refits each candidate it rescues, and the acceptance step then refit the winner again with identical arguments. The two fits were bit-identical, so this was cost rather than incorrectness – but it doubled the price of every accepted fallback entry, against a criterion whose whole advantage is that it does not refit per candidate. The rescuing fit is now kept and reused, as the Wald path already did with its candidate fits.
-
DELTAis not implemented, and two comments said it was absorbed. The headers ofR/decomposition.RandR/argument_mapping.Rboth stated that the CDELTAparameter’s time transformationB(t) = (exp(delta * t) - 1) / deltais “absorbed bydecompos()”. It is not absorbed; it is unimplemented, anddelta = 0is assumed.DELTAenters the C reference in three separate places – it buildsrhofromB(t_half)rather thant_half, it replaces the time argument withB(t), anddelta * tenters the log-density additively so the density carries a factor ofexp(delta * t)– and R computes thedelta = 0branch of all three.The comment was the harmful part. It made the omission look deliberate and safe, so a reader looking for exactly this discrepancy was told to stop looking, while a
PROC HAZARDjob withDELTA != 0was reproduced against a different function with no error. Both comments now say what is true.The SAS-facing paths now distinguish the two cases rather than treating
DELTAas one unmapped keyword.hzr_read_outhaz()already stopped on a non-zeroDELTA;hzr_translate_sas()now recordsPARMS DELTA = <nonzero>as untranslated with a reason saying the emitted call fits a different model, and the.lstnatural-estimates parser warns when a listing carries one.DELTA = 0and a bareFIXDELTAare treated as faithful translations, because that is the branch R implements – previously both the safe and the unsafe case produced the identical generic note “PARMS keyword has no phase target”, which distinguished nothing. -
A multiphase fit now records whether Conservation of Events was actually applied. CoE counts exact events, so it is disabled whenever any
statusfalls outside {0, 1} – which interval or left censoring guarantees – and whenever the model has fewer than two phases. That is deliberate and correct. What was missing is that nothing on the returned object said it had happened: a caller who passedcontrol = list(conserve = TRUE)got a fit carryingconserve = TRUEover a computation that did not run.It is not an edge case.
ICENSORappears on 42 to 74 blocks per production study, andICENSORguaranteesstatusleaves {0, 1}, so the auto-disable fires constantly. Production also writesNOCONSERVEon 16 to 68 blocks per study, so R and SAS usually agree on the outcome – but for different reasons, and a job carrying bothCONSERVEandICENSORis exactly where the two could diverge unobserved.A fitted multiphase object now carries two new fields, both alongside the requested
conserveunderfit$spec$control:-
fit$spec$control$conserve_applied– logical, whether CoE was actually applied; -
fit$spec$control$conserve_disabled_reason– one of"not_requested","unsupported_censoring","single_phase","no_events"or"setup_failed", andNAwhen CoE was applied.
Read
fit$spec$control$conserve_applied, notfit$spec$control$conserve: the latter says only what you asked for.conserveis adist = "multiphase"control; the single-distribution fits do not use it.The reason is recorded rather than a bare logical because the causes want different responses – and a bare
FALSEreads as “you turned it off” to a user who did the opposite. -
-
The SAS
.lstnomogram parser no longer returns the first of several tables silently..hzr_parse_sas_nomogram()matched every nomogram header in a listing and read only the first, with no warning and nothing in the return value to say a second existed. That is the same shape as the three layout defects fixed in 1.2.1 – silent, and discoverable only by pointing the parser at a second study.It now warns when a listing holds more than one, and attaches
n_foundto the returned frame whatever the count, so a caller can distinguish “one nomogram” from “the first of several” without reading the source. The behaviour is otherwise unchanged: the first table is still what comes back.Every file in the corpus this was found against happens to print exactly one nomogram, so the parser got the right answer there – by luck of the corpus rather than by construction.
Documentation
-
predict()andhzr_nelson()now say that SAS draws narrower bands. SAS%KAPLAN,%NELSONTandPROC HAZPREDall take their band width fromCLEVEL, whose default is0.68268948– documented in the macro source as “(1 sd)”. That makes the multiplier1to seven decimals – the literal is truncated – so the band is one standard error, 68.3%, not 95%.Nothing here computed the wrong thing: parity is tested and passing, and
hzr_kaplan()already documented the convention. The gap was that the other three entry points did not, and they are the ones a reader meets when checking an R fit against an existing SAS figure. At the R default oflevel = 0.95the reproduced band is about 1.96 times wider than the one being checked against, with no error on either side – so the two look like they disagree numerically when they do not.The defaults are unchanged.
0.95is the right R-side default, and adopting SAS’s silently would makepredict()disagree with every other R modelling function. The help pages now carry the level to pass instead:level = 2 * stats::pnorm(1) - 1. summary()’s documentation and the Inference and diagnostics vignette both listed the notes the method prints and had not been updated for the ridge note. Both now include it, and the vignette says plainly that a flat direction means the point estimates along it are unreliable, not only their standard errors.hazard()now states that SAS’sSTEEPESThas no equivalent.PROC HAZARDjobs writeSTEEPEST QUASItogether – steepest descent, then quasi-Newton – andSTEEPESTappears 14 to 109 times per study across the corpus.QUASI/QUASINEWTONmaps tomethod = "bfgs"; there is no steepest-descent option and no two-stage strategy. Since the multiphase likelihood is multimodal, a different descent path can land on a different optimum, so a fit translated from such a job may not reproduce SAS’s estimates.hzr_translate_sas()already recorded the keyword as untranslated rather than dropping it; thecontrol$methoddocumentation now says why.
Internal
Two tests in
test-score-wald-fallback.Rdid not catch the mutations their comments named. Widening.hzr_score_fallback_reasonsto includeconstantandcollinearleft both green, because a degenerate candidate still fails to enter – it merely costs a refit on the way out – and the “noise stays out” assertion is guarded byslentryrather than by how narrow the fallback is (the fixture’s own Wald p-values are 0.0997 and 0.149, so a fallback that refit everything would still decline both). Both now assertn_wald_fallbacks, which is the quantity that moves.A roxygen block in
R/score-test.Rbound to the character vector declared after it rather than to the function it documents.@noRd, so no Rd was affected; source readability only.
TemporalHazard 1.2.7
New features
-
A fit sitting on a likelihood ridge now says so, and names the parameters. An ill-conditioned Hessian already warned that standard errors were unreliable. That understates the problem when the ill-conditioning is a ridge: the likelihood is near-flat along some combination of parameters, and there the individual point estimates are not determined by the data either – only the combination is. The fit still reports
converged, and the coefficient table still prints a number for every parameter, so nothing on the object signalled it.hazard()now warns, once per fit, naming the parameters that span the flat direction along with their correlation and the Hessian’srcond, andsummary()prints the same note. The finding is recorded on the object asfit$weak, so it can be checked programmatically rather than scraped from a warning. See the 1.2.8 notes below for the three values that field takes.The direction is read off the correlation of the estimates rather than their raw covariance. Parameters here sit on very different scales – an
mof 27 against anuof 0.027 – and in raw units a direction that moves both equally in statistical terms loads almost entirely on the larger one, which would report a two-parameter ridge as a single unidentified parameter. A parameter that is merely imprecise, without trading off against another, is deliberately not reported: that is ordinary low precision, and the existingrcondwarning and the parameter’s own standard error already cover it.The check is generic – it runs for every distribution and knows nothing about phase shapes – and is gated on the
rcondthreshold the package already uses, so it never fires where the ill-conditioning warning stays silent.
Bug fixes
-
The score criterion no longer declines a candidate for being too predictive.
criterion = "score"computes SAS HAZARD’s Q exactly –Q = grad^2 * I22, the reciprocal Schur complement of the observed information atbeta = 0(src/vars/q1.c). When a candidate’s true effect is far from zero the log-likelihood is convex there, the Schur complement turns negative, and Q is undefined. The criterion therefore declined candidates in proportion to how predictive they were: on a fixture with one planted effect (beta = 0.9, LR = 178) and two pure-noise columns, the screen entered both noise columns and never tested the real one (#130).This is not a deviation from the reference – it is inherited from it. SAS documents the same failure in
q1.c(“IT IS POSSIBLE THAT THE PROGRAM WILL RETURN A NEGATIVE Q VALUE … THE USER SHOULD USE THE MORE EXPENSIVE Q2 AS AN ALTERNATIVE”), anddqstat.cdeclines the candidate withp = 1.Q2is named once in the C tree and never implemented.So Q itself is unchanged and stays bit-faithful; only the handling diverges, and only where SAS says its own answer is unusable. A candidate the score cannot test is now refit and tested by Wald – the substitute the unbuilt
Q2was for. This is a deliberate, documented divergence from the reference implementation.The fallback is deliberately narrow: it applies to
information_indefiniteandcoefficient_diverging, the two causes that mean “the approximation at zero broke down”. Collinear, constant and non-numeric candidates are still declined without a refit, so the screen keeps the speed advantage that the score criterion exists for – the cost is paid only on the few candidates that trip it. The returned object’s$criteriagainsn_wald_fallbacks, so the substitution is reported rather than silent, and a fallback refit that fails is recorded in$criteria$refit_failuresand warned about rather than leaving a row indistinguishable from one never refit.
TemporalHazard 1.2.6
Bug fixes
.hzr_parse_sas_nomogram()no longer discards a nomogram whose PROC PRINT counter column is labelledOBSrather thanObs(#184). The counter was dropped by exact name, so on the other casing it stayed among the header names, the row-width guard rejected every data row, and the parser returnedNULL– indistinguishable from a listing that printed no nomogram at all. A corpus sweep therefore reported its own parse failures as gaps in the SAS output. The counter is now identified structurally, as a leading column running exactly1..n, so any label parses..hzr_parse_sas_nomogram()now warns rather than returningNULLin silence when a nomogram header matched but no data rows could be read.NULLagain means “no such table”, and only that.
TemporalHazard 1.2.5
New features
-
A multiphase fit now warns when a phase has effectively left the model. Such a phase is silent in every other way: the fit converges, reports no trouble, and the affected parameters simply drift.
Two modes are distinguished, because the consequences differ. A phase that is absent – contributing essentially none of the cumulative hazard at any observed time – has not started by the end of follow-up, and neither its
munor its shape is identified. A phase that is saturated – one whose contribution is constant across the observed times, typically acdfphase whose half-life is far shorter than the first observation – has already finished, and then acts as a constant offset: itsmustays well identified while the shape parameters (t_half,nu,m) go exactly flat. Pinning those at any value leaves the log-likelihood unchanged.The distinction is the point. It is tempting to describe a phase that supplies no late hazard as one whose
muhas stopped being identified;muis in fact the one parameter that survives, through the offset the phase already contributed. The share is measured against the cumulative hazard rather than the instantaneous hazard for the same reason.The shares are recorded on the fit as
fit$phase_share, so the warning can be checked rather than taken on trust, and the threshold iscontrol$phase_share_tol(default 1e-8).
TemporalHazard 1.2.4
New features
-
hazard()gains anobjectiveargument. The default,"likelihood", is unchanged: interval-censored rows contribute the interval probabilitylog(S(l) - S(u)). The new"sas"reproduces whatPROC HAZARDactually accumulates for such a row – the ordinary event-density term with the instantaneous hazard replaced by the interval-mean hazard over (l, u]:d * log[ S(u) * (Lambda(u) - Lambda(l)) / (u - l) ]which makes the three row types one family: right-censored contributes
log S(u), an exact eventlog S(u) + log h(u), and an interval-censored rowlog S(u) + log h_bar(l, u]. Exact-event and right-censored rows are untouched by the switch, and it applies only todist = "multiphase".This is a different estimator, not a reparameterization, and must not be used for new analyses. It is a density, not a probability, so it is inconsistent for wide intervals – on a 12-year-interval reference fit the two forms differ by 22 log-likelihood units. It exists to reproduce legacy
PROC HAZARDruns, and it is deliberately an explicit top-level argument rather than acontrolelement, because it changes the estimand.Interval-censored rows with
u <= l, and any left-censored row, are errors under"sas"rather than silently-substituted values:PROC HAZARDhas no left-censoring statement, so no SAS run corresponds to such a result. New dataset
uslife2023: the NCHS United States life table for 2023 on a synthetic 100,000 radix, 124 rows, every one interval-censored and exactly one year wide. Published aggregate counts only. It is the reference fixture forobjective = "sas", which reproduces its SAS log-likelihood of -410414 at the printed estimates and at three off-optimum points of SAS’s own iteration trace.
Internal
- The interval-censored contribution was written twice – once in the log-likelihood and again in the finite-difference closure inside the gradient. Those copies had to agree or the optimizer would step by the gradient of a different objective than it evaluated. Both now delegate to a single
.hzr_logl_interval(). Behavior under the default is unchanged and bit-identical, log-likelihood and gradient alike.
TemporalHazard 1.2.3
Bug fixes
-
A logical column no longer stops a stepwise screen dead.
hzr_stepwise(scope = NULL)enumerates its own candidates and counts logical columns among them, on the grounds that a 0/1 field arriving logical rather than numeric is a property of the reader that produced the frame, not of the variable. Both criteria then refused what the package had offered: the score criterion errored with “is not numeric (logical)”, and the Wald criterion failed looking upphase.varwhenmodel.matrix()had named the columnphase.varTRUE. Either way the screen stopped before its first step, on a column nobody had chosen by hand.A logical candidate is now modelled as the 0/1 predictor it is, and gives the same screen as the identical numeric column: same variables entered, in the same order, at the same p-values, with the same coefficients.
The coefficient-name half of this also fixes a two-level factor, which expands the same way (
varb). A candidate that expands to more than one column is still refused, unchanged.That makes
criterion = "wald"a real answer for a two-level factor or character column named in an explicitscope, which the score criterion still cannot expand. Its refusal used to say switching criterion would not help, and now points at it instead.
TemporalHazard 1.2.2
New features
-
hazard()’s vector interface now evaluatestime,status,time_lower,time_upperandweightsindata’s scope.hazard(data = df, time = tt)previously failed withobject 'tt' not found:datawas consulted only by the formula path, and the vector path accepted it and ignored it. The rule issubset()’s – a column ofdatawins, anddf$col, a local vector or a literal falls through to the calling frame unchanged – so withdata = NULLnothing changes and the formula path is untouched.Because a column winning can silently redirect a wrapper that forwards its own argument by name,
hazard()now warns, once per call, when a symbol is both a column ofdataand visible from the calling frame – that frame or a lexical parent of it, up to and including the global environment – naming every such symbol and the argument it appeared in.datamust now be a data frame or a list:hazard(data = <matrix>, ...)errors, where a matrix was previously accepted and silently ignored along with everything else indata. -
hzr_translate_sas()translates a SASPROC HAZARD/PROC HAZPREDjob into a Quarto document of equivalent R calls. It parses the SAS statements, builds the calls, and renders them into.qmdchunks – the model state is stored as unevaluated calls, so rendering isdeparse(), not string templating.This function is experimental. A job that translates now renders: the emitted
hazard()chunk binds its fit to a name and asks for an actual fit, and thepredict()chunks have something to predict from. Measured on the publichazardcorpus of 110.sasfiles, 57 translate into 22 distinct documents; the 11 of those that synthetic data can drive end to end evaluate every chunk and bind a converged fit, and the other 11 –PROC HAZPRED-only jobs with no local fit to bind – are exercised up to their fit chunks. Read that as a measurement, not as “the translator works”: the rest of the corpus is refusals or jobs whose externalINHAZ=could not be resolved. It remains a translation aid rather than a turnkey reproduction, and the API, thehzr_sas_jobfield layout and the emitted document format may all still change.Two SAS constructs are refused outright rather than mistranslated into something that computes a wrong answer. Each records an
UNTRANSLATEDrow and emits astop()in place of the fit, so the document fails where the fit would have been:- a
SELECTIONstatement requesting a stepwise screen.hzr_stepwise()’s refit path needs a formula-interface base fit and this translator emits the vector interface, so every candidate refit would error and the screen would report zero steps – indistinguishable from “nothing metslentry” (#152, #160; the underlyinghzr_stepwise()silent no-op is #159). -
LCENSORcombined withICENSOR.hazard()’s singletime_lowerargument carries the entry time for status 0/1 rows and the interval’s lower bound for status 2 rows, so one column cannot express both (#155).
Two gaps that made the emitted calls compute a different answer from the SAS job are closed. The log prediction grid now takes its step from the job’s own
INC=expression rather than a hardcoded one (#153): three denominators appear across the public corpus (/49.9,/99.9,/999.9) and the denominator sets both the step and the number of points SAS’sDO lo TO hi BY INClands, so reading every job as/99.9gave the/999.9jobs 100 points on a step ten times too large – every time wrong, over an emptyuntranslatedand full coverage. The span is the loop’s ownlog(hi) - lo, which coincides with5 + log(hi)only because every corpus job starts at -5, and anINC=in a form the parser cannot read is refused with anUNTRANSLATEDrow rather than stepped by a guess. Every log grid in the public corpus also writes itsDOstatement with an explicit trailing element (DO lo TO hi BY INC, hi), which SAS’sDOlist syntax evaluates as the loop plus one final point at exactlyhi– the translator emitted only the loop, so every translated grid stopped short of the time the job actually asked for (about 8% short for a/99.9step). The trailing element is now read from the job’s ownDOstatement and emitted as the grid’s last point; a trailing element this cannot resolve to the loop’s own bound is refused with anUNTRANSLATEDrow.EVENT,ICENSORandWEIGHTare counts in the reference implementation, not flags, andsetlik.ccombines one record’s contribution asc1c2c3 = c1w + c2 + c3wwithc1w = C1 * WTandc3w = C3 * WT. All three now reach the fit that way. AnICENSORevent count is no longer discarded (#154); anEVENTcount carries intoweightsandstatusderives fromEVENT > 0, whereEVENT = 2used to map straight ontostatus = 2and be fitted as interval-censored – a different likelihood branch, not an under-count (#157); and aWEIGHTvariable no longer weights right-censored rows, becausec2is the one term entering that sum unweighted andreadc2.csets it to1on exactly those rows – aWEIGHTthat was0there previously deleted them from the fit silently (#158). A row where theEVENTandICENSORcounts both fire is two contributions at once, which onestatusand oneweightcannot express, so the emitted status chunk now stops before the fit rather than picking the event branch and discarding the interval one.RCENSORis the third of those counts and was being ignored outright (#162). It namesC2– “COUNT OF CENSORED INDIVIDUALS AT TIME=T” – and when a job names it,readc2.creads the column straight from the data and skips theC2 = 1derivation that a job withoutRCENSORgets. Four censored individuals were therefore fitted as one observation. The censored branch ofweightsis now that variable, still unmultiplied byWEIGHT, and the both-fire guard now coversEVENT+RCENSORandICENSOR+RCENSORas well:readobs.cdeletes an all-zero row only whenRCENSORis named and exactly one of the other two is, so a row with two counts positive always survives to be summed.A
0/1RCENSORflag that is exactly1 - EVENT– which is what both corpus jobs carry, and what the statement is usually used for – fits exactly as before. A0/1flag that is not its complement does not, and both ways it can differ are deliberate: a row with the event and the censoring flag both set now stops the document instead of being fitted as an event alone, and a row with neither set now carries weight0instead of a fabricated weight of1, matching the row SAS would have deleted. A count column that is negative or missing on any row now stops the document with a message naming the variable, rather than being folded into the censored branch or propagatingNAintoweightsuntilhazard()refused it as “non-negative and finite”.readc1.c,readc2.candreadc3.capply the same rule to every count the job names – a missing value setsmdel, a negative one setsdel– andreadobs.cthen skipssetobs()for that row and subtracts it fromNobs. Such a row contributes nothing at all, so translating it as a right-censored observation of weight1adds survival mass at a time SAS had removed. This is the one case where a missing count is not interchangeable with a zero one: a zero count is kept and contributes, a missing one is deleted. The translator cannot drop rows without changingnbehind the reader’s back, so it stops and says to filter them.Every translated job now emits a
statuschunk ahead of its fit, where these guards live – previously only jobs withICENSORor more than one named count did. The emitted document format remains experimental.Loading a fit from an external
INHAZ=dataset returns a classedhzr_outhazobject with apredict()method (#151). That method takes the same arguments in the same order aspredict.hazard()–newdata,type,decompose,se.fit,level,conf.type– so a positional call means the same thing for both methods of the generic, andconf.type(thePROC HAZPREDparity switch the translator emits) is a real argument rather than one that a misspelling could drop into..., returning the log-log limits the SAS job did not ask for. Its value is checked only on the survival standard-error path that reads it, exactly aspredict.hazard()does, so an ignored value does not fail a point or hazard prediction; a mistyped argument name still errors.typedefaults to"hazard", as inpredict.hazard(), anddecompose = TRUEis an error: anOUTHAZ=dataset carries fitted parameters, not a per-phase decomposition. Point predictions work;se.fit = TRUEis refused whenever the SAS fit estimated a late shape parameter thatPROC HAZARDput on a composite scale –log(GAMMA*ETA - 2)and friends, which is the generic unconstrained three-phase case rather than an exotic one – and likewise underFIXMNU1or where one late parameter is derived from another. A translatedPROC HAZPREDblock requests confidence limits unless the SAS job saysNOCL, so such a job stops at itspredict()chunks with an explicit message naming the parameter and its scale, rather than reporting standard errors built on the wrong one.Treat
job$coverageas a measure of parsing – tokens recognised – not of whether the result runs. The parameter translation itself is verified separately: refitting thehz.death.AVC.sasjob’s parameters throughhazard()directly reproduces the SAS log-likelihood to the six significant figures the reference listing prints (-210.501).The keyword grammar behind the parser – 122 keyword rules, 67 of them mapped to an R target – is generated from the reference
HAZARD/HAZPREDC implementation’s own lex sources (data-raw/hazard-grammar.R), not hand-written. Only the extracted table ships; no GPL-2 source enters the tarball. A hand-written table would capture only the spellings a study happened to use, and the grammar has real context-dependent collisions –Mmeans a phase shape parameter insidePARMSandMOVEinside aPHOP/STEPstatement – that a context-free lookup gets silently wrong.Constructs the translator does not cover are recorded on the returned
hzr_sas_jobobject and rendered as visibleUNTRANSLATEDcallouts in the.qmd, never dropped. Two limits are worth stating plainly:-
Prediction grids built from
SET-derived values, function calls, or unknown names are not translated. The parser resolves aPROC HAZPREDgrid’sDOloop bounds when they are literal numbers or DATA-step constants it can fold (e.g.DO MONTHS = 1*DTY, 2*DTY, ...;withDTYassigned earlier in the same DATA step) – but a bound read fromSET, computed by a function call, or naming something the parser can’t resolve is refused whole rather than partially read: a partially read grid is a partialnewdata, which is a hollow result. Such grids emit an explicitUNTRANSLATEDblock instead, and thepredict()chunks that would have read the grid become astop()naming it: emittingpredict(fit, newdata = <name>)that nothing builds either fails on an unbound name or, if the rendering session happens to hold an object of that name, reports predictions over unrelated times. On the public corpus, grid resolution is 19 of 55 (35%), up from 10 of 55 (18%) before constant folding. -
An unresolved
INHAZ=fails the render, on purpose. APROC HAZPREDjob whose fitted-model dataset can’t be located – neither from another translated job’sOUTHAZ=nor from thelibrefsargument – gets aninhaz-unresolvedchunk, ahead of the grid andpredict()chunks, whose whole body is astop()naming the unresolved libref. The document fails to render rather than reporting predictions over a model it never loaded.
- a
Bug fixes
-
$seon a fitted object is now one standard error per parameter, whatever the variance matrix looks like. A multiphase fit legitimately carries NA variance rows for the parameters it holds fixed, and a single NA anywhere in the matrix collapsed the whole vector to a length-1NA. A five-parameter fit came back with a length-1$se, so naming the standard errors against the parameters failed with “‘names’ attribute [5] must be the same length as the vector [1]”.A scalar
NAnow carries the meaning it already has forvcov(), that there is no variance matrix at all. Where a matrix is present,$seis computed element by element: a parameter held fixed carries an NA variance row and earns anNAstandard error, while every parameter whose variance was computed keeps its own. Sizing the vector to the matrix is not enough on its own – filling it withNAthroughout would leave$seconformable and empty, and contradictingsummary(), which reads the same matrix and reports those standard errors.summary()was never affected either way, so this was a quiet inconsistency on the fit object rather than a visible break. -
hzr_decompos()no longer returns a wrong value for large|m|. The three branches with a nonzeromall formed2^mand the terms built from it all three lost the answer well inside the range a fit can reach.For
m > 0the failure is overflow.2^misInffromm = 1024, butbt^(-1/nu)goes first: att/t_half = 0.5withm * nu = 3it overflows bym = 750, and sooner fornu > 1. Either waybtnubecomesInf, andInf^(-1/m)is0. Sohzr_decompos(0.5, t_half = 1, nu = 3/1000, m = 1000)reportedG = 0where the answer is0.3969, withgandhNaN. Nothing warned.G = 0is a perfectly ordinary probability, and a fit whose optimizer wandered into largemused it. Thenu < 0branch collapsed the same way, toG = 1.For
m < 0the failure is cancellation instead.1 - 2^mrounds to exactly1once2^mfalls below machine epsilon, so(1 - 2^m)^(-nu) - 1is0,rhoisInf, andGis again0. That collapse is atm = -53, which an optimizer reaches much more easily thanm = 750, and the accuracy decays before it: atm = -20the old code was already wrong in the tenth digit.All three branches now work on the log scale. The
min the(2^m - 1)/mfactor ofrhocancels the explicit multiplier, som * bt^(-1/nu)is exactly(t_half/t)^(1/nu) * (2^m - 1), andlog(btnu)follows fromhzr_log1pexp()applied to the log of that product. Them < 0branch takeslog(1 - 2^m)fromhzr_log1mexp()rather than forming the difference. Both primitives were already in the package. Checked against a reference computed at 100 or more decimal digits,Gandgare now accurate to machine precision fromm = -1000up tom = 5000, they track the analytic large-mlimit atm = 1e6, and they are unchanged where the old code was already right. Smallmimproves too, by eight orders of magnitude or more:log(2^m - 1)is taken asx + hzr_log1mexp(x)forx = m * log(2), which holds at both ends, where the directlog1p(-2^(-m))decays from aboutm = 1e-3down and reaches-Infonce2^(-m)rounds to1.One boundary remains, and it is now visible rather than silent. Below about
m = -1074the term2^munderflows outright and no rearrangement recovers it in double precision;hzr_decompos()returnsNAthere.The multiphase log-likelihood is evaluable again over the same range. On a two-phase fixture it returned
-Inffrom aboutm = 450, for this same reason: the smallest observed time is what makeslog(t_half/t)largest, so the overflow arrives earlier than them = 750above. That is what made the likelihood surface along the ridgem * nu = consthard to characterize. hzr_stepwise()can no longer return a zero-step result that is silently empty. Every accepted move goes through a refit, and a refit that failed was downgraded to a warning and then dropped: the returned object carried no record of it, so a screen that could not fit a single candidate looked exactly like one that tested them all and liked none.$criterianow carriesrefit_failures,n_refit_failuresandstopped_refit_failed; a run that ends on an iteration with failed refits warns that its candidates were never tested; and the trace names the cause instead of claiming “no further action”. A base fit built with the vector interface (time =/status =) stores no formula for the refit to mutate, so every candidate would fail –hzr_stepwise()now rejects it up front with one message naming the remedy, through the same predicate the refit itself uses.-
A multiphase fit is now reproducible.
hazard(dist = "multiphase")offsets the starting values for every optimization start after the first, and those offsets were drawn from the ambient RNG stream. The identical call run twice returned a different answer: on a 150-row two-phase fit the estimates moved by about 0.3 on the log scale and the objective by about 0.08, which is enough to change what the fit says. Fitting also advanced the caller’s stream, so a latersample()orrnorm()depended on whether a model had been fitted first.Where the assembled starting values did not converge on their own, the draw decided whether there was a fit at all: the fit succeeded only from a perturbed start, and about a quarter of draws stopped outright. The same call could raise that error on one run and not the next. The reason those starting values failed is fixed below, so the draw no longer decides that; it decides only which optimum is reached.
The offsets now come from an internally seeded stream, and the ambient
.Random.seedis restored afterwards. The same data and the same control give the same fit, with noset.seed()needed, and fitting leaves the caller’s stream where it found it. The newcontrol$start_seed(default 3) selects a different ensemble of starts. That is worth reaching for when a fit looks like it settled in a local optimum: fit at a few seeds and compare theobjectivevalues. See the note onstartsbelow for how to read two objectives that differ, which is not always a pair of rival optima.start_seedtakes any whole number within integer range, negatives included –set.seed(-1)is perfectly valid and deterministic, so restricting to non-negative values would discard half the seed space for no reason. A fractional value is rejected rather than truncated:set.seed()truncates, so3.9and3would select the same ensemble, and a sweep over3.1 / 3.5 / 3.9would report three fits having tried one set of starts. Coercing quietly would keep that aliasing and merely move it. A value out of integer range is rejected too, becauseset.seed()would otherwise fail with “supplied seed is not a valid integer” and name neither the argument nor the fit it came from.hzr_bootstrap()draws its own resample before each refit, so replicates are still distinct. Its numbers do shift, because the refits no longer advance the stream between resamples, and a run withseed=is now reproducible end to end. -
A multiphase optimization start no longer dies on an infeasible shape. The multiphase cumulative hazard short-circuits to an infinite hazard when a phase’s
mandnuare both negative, so the optimizer sees a penalty and backs out of the region. Asked for a per-phase decomposition it short-circuited in the wrong shape – a bare vector where the caller expects a named list – and the Conservation-of-Events adjustment, which runs inside the objective on every evaluation, raised$ operator is invalid for atomic vectors. BFGS steps into that region routinely, so the error came back out ofoptim()and the multi-start loop threw the whole start away.A discarded start was reported as a failure to converge, so a crash read as a numerical problem. On the two-phase fixture in
test-multiphase-gradient.Rit cost the fit its assembled starting values outright:n_starts = 1stopped with an error, the fit survived only on a perturbed start, and 12 of 50start_seedvalues failed. All 50 converge now,n_starts = 1converges on its own, and across 50 seeds every one of the 250 starts is usable. -
A multiphase fit now says which of its starts survived.
fit$fit$startsgives one row per optimization start: itsstatus, itsobjective, itsconvergencecode fromoptim(), whether it was thebestone and so the fit you are looking at, and themessageof any error it raised. Worth a look when a fit is in doubt: on the fixture above the assembled start reaches -159.15 and a perturbed start -158.30, and start 1 wins 5 of 50 seeds at the defaultn_starts = 5(17 of 50 atn_starts = 3– the rate depends on how many starts there are to lose to, so read it against your own setting).Read two such numbers as objectives, not as rival optima. That fixture has no interior maximum in
m. Profiled, its objective climbs past -158.30 toward a finite limit of -157.88 that is reached only asmgrows without bound, so the better number is a point on a flat ridge where the optimizer met its tolerance, and its standard error onmis 42.8 against an estimate of 27.2. Starts that disagree like that are telling you the shape is barely identified, which is the readingstartsis there to support. On data that does identify the shape the picture is the ordinary one: theavcearly+constant profile has an interior maximum nearm = 1and falls away on either side.statusseparates the four ways a start can end, and in particular a start that stopped atmaxitreads as"nonconverged", not"ok". That distinction is not cosmetic:optim()attaches a perfectly finite objective to a run it abandoned at the iteration limit, and such a start can carry a better objective than one that genuinely converged and so become the reported fit. Which start wins is unchanged – it is still the best objective – but you can now see whether it converged.fit$fit$convergedcontinues to report that for the fit as a whole.A start that errors now also warns rather than being absorbed, and when every start fails the error names what was raised instead of calling it a convergence failure. An error thrown inside the objective used to be indistinguishable from a start that merely optimized badly, which is how the defect above stayed hidden.
TemporalHazard 1.2.1
Breaking changes
This release contains a breaking change but ships as a minor version. The 1.x line is the run-up to a first production release; the major digit is reserved for that milestone rather than spent on a single changed default. The change below is also closer to a correction than a redesign — the previous default deviated from the SAS/C reference this package exists to reproduce. Read the entry regardless: it can change which variables a stepwise run selects.
-
hzr_stepwise()now defaults tocriterion = "score", reproducing SAS/C HAZARD’sSELECTIONstatistic. Previously it defaulted to"wald", which refit the model once per candidate and used the refit’s Wald chi-square – a deviation from the reference implementation this package exists to reproduce. Re-running an existing stepwise analysis can now select a different variable set, because the score and Wald paths take different step sequences. Passcriterion = "wald"to restore the previous behavior exactly.The score criterion also removes the per-candidate refit, which dominated runtime: a 92-variable two-phase screen fell from roughly 25 minutes per bootstrap replicate to seconds.
Following SAS, the variance used during selection is approximate – shaping-parameter covariances are ignored. Final-model standard errors are unchanged and still use the full Hessian.
Score is an entry criterion. The drop path never refit per candidate, so removals are still tested on the current model’s Wald p-value against
slstay, as SAS does; drop rows in$stepsare labelled"wald"accordingly.
New features
-
The SAS
.lstparsers now ship with the installed package, undersas-parity/(inst/sas-parity/in the source tree –R CMD INSTALLstrips theinst/prefix). They previously lived intests/testthat/, whichR CMD INSTALLskips unless--install-testsis passed – so a plaininstall.packages()orremotes::install_github()left them unreachable, and a downstream analysis wanting to check its own SAS output against them had to clone the repository. Reach them with:source(system.file("sas-parity", "helper-sas-parity.R", package = "TemporalHazard"))The parsers themselves are unchanged; only their location is. The package’s own parity tests load them through a shim at
tests/testthat/helper-sas-parity.R, so testthat’s helper auto-sourcing still applies and no test file changed.These functions remain internal (
.hzr_-prefixed) and unexported. They parse a specific vintage of SAS HAZARD listing output and carry no API stability guarantee. hzr_bootstrap(verbose = TRUE)now shows a text progress bar over the bootstrap replicates (viautils::txtProgressBar()) instead of an every-50-replicates message.hzr_bootstrap()gains ascopeargument for embedded stepwise variable selection during each bootstrap replicate – the R equivalent of SAS’s%HAZBOOTprocedure. This is experimental: the selection arguments and the shape of what they return may change in a future release, and?hzr_bootstrapsays why under “Selection mode is experimental”. The fixed-formula bootstrap (scope = NULL) is unaffected and unchanged. The short version: the design is still being read off production runs, and a screen large enough to matter runs for hours while this function writes nothing until its last replicate, so splitting a run across processes is currently the caller’s job. Each replicate runs a freshhzr_stepwise()selection (starting from a fixed-shape refit of the base model) instead of a plain refit, sosummary$pctreports the variable’s selection frequency across resamples andsummary$mean/sd/ci_*describe the coefficient distribution conditional on selection.scope = NULL(the default) preserves the original fixed-formula bootstrap unchanged.hzr_read_outhaz()reads aPROC HAZARDouthaz=estimate dataset, returning the estimates, each parameter’s free/fixed status, the variance-covariance matrix over the free parameters, and the model-structure flags.outhazstores its numbers at full double precision where the printed.lstcarries about seven significant figures, so for any quantity it holds it is the better parity reference – print precision stops being the binding constraint and optimizer convergence takes over. The log-likelihood is not among them; that still comes from the.lst.
Bug fixes
-
hzr_stepwise()never checked that an accepted step improved the fit. A forward step enters a model that contains the one it started from, so at the optimum the log-likelihood cannot fall. It was written into$stepsat every step and compared at none, so a step whose refit failed to converge entered anyway and every later step was then scored against a model that was not at its own optimum. In the production screen that surfaced this, three of ten steps lowered the log-likelihood and the run still reported convergence, ten entries andp = 0.000throughout; the final 19-coefficient model fitted 57 units worse than the nested 16-coefficient model from three steps earlier, which cannot happen at a maximum.$stepsnow carriesdelta_logLik, a forward step that lowers the objective warns and is counted in$criteria$n_nonmonotone_entries, andhzr_bootstrap(scope = )reports$n_nonmonotone_replicates— a replicate whose path went backwards still contributes its selections to the pooled frequencies, and each replicate runs undersuppressWarnings()so the step-level warning cannot reach the user. The comparison carries a small tolerance so optimizer noise does not fire it. Reported as issue #134. -
A score statistic could be finite, enormous and meaningless. A production screen accepted a candidate with
stat= 92,211 on 1 df andp = 0.000, after which the refit made the model worse. Neither existing guard reached it: the adjusted variance stayed positive and well above the collinearity floor, so the statistic was reported as evidence.Near-collinearity alone does not do this — as a candidate approaches collinearity its score shrinks along with its variance and
Qstays small.Qexplodes when the model being scored against is not at its optimum, because the reduced-model score is then no longer zero: the numerator is inflated while the denominator stays small. That is the state a failed refit leaves behind.hzr_stepwise()now declines a candidate whose implied coefficient exceeds ±50, reportingcoefficient_diverging, which is what the SAS/C reference has always done (dqstat.crejects|QBETA| > 50as “the model is going to infinity”). Measured on the bundledavcdata, a model displaced 0.25 from its optimum producedQ= 6.5e7 with no reason reported at all; a legitimate candidate reaches an implied coefficient of about 14, so the threshold has real headroom. Reported as issue #134. The multiphase gradient and Hessian disagreed with the log-likelihood on left-truncated data. For a row with
statusin{0, 1}the log-likelihood subtractsH(time_lower)unconditionally, but the analytic derivatives defined the entry time with an extratime_lower < timefilter. A subject entering the risk set at its own event or censoring time was therefore differentiated as though it had no entry time, while its weight was still applied – so the derivative was taken of a different function from the one being evaluated, and the optimizer left any sensible region immediately. Measured onavcat fixed parameters, the analytic gradient was out by 382 where every row entered at its exit time, and by 126 where only some did – which is ordinary left-truncated data, not a pathological input. Both derivatives now define the entry time exactly as the likelihood does, and new tests assert agreement withnumDerivacross five entry-time layouts rather than the one the old filter happened to admit.hazard()documentedtime_lowerincorrectly, and now warns when it is self-defeating. The argument was described only as the lower bound of a censoring interval, “defaulting totimeif NULL”. Forstatusin{0, 1}it is in fact the counting-process entry time, and leaving itNULLmeans entry at0, not attime. Read literally, the old wording said that passingtime_lower = timechanges nothing; it in fact states that every subject left the risk set at the instant it entered, which removes every such row from the likelihood and leaves the objective unbounded above. The documentation now gives both roles, and supplyingtime_lower >= timeon astatus0 or 1 row warns, naming the count and theNULLdefault. Reported as issue #136.-
hzr_stepwise()now says why a candidate could not be scored, and warns when the reason is that the candidate looks strong. Undercriterion = "score"a candidate whose Q statistic cannot be computed drops out of the step, and the run previously reported only a count of them. Two of the causes mean opposite things. A collinear column should be dropped. But the observed information atbeta = 0is not positive definite away from a maximum, and when a candidate’s effect is large the log-likelihood curves upward there, the adjusted variance goes negative, and the candidate is declined – so the criterion is least able to score exactly the variables a screen most wants to find. The old warning attributed both to “a degenerate or collinear candidate column”, which tells a user to discard their best variable.$criteria$uncomputable_reasons(and$uncomputable_reasonson amode = "select"bootstrap) now counts the causes by name,$all_scorescarries areasoncolumn per candidate, and both warnings name them. The reference implementation separates these too, and the R side now matches its split: a candidate whose own observed information is not positive is reported apart from one that is unusable only given what is already in the model (information_nonpositiveagainstcollinearandinformation_indefinite). The first is reachable on a multiphase fit with a large share of interval-censored rows. A run that completed while declining a candidate for this reason now warns too: it previously returned a selection – sometimes an empty one – in complete silence, which is the case where the omission is least visible. The underlying limitation of the score criterion is unchanged and is tracked separately;criterion = "wald"tests these candidates.One behavior change comes with it: the guard on the adjusted variance is now a magnitude test rather than a sign test. A variance within rounding distance of zero is reported as collinear whichever side of zero it lands on, and only a materially negative one is reported as indefinite. The previous floor was signed and relative to
I_bb, so whereI_bbwas itself negative a slightly negative variance passed through and produced a negative Q. hzr_bootstrap()now resamples fits built with the vector interface (time =/status =rather than a formula plusdata). Previously it resampleddataonly, but a vector-interface call storestime = d$colas an expression, so every replicate re-evaluated it against the original data and returned the original fit. The result wasn_success = n_boot,n_failed = 0, no warning, andn_bootidentical replicates – a summary table that looked complete and contained nothing, withsdexactly 0 on every parameter. The evaluatedtime,status,time_lowerandtime_uppervectors are already stored on the fitted object, so they are now resampled by the same index as the rows and rewired into each replicate’s call, exactly asdataandweightsalready were. Both interfaces now produce identical bootstrap replicates for the same model, data and seed. Found running a 500-replicate production bagging job that completed in 9.5 minutes and produced no usable output.-
The formula interface mistranslated left- and interval-censored
Surv()objects.survival::Surv()and this package use different integer codings for censoring status, and the parser passedSurv()’s through unchanged.Surv(time, event, type = "left")codes a left-censored row as0, which this package reads as right-censored: a wrong answer with no error, warning, or other outward sign. Undertype = "interval"/"interval2",Surv()codes rows0/1/2/3for right / event / left / interval against this package’s0/1/-1/2, so left-censored rows were read as interval-censored and interval rows carried a status the likelihood does not recognise at all.Two related faults in the same branch:
Surv()stores the status in itstime2column for every non-interval row, and the parser read that sentinel as an upper bound; and it settime_lowerfor every row, which the likelihood treats as a counting-process entry time when status is0or1, cancelling each exact-event and right-censored row out of the likelihood. Together these made an interval-censored formula fit return the optimizer’s failure sentinel rather than a fit.Status codes are now translated, an upper bound is taken only from a genuine interval row, and
time_lowerleft-truncates only interval rows. A regression test asserts that aSurv(type = "interval")fit reproduces the equivalent vector-interface fit to 1e-8 in log-likelihood. Found when a production study’s three interval-censored records could only be expressed through the vector interface. A fit that cannot compute a Hessian now says so. The analytic Hessian declines for left- and interval-censored rows by design, the optimizer falls back to
numDeriv::hessian(), andnumDerivis aSuggests– so on a machine installed without Suggests, an interval-censored multiphase fit produced no standard errors,rcond = NA,pd = NAand avcov()of barelogical, with nothing naming the cause. The user-visible symptom wasdiag(vcov(fit))reporting an invalid'nrow', which is unrecognisable from the cause. Three paths now warn:numDerivabsent (naming it and the install command),numDeriv::hessian()failing (carrying its message), and no Hessian available at all. Ahessian_fnhook that errors is also no longer swallowed into silence, so a broken analytic hook is distinguishable from one that deliberately declines. Behavior is unchanged – the diagnostics are stillNA– but the reason is now stated. Found while fitting a production interval-censored study.-
The score criterion could not test a single candidate on an interval- or left-censored multiphase fit. The analytic multiphase Hessian declines by design for
statusin{-1, 2}, and the score path had no fallback on that branch – the single-distribution branch has had one all along. TheNULLpropagated into the step’s reusable nuisance block, every candidate scoredNA, andhzr_stepwise()stopped having tested nothing, reporting it in the language of a degenerate candidate. Both halves became reachable in this release and only together: theSurv()translation fix above made left- and interval-censored rows expressible through the formula interface, andcriterion = "score"became the default. No test exercised the two at once.The observed information is now computed numerically where the analytic form declines, as the single-distribution path already did. It agrees with the analytic Hessian to 1e-4 on the equivalent right-censored fit, which is what licenses using it in place of one. The cost is a numeric Hessian per candidate – the per-candidate work the score criterion exists to avoid – but it is paid only where there would otherwise be no information matrix at all, and slower is the right trade against selecting nothing.
numDerivis aSuggestshere as elsewhere: when it is absent this now stops and names both it andcriterion = "wald", rather than returning a screen that tested nothing. -
hzr_stepwise(scope = NULL)still failed on a formula passed by variable. The fix for that defect reached.hzr_refit_with_scope()but not three sibling sites, so the default-scope path still raisedinvalid formula "f": not a call– the very string the entry below says no longer occurs. All four sites now resolve the stored formula through one internal helper, so a fifth cannot drift:match.call()recordsformulaunevaluated, anddeparse(quote(f))is"f", whichas.formula()rejects.Two consequences of that path becoming reachable, both fixed here.
scope = NULLnow skips columns it cannot model instead of erroring on them – numeric and logical columns are kept, since whether a 0/1 field arrives logical or numeric depends on the reader that built the frame rather than on the variable: under an explicit scope the caller named the column, so an error is right, but underscope = NULLthe package enumerates the candidates itself and a column it cannot model is its own choice to make better. Any data frame carrying a character or factor column – which is most of them – was otherwise unusable with the default scope. hzr_bootstrap()no longer returns a silentn_success = 0(andn_failed = n_boot, with no error and no warning) when the model was fitted inside a function.hazard()stored its call but not the environment that call was written in, so each replicate’s refit resolved arguments passed by symbol –theta,phases,control– against the package namespace andglobalenv()rather than the caller’s locals. Fits built at the top level appeared to work by falling through toglobalenv(); fits built inside a function failed on every replicate, and the per-replicatetryCatch()swallowed the error.hazard()now records the fitting environment, and each replicate is evaluated in a child of it that carries the resampled data and weights. Affects bothrefitandselectmodes.hzr_bootstrap(scope = ...)selected nothing when the base fit’s formula was passed by symbol.hazard()records its call withmatch.call(), so a formula assigned to a variable first (f <- Surv(t, d) ~ 1; hazard(f, ...)) is stored as a symbol rather than a call. The scope-mutating refit recovered it withas.formula(deparse(...)), which turns that symbol into the string"f"and errors withinvalid formula "f": not a call. Every post-entry refit therefore failed, no candidate ever entered, and the run reportedn_success = n_boot,n_failed = 0, no error and no warning – with a summary holding only the base model’s parameters. The stored formula is now evaluated in the fit’s recorded calling environment, which handles the literal and by-symbol forms alike, and a stored formula that fails to resolve raises an error naming the problem instead of degrading to an empty screen. The same defect affectedhzr_stepwise()directly. (#114)A select-mode
hzr_bootstrap()run that selects no covariate now warns. The base model’s own parameters appear in every replicate by construction, so they fill the summary atpct = 100and an empty screen reads as a set of perfectly reliable variables; nothing in the output prompted the reader to compare the parameter names againstnames(coef(object)). The warning names the likely causes: an entry criterion stricter than intended, ascopenaming columns absent from the data, or a base fit whose stored call cannot be rewritten. Legitimate empty screens warn too – an entry criterion no candidate can clear is also worth reporting. (#115)-
A stepwise screen that could not score anything now says so, instead of looking like one that finished. Under
criterion = "score"a candidate whose Q statistic cannot be computed – a degenerate or collinear column, or an information matrix that will not invert on this data – yieldsNAand is dropped from consideration. When that happened to every remaining candidate the step returned exactly what a legitimate “no candidate metslentry” stop returns, so a screen that stopped because it was unable to test its candidates was indistinguishable from one that tested them and found nothing. The per-step diagnostic existed on the returned object the whole time and had no readers.hzr_stepwise()now warns when a run stops this way and reports$criteria$n_uncomputable_scores. Becausehzr_bootstrap()runs each replicate undersuppressWarnings()– deliberately, so per-replicate numerical noise does not swamp the console – that warning cannot surface in the mode where it matters most, so the count is aggregated instead:hzr_bootstrap()gains$n_uncomputable_replicatesand warns once when it is non-zero. A replicate that scored nothing still counts towardn_successwhile contributing no selections, so it silently depresses every reported selection frequency – which is the whole deliverable of a bootstrap screen.Found by a pre-release review pass, not by a failing test: the package’s own
print.hzr_bootstraptest runs a five-replicate screen in which four replicates cannot score a candidate and none selects anything, and it passed throughout because it only ever asserted the printed label. hzr_bootstrap()no longer floods the console with per-replicate numerical warnings (e.g. ill-conditioned-Hessian notes from unstable resamples), which are not individually actionable when the bootstrap aggregates over replicates. Structural problems (a mistypedscopecolumn, an invalid scope) still surface once, up front.hzr_bootstrap(scope = ..., trace = ...)no longer errors with “formal argument matched by multiple actual arguments”. Select-mode forwarded...tohzr_stepwise()alongside an explicittrace = FALSE, so any caller-suppliedtrace=collided with it.Multiphase models with a
"cdf"/"hazard"phase whose shape sits exactly at them = 0(Case 3L) ornu = 0(Case 2L) limiting-case boundary no longer lose their analytic Hessian. The finite-difference second derivative used to probe the other shape parameter’s-hside, which can cross into the mathematically undefinedm < 0 && nu < 0region and raise an error; this silently fell back to a numerical Hessian (or, if that also failed to invert, toNAstandard errors) for every affected fit, not justhzr_bootstrap()’s Conservation-of-Events full-information recompute. The boundary direction now uses a one-sided finite difference instead.Multiphase fits with a single free parameter (a two-phase model with all shapes fixed, where Conservation of Events fixes one of the two
log_mu) now use the analytic Hessian for standard errors instead of silently falling back to a numerical one. Restricting the Hessian to the lone free parameter dropped it from a 1x1 matrix to a scalar, which was rejected as non-conformant; it is now kept as a matrix (drop = FALSE).
TemporalHazard 1.1.0
CRAN release: 2026-06-12
New features
predict.hazard(type = "hazard")now works for multiphase models, returning the instantaneous additive hazardh(t|x) = sum_j mu_j(x) phi_j'(t)(previously only single-distribution models supported"hazard", viaexp(eta)). Like"survival"/"cumulative_hazard"it is time-based (requiresnewdata$time), supports covariatenewdata, andse.fit = TRUE(delta-method limits on the log scale via a numeric Jacobian of the hazard evaluator).decompose = TRUEis not supported for"hazard". This gives the multiphase instantaneous hazard a public route (it was previously reachable only through internal functions).predict.hazard(..., se.fit = TRUE, conf.type = "logit")selects the survival confidence-limit transform. The default"log-log"builds limits onlog(-log S)(thesurvival::survfitstandard);"logit"builds them onlogit(1 - S), reproducing SAS HAZARD’sHAZPREDsurvival limits. With the full-information vcov for CoE fits,conf.type = "logit"matches the SAShp.death.AVCsurvival CLs to ~1e-5. Hazard / cumulative-hazard limits are unaffected (their log scale already matches HAZPRED).predict.hazard(type = "cumulative_hazard", decompose = TRUE, se.fit = TRUE)now returns per-phase and total delta-method confidence limits for multiphase models, as a long data frame (time,component,fit,se.fit,lower,upper). Each phase’s CL uses only that phase’s parameters, so per-phase limits do not sum to the total. Previously this combination raised an error.
Changes
-
hzr_deciles()now matches the SASdeciles.hazardmacro exactly. Previously it excluded subjects censored before the horizon and defined the expected count assum(1 - S(horizon)). It now follows the SAS method: all subjects are ranked into equal-sized risk groups by predicted survival at the horizon, and the expected count per group is the sum of predicted cumulative hazard at each subject’s own follow-up time (so group totals sum to the total observed events under conservation of events). Thetimeargument now only stratifies subjects into risk groups; it no longer restricts or excludes any subject, and the expected/observed totals are horizon-independent. Verified to reproduce thehm.death.AVC.decilesSAS decile table (CASES/EXPECTED/ACTUAL) to print precision. The output columns are unchanged; their definitions are updated in?hzr_deciles.
Bug fixes
Conservation-of-Events fits now report the full-information variance. CoE removes one phase’s
log_mufrom the optimizer search (its score equation is the CoE constraint), but the previous code also dropped it from the uncertainty – the conserved phase got anNAstandard error, and anything depending on it (other SEs,se(H), prediction confidence limits) was understated wherever that phase contributed. At the optimum the CoE solution is the unconstrained MLE, sovcov()is now recomputed from the unconstrained-objective Hessian over the full free set (including the conservedlog_mu), matching an all-mu-free (conserve = FALSE) fit at the same point. Onhz.death.AVCevery parameter SE now matches the SAS HAZARD reference (e.g. the conserved earlylog_mu: 0.133 vs the previous ~0.059). The recomputation usesnumDeriv(Suggests) and an invertible Hessian; if either is unavailable the fit emits a warning and the conservedlog_muretains anNAstandard error (as before).Conservation of Events ignored left-truncation (counting-process entry times). For multiphase fits on
Surv(start, stop, event)data, the CoE reparameterization conservedSum H(stop)while the likelihood scores the intercepts on the entry-time scale,Sum E = Sum [H(stop) - H(start)]. The conserved phase therefore absorbed the spuriousSum H(start), biasing its intercept and lowering the attained log-likelihood (thehz.te123.OMCfit-1 parity offset, gap-list P1 #6)..hzr_conserve_events()and.hzr_select_fixmu_phase()now subtract the per-phase entry-time cumulative hazard, matching the likelihood and C HAZARDsetcoeunderLCENSOR/STARTTME. Plain right-censored fits (nostarttime) are unaffected.vcov()was unusable for multiphase fits and returned an unnamed matrix.vcov.hazard()collapsed the entire matrix to a scalarNAwhenever any cell wasNA. Multiphase fits legitimately haveNAvariance rows – for parameters held fixed (e.g. early shapes) and for the Conservation-of-Events-conserved phaselog_mu– so the finite free-parameter block was discarded for almost every multiphase model. The method now returns the full matrix withNArows preserved and labels rows and columns with the coefficient names (phase-prefixed for multiphase, e.g.early.xvsconstant.x), so a covariate shared across phases resolves to distinct, name-addressable slots. A scalarNAis returned only when no covariance matrix is available.Weibull analytic gradient produced
NaNfor right-censoredtime = 0rows..hzr_gradient_weibull()used an unguardedlog(time)in the shape (nu) score; a legal right-censored row attime = 0made0 * -Inf = NaN, which poisoned the entire summed shape-gradient component (then silently zeroed by the optimizer, harming convergence).log(time)is now guarded withlog(pmax(time, .Machine$double.xmin)), matching the analytic Hessian. The other families were audited: exponential (nolog(time)in the score), log-normal (rejectstime = 0), and multiphase (the decomposition clampstime) are unaffected.Weibull event hazard was inconsistent with its cumulative hazard.
.hzr_logl_weibull()defined the event hazard asmu*nu*t^(nu-1)*exp(eta)while the cumulative hazard was(mu*t)^nu*exp(eta); the former is missing amu^(nu-1)factor (the exact derivative isnu*mu^nu*t^(nu-1)*exp(eta) = (nu/t)*H, Form A as in the C/SAS HAZARD reference). The natural-scale log-likelihood and its analytic gradient (d/dmu,d/dnuevent terms) are corrected to match. Pure event/right-censored fits were already correct (they use the self-consistent internal reparameterization); the visible effect is on mixed event + interval/left-censored Weibull fits, which delegate to this likelihood and previously optimized a slightly mis-specified event term.Weibull gradient attribute ignored observation weights.
.hzr_logl_weibull(..., return_gradient = TRUE)attached an unweighted gradient even whenweightswere supplied (the analytic gradient was off by the weight scale, e.g. halved underweights = 2).weightsis now forwarded to the score computation. The model-fitting path was unaffected (it uses a separate internal weighted gradient); this only changes callers reading thereturn_gradient = TRUEattribute on weighted data.hzr_bootstrap()was non-functional for weighted fits (Phase 7c). The resample loop rewired onlydatain the refit call, leaving the originalweightsargument bound to a symbol in the caller’s frame. The internaleval()could not resolve that symbol, so every replicate of a weighted model errored out (n_success == 0) regardless offraction; even had it resolved, the un-resampled weights would have been misaligned with the bootstrapped rows.weightsis now evaluated once and resampled in lockstep with the data on each replicate (mirroring howdatais handled). Unweighted bootstraps are unaffected. A regression test covers both thefraction < 1and full-size weighted paths intest-diagnostics.R. Follow-up:hzr_bootstrap()now resamples the weights already stored on the fitted object (object$data$weights) rather than re-evaluating the call’sweightsexpression inparent.frame(), which fails when the original symbol is no longer in scope (e.g. the fit was built inside a helper that has returned). Caller-frame evaluation remains a fallback for objects fitted before weights were stored. The same fragility applied to the call’sdataargument:hazard()now stores the evaluateddataargument (the data frame passed tohazard(), not amodel.frame()result) on the fitted object (object$data$frame), andhzr_bootstrap()resamples that stored frame instead of re-evaluatingcl$datainparent.frame(), so bootstrap succeeds even when the originaldatasymbol is out of scope. Caller-frame evaluation remains a fallback for objects fitted before the frame was stored.4-phase CoE fixmu-phase selection (Phase 7d).
.hzr_select_fixmu_phase()usedwhich.max()over raw per-phase cumhaz at the starting theta. G3 late phases with typical shape parameters have unnormalized cumhaz orders of magnitude larger than other phases, causing CoE to pin the G3log_muaway from its true near-zero MLE. Fixed by excluding phases whose cumhaz contribution exceeds 10× the median before selecting (falls back towhich.maxwhen all phases are outliers). On the 4-phase CABGKUL fit the CoE vs no-CoE LL gap closes from 6.9 to < 0.1 units. Six new tests cover the 4-phase code path intest-conservation-of-events.R.time_lowerdual-use bug in Weibull and multiphase likelihoods. Whentime_lowerwas supplied for a mixed interval-censored + right-censored dataset, the Weibull LL interpretedtime_loweras the counting-process entry time for right-censored rows, computing H(stop) − H(start) = 0 and silently zeroing those rows’ likelihood contribution. Fixed inlikelihood-weibull.R(4 sites: LL, gradient, L-BFGS-B internal LL/gradient) andlikelihood-multiphase.R:start_vecis now set fromtime_loweronly for genuine epoch rows (status %in% c(0L, 1L)andtime_lower < time). Two regression tests added totest-interval-censoring-weibull.R.-
hzr_decompos()Case 3 corrected andnu = 0, m >= 0now fails loud (Phase 7d). Two issues in the early-phase (G1) sign dispatch:-
Case 3 (
m > 0, nu < 0, “bounded cumulative”) carried a spurious factor ofm. Itsrhoused a bare(2^m - 1)^nuinstead of the((2^m - 1)/m)^nuform used by Case 1, leaving anmfactor on thebt^(-1/nu)term. The CDF diverged from the C HAZARD G1 evaluator (g1flag = 5) by up to ~0.2 and was discontinuous with itsm -> 0limit (Case 3L). Adding the/mdivisor makes themfactors cancel, reproducing the C evaluator exactly and restoring continuity (verified againstsrc/common/hzd_ln_G1_and_SG1.c). No shipped phase uses Case 3, so fitted models are unaffected; the synthetic 3-phase golden fixture was regenerated because its free-shape optimizer path crosses Case 3 territory. -
nu = 0withm >= 0fell through every dispatch branch, leaving the CDF unassigned and raising the crypticobject 'G' not found. Thenu -> 0limit is defined only form < 0; form >= 0it is degenerate. The function now raises a clear, explanatory error. Newtest-decompos-boundary.Rlocks in continuity of all limiting branches (Case 1 -> 1L, 2 -> 1L, 2 -> 2L, 3 -> 3L), Case 3 <-> Cg1flag=5parity,g = dG/dtinternal consistency, CDF sanity, and stability at extremet_half.
-
Case 3 (
Improvements
Hardened Hessian inversion for standard errors (Phase 7c). Post-fit variance-covariance estimation now symmetrizes the Hessian, checks its reciprocal condition number, inverts via Cholesky with a
solve()fallback for non-positive-definite Hessians, and guards non-positive variances instead of silently emittingNaNstandard errors. Ill-conditioned, non-positive-definite, and non-finite Hessians now raise specific, named warnings, and fits carryrcond/pddiagnostics thatsummary()surfaces as a note when a fit is flagged. This closes the “12+-parameter Hessian stability” hardening item for the inversion layer; analytic Hessians (more accurate standard errors) follow in subsequent releases.Analytic Hessian for exponential standard errors (Phase 7c, Layer 2). The exponential distribution now computes its post-fit Hessian in closed form (
X~' diag(wH) X~over event + right-censored rows) rather than numerically, giving more accurate standard errors. The shared optimizer gained ahessian_fnhook that analytic Hessians for the remaining families will reuse; left/interval-censored exponential fits fall back to the numerical Hessian.Analytic Hessian for Weibull standard errors (Phase 7c, Layer 2). The Weibull distribution now computes its post-fit Hessian in closed form on the internal
(alpha, psi, beta)optimization scale (then mapped to the natural scale by the existing delta method) rather than numerically, giving more accurate standard errors. Covers event + right-censored data (including counting-process start times); left/interval-censored fits fall back to the numerical Hessian.Analytic Hessian for log-logistic standard errors (Phase 7c, Layer 2). The log-logistic distribution now computes its post-fit Hessian in closed form on the internal
(log alpha, log beta, beta_coef)scale rather than numerically, giving more accurate standard errors. Covers event + right-censored data; left/interval-censored fits fall back to the numerical Hessian.Analytic Hessian for log-normal standard errors (Phase 7c, Layer 2). The log-normal distribution now computes its post-fit Hessian in closed form on the internal
(mu, log_sigma, beta_coef)scale rather than numerically, giving more accurate standard errors. Covers event + right-censored data; left/interval-censored fits fall back to the numerical Hessian.Analytic Hessian for multiphase standard errors (Phase 7c, Layer 2 PR-6). Post-fit standard errors for all multiphase fits now come from a closed-form Hessian of the negative log-likelihood rather than a numerical Richardson approximation. The Hessian is assembled from three terms: (A) a phase-block-diagonal curvature of Σᵢ wᵢ H(tᵢ), (B) a dense Fisher information outer product Σₑ (wᵢ/hᵢ²) ∇h ∇hᵀ capturing cross-phase parameter interactions, and (C) a phase-block-diagonal curvature of −Σₑ wᵢ log h(tᵢ). μ/β parameters use fully closed-form expressions; shape parameters (t_half, ν, m, and G3 parameters) use second-order central differences. The Conservation-of-Events full-information vcov path also switches to the analytic Hessian. Left/interval-censored fits fall back to the numerical Hessian. Completes the 6-PR analytic-Hessian rollout across all five families.
Documentation
-
vignette("fitting-hazard-models")gains an Interval and left censoring section covering: status coding reference (-1/0/1/2), a cardiac clinic-visit simulation with right- and interval-censored observations, the directtime_lower/time_upperAPI, and a comparison showing the interval-censored fit recoveringnuclose to 1.0 (true value) while the naive exact-at-upper fit incurs a shape bias of ~+0.45. Includes a callout note on the correct use oftime_lower = 0for right-censored rows. -
vignette("fitting-hazard-models")gains a Convergence troubleshooting section covering: reading the KM cumulative hazard for Weibull starting values (log-log plot), when to fix shape parameters vs. estimate freely, diagnosing overparameterization via near-zero phase scales andNAfromvcov(), andcontroloptions (n_starts,maxit). - Added a package-level overview help page (
?TemporalHazard) giving the additive multiphase model, the phase-type vocabulary, the SAS/C HAZARD bridge, and a map of the main entry points. - Expanded the mathematical content of the core help files in the style of
randomForestSRC: explicit display equations for the generalized temporal decompositionG(t)(?hzr_decompos), the additive cumulative-hazard model on?hzr_phaseand?hazard, and defining formulas plus the Mächler (2012) reference for the numerical primitives (?hzr_log1pexp,?hzr_log1mexp,?hzr_clamp_prob). - Added methodological references to the nonparametric diagnostics (Kaplan-Meier/Greenwood, Nelson-Aalen, Aalen-Johansen) and filled in missing cross-references across the exported help pages.
- Explained the remaining enumerated options in the style of the
hzr_phase()phase-type help.?hazardgains a Baseline distributions section describing eachdistvalue ("weibull","exponential","loglogistic","lognormal","multiphase") by its hazard shape and when to use it;?hzr_stepwisegains a Selection direction and criterion section explaining eachdirection("forward"/"backward"/"both") andcriterion("wald"/"aic"), including how Wald selection differs from C/SAS HAZARD’s score-statistic path.
Testing
Patient-specific HAZPRED prediction parity (Group A fixtures
hp.death.AVC.hm1/hm2). Newtest-sas-parity.Rblocks predict survival and instantaneous hazard – with logit survival CLs and log hazard CLs at the SAS 1-SD level – from the saved multivariable both-phase model (hm.death.AVCfinal fit, “HMDEATH”) for two covariate profiles each (hm1: with/without an associated cardiac anomaly; hm2: complete vs partial canal by date of repair), matching SAS to ~5e-4 (survival) / ~8e-3 (hazard; the looser hazard tolerance reflects the near-singular 9-coefficient fit and the steep early-phase times). Adds a header-driven.hzr_parse_sas_nomogram_mv()(parses the BY-group “digital nomogram” whose rows each carry their own covariate vector) and a shared.hzr_fit_avc_hmdeath()helper.-
Stratified HAZPRED calibration parity (Group A fixture
hs.death.AVC.hm1). Newtest-sas-parity.Rblocks reproduce the population-averaged, stratified-by-COM_IVoutputs from the same HMDEATH model: (1) the observed-vs-expected “predict number of deaths” table – per stratum, EXPECTED = sum of predicted cumulative hazard at each subject’s own follow-up, PEXPECT = sum of predicted death probability, ACTUAL = observed deaths (totals conserve events, 14.76 + 55.24 = 70), to ~5e-3; and- the per-stratum mean survival curve (MSURVIV) at the digital time grid, to ~5e-4. Adds
.hzr_parse_sas_calibration()and.hzr_parse_sas_strata_survival().
- the per-stratum mean survival curve (MSURVIV) at the digital time grid, to ~5e-4. Adds
hm.death.AVCstepwise documented as a non-parity gap (Group A). The phase-aware forwardSELECTION SLE=0.2 SLS=0.1fit’s final selected model is the saved “HMDEATH” fit already verified by thehm.death.AVC.deciles/hp.death.AVC.hm1/hm2parity tests; its selection path cannot be reproduced (SAS uses approximate variances during selection while R’s full Hessian is near-singular here; SAS’s/I/Sflags are phase-level but R’sforce_inis phase-blind; R oscillates at p ~ slstay and lands in a worse basin – the same divergence already documented forhm.deadp.VALVES).test-sas-parity.Rgains a regression-guard test that exercises the multiphase phase-aware stepwise path end-to-end on real data without asserting path parity; seeinst/dev/FIXTURE-GAP-LIST.md.bs.death.AVCbootstrap documented as a non-parity gap (Group A). SAS%HAZBOOTruns a fresh stepwise selection on each bootstrap resample and reports a variable-selection frequency; R’shzr_bootstrap()resamples and refits a fixed model (no embedded-selection mode), and reimplementing the SAS procedure would inherit the documentedhm.death.AVCstepwise divergence.test-sas-parity.Radds.hzr_parse_sas_bootstrap()and asserts the SAS reference selection frequencies in parseable form (so the parity test is half-written for a future bootstrap-with-selection capability), plus a regression guard that R’s fixed-model bootstrap runs on the cohort; seeinst/dev/FIXTURE-GAP-LIST.md.Phase-specific covariate recovery tests (Phase 7d). New
test-phase-specific-covariates.Rconfirms thathzr_phase(formula = ~ ...)is correct, not just runnable: simulation-based recovery tests verify that a covariate entered into one phase recovers its true coefficient, that the same covariate carries independent (here opposite-sign) effects across two phases, and that a covariate confined to one phase does not leak into another. This is the honest substitute for a SAS parity fixture and guards against the “accepts the formal but never applies it” regression that has surfaced before with weights and counting-process times.Added fractional (non-integer) weight coverage to close the roadmap 7a gap. Prior weight tests verified weighting only via integer row duplication, which cannot express fractional (e.g. inverse-probability) weights. The new tests assert the two properties that define a correct per-row weighted log-likelihood: an additive split (a row of weight
a + bequals two identical copies of weightsaandb) and linear scaling (L(theta; c*w) = c * L(theta; w), gradient likewise, MLE invariant), across the Weibull, exponential, and multiphase-with-covariates paths.Made the single-distribution weighted-fit tests exercise a real fit. They previously omitted
thetastart values, sohazard(fit = TRUE)took its unfitted branch and the assertions comparedNULL/NAvacuously; they now supply starts and genuinely compare the weighted MLE to the duplicated-row MLE.Added interval-censoring coverage under the multiphase model (roadmap 7c). The multiphase likelihood’s interval-/left-censored branch had a working code path but no isolated test. New R-only self-consistency invariants in
test-interval-censoring-multiphase.Rverify the interval contribution equalslog(S(lower) - S(upper)), the left-censored term equalslog(1 - exp(-H(u))), right-censoring stays-(H(stop) - H(start))(including left truncation), invalid bounds (lower > upper) yield-Inf, integer weights match row duplication on interval rows, and an interval-censored multiphase fit converges.Added a SAS fractional-weight parity capture scaffold under
inst/extdata/weights-fixtures/(roadmap 7a / FIXTURE-GAP-LIST B5): aPROC HAZARD ... WEIGHT IPWtemplate, a deterministic non-integer weight dataset, a.lstparser, andtest-weights-sas-parity.R. The parity test re-fits the SAS specification in R and compares covariate estimates and log-likelihood; it skips when the capture fixture is absent (as it is by default), so CI and installation are unaffected until a SAS run is dropped in. R-side fractional-weight correctness is already proven by the invariants above; this is the drop-in external SAS confirmation.
TemporalHazard 1.0.3
CRAN release: 2026-05-30
Bug fixes / CRAN compliance
-
hzr_bootstrap()no longer touches.GlobalEnvdirectly. The 1.0.2oldseed/on.exit()/assign(".Random.seed", ...)save-restore wrapper added in 1.0.2 violated CRAN policy on writing to.GlobalEnvand has been removed. Whenseedis supplied the function simply callsset.seed(seed)(the documented R API for seeded reproducibility); the@param seeddocumentation now notes that the caller’s RNG state is not restored on exit. Withseed = NULL(the default) the function does not callset.seed()at entry, so it starts from the caller’s current RNG state; the bootstrap still consumes random numbers and advances that state in the usual way.
TemporalHazard 1.0.2
Bug fixes / CRAN compliance
- The golden-fixture generators (
.hzr_create_*_golden_fixture(), previouslyR/golden_fixtures.R) have been moved out of the package todata-raw/golden_fixtures.R. They are maintainer-only helpers for regenerating the bundledinst/fixtures/*.rdsreference outputs and are not part of the installed package, so they are no longer shipped, checked, or user-reachable. This resolves the home-filespace concern at its root: the earlier fallback resolved tosystem.file("fixtures", ...)— i.e. the installed package directory — whenever the package was installed, so the 1.0.1 “falls back totempdir()” fix did not actually prevent writing to the user library. The bundled.rdsfixtures still ship and the parity tests still read them viasystem.file(). -
.hzr_generate_golden_fixture()(the C-binary reference writer inR/parity-helpers.R, which shares a file with test-time helpers and so was kept in the package) now takes a requiredoutput_dirargument with no default path. - Removed the remaining hardcoded
seed = 42literals from the relocated generators; recorded fixture metadata reflects the actualseedargument passed (NULLby default, so no seed is set inside the function). -
hzr_bootstrap()no longer leaves the caller’s random-number stream altered whenseedis supplied: the global.Random.seedis saved beforeset.seed()and restored viaon.exit(), matching the fixture generators. Bootstrap reproducibility under a givenseedis unchanged.
TemporalHazard 1.0.1
Bug fixes / CRAN compliance
- Added
\valuedocumentation to all exported functions that were missing it:hazard(),coef.hazard(),vcov.hazard(),print.hzr_calibrate(),print.hzr_deciles(),print.hzr_gof(), andprint.hzr_kaplan(). - Internal fixture generators (
R/golden_fixtures.R) no longer set a specific seed unconditionally. Generators now accept an optionalseedargument; when provided, the global RNG state is saved and restored viaon.exit(). - Default
output_dirfor fixture generators falls back totempdir()instead of the package source directory, keeping the home filespace unmodified.
TemporalHazard 0.9.8
New features
-
Delta-method confidence limits on
predict.hazard()— Phase 4g of the development plan lands. Two new arguments:se.fit = FALSEandlevel = 0.95. Whense.fit = TRUE, the return value becomes a data frame with columnsfit,se.fit,lower,upper.-
Weibull and multiphase use closed-form Jacobians (
dH/dtheta,dexp(eta)/dtheta,deta/dtheta); exponential / log-logistic / log-normal fall back tonumDeriv::jacobianon a per-call cumhaz closure. -
Transforms match SAS HAZARD (
hzp_calc_haz_CL.c/hzp_calc_srv_CL.c):hazardandcumulative_hazarduse log-scale CLs;survivaluses log(-log S) CLs (equivalent to log-cumhaz) so 0 <= lower <= upper <= 1;linear_predictoris symmetric on the natural scale. -
Fixed-shape / CoE multiphase fits produce meaningful CLs — the delta-method sandwich is restricted to the free-parameter submatrix of
vcov, treating fixed parameters as known-with-zero-variance. - Backward compatible:
se.fit = FALSE(default) preserves the pre-0.9.8 scalar-vector / decompose-data-frame return shape.
-
Weibull and multiphase use closed-form Jacobians (
TemporalHazard 0.9.7
New features
-
Counting-process / repeating-events likelihood wired up — Phase 4f of the development plan lands.
Surv(start, stop, event)with anystart > 0is now accepted. The Weibull and multiphase log-likelihoods applyH(stop) - H(start)to event and right-censored terms; the trivialstart = 0case degenerates toH(stop)and recovers the plain-Surv fit exactly. Splitting each row into contiguous epochs preserves both the log-likelihood and the MLE to optimizer tolerance (split-invariance). -
Weibull + multiphase analytic gradients handle H(start). The closed-form Weibull score adds a
-d H(start)/d thetaterm per row (guarded atstart = 0). The multiphase analytic gradient computes per-phasePhi_j(start)and its shape derivatives, then adds+w_H_start * mu_j * dPhi_j(start)to each parameter’s score; G3 phase derivatives atstartuse the same finite-difference machinery as atstop. -
0.9.5 narrowing removed. The
hazard()guard that rejected counting-processSurv(start, stop, event)with anystart > 0is gone.
TemporalHazard 0.9.6
New features
-
weightsnow supported for all distributions — Phase 4e of the development plan lands. The exponential, log-logistic, and log-normal likelihoods and their analytic gradients now apply row weights to every censoring term (event, right-censored, left-censored, interval-censored). The 0.9.5 guard inhazard()that rejectedweightsfordist %in% c("exponential", "loglogistic", "lognormal")has been removed. Fits with integer weights reproduce the row-duplicated fit to optimizer tolerance across all five distributions. -
Conservation of Events now honours weights.
.hzr_conserve_events()and.hzr_select_fixmu_phase()take an optionalweightsargument; the multiphase optimizer threads it through so per-phase cumulative hazards are summed on the same scale as the (weighted) observed event count. CoE no longer auto-disables when weights are non-uniform — the dimension reduction stays on and the MLE matches the full-dim path.
Bug fixes
-
Multiphase analytic gradient now applies
weights..hzr_gradient_multiphase()accepted neitherweightsnor its downstream equivalents: the per-row score weightsw_H/inv_hwere set to ±1 and the interval-censored finite-difference correction summed an unweighted LL. Weighted multiphase fits therefore optimised a weighted objective with an unweighted score; BFGS line search still converged near the correct MLE but the final gradient norm did not go to zero. All three paths now honour row weights, and the optimizer’sgradient_fnwrapper (including the all-zero numeric fallback and the CoE wrapper) forwardsweightsconsistently. Regression test covers weighted analytic vs numerical gradient parity. Surfaced by Copilot review on PR #18.
TemporalHazard 0.9.5
New features
-
Stepwise covariate selection —
hzr_stepwise()runs forward, backward, or two-way stepwise selection on an existinghazardfit using Wald p-values or AIC deltas as the entry / retention criterion. Phase-specific entry is supported for multiphase models: a covariate can enter one phase and not another. Defaults match SASPROC HAZARD(SLENTRY = 0.30,SLSTAY = 0.20); AIC mode usesΔAIC < 0uniformly. SAS-styleMOVEoscillation guard freezes variables that enter + exit more thanmax_movetimes. Returns an object of classc("hzr_stepwise", "hazard")with a$stepsselection trace, scope record, and elapsed timer. Implements the core algorithm from C HAZARDstepw.c/backw.c.
Bug fixes
-
Multiphase convergence after weights/repeating-events merge — restored multiphase optimization that regressed in 0.9.4: three interacting defects in the new
weightsthreading (dup-arg collision in the multiphase / Weibull closures, positional-arg corruption in every distribution’s gradient call) made every optimizer iteration error silently insidetryCatch. Diagnosed and fixed via commit 73b4657. -
Weibull analytic gradient now applies
weights— both.hzr_gradient_weibull()and thegrad_internalclosure inside.hzr_optim_weibull()acceptedweightsas a formal but did not apply it to the score vector. The optimizer still converged via line search on the (weighted) log-likelihood, but the gradient direction was wrong and the final gradient norm did not go to zero. Both gradient paths now weight the event indicator and cumulative hazard building blocks. Fits with integer weights reproduce the equivalent row-duplicated fit to optimizer tolerance.
Scope change
-
weightsis now only accepted fordist = "weibull"anddist = "multiphase". The 0.9.4 NEWS claimed weights were threaded through all distribution-specific likelihoods; in fact the exponential, log-logistic, and log-normal single-distribution paths accepted the formal but never applied it, so the fit was silently unweighted.hazard()now raises an explicit error whenweightsis supplied with one of those distributions rather than returning an unweighted fit. Full support for the remaining single-dist paths is tracked ininst/dev/DEVELOPMENT-PLAN.mdPhase 4e. -
Conservation of Events is auto-disabled when weights are not all 1.
.hzr_conserve_events()receives the weighted event count as its target but sums per-phase cumulative hazards across rows without applying weights, so Turner’s adjustment comes out on a mismatched scale. The multiphase optimizer now detects non-unit weights and skips the CoE dimension reduction, falling through to the (correctly weighted) full-dimensional path. Fits are still correct; they just don’t benefit from the one-parameter analytical closed-form solve. Weighted CoE wire-up is tracked alongside the other weights completion work ininst/dev/DEVELOPMENT-PLAN.mdPhase 4e. -
Repeating-events / counting-process notation narrowed.
Surv(start, stop, event)withstart > 0is no longer accepted byhazard(). The 0.9.4 NEWS claimed each epoch contributedH(stop) - H(start)to the likelihood, but downstream likelihoods only readtime_lowerfor interval-censored rows (status == 2); counting-process rows (statusin{0, 1}) were silently scored withH(stop)alone, so any fit with nonzero entry times was silently wrong.hazard()now raises an explicit error. The trivial caseSurv(0, t, d)– equivalent toSurv(t, d)– continues to work. Full wire-up ofH(stop) - H(start)for all distribution paths is tracked ininst/dev/DEVELOPMENT-PLAN.mdPhase 4f.
TemporalHazard 0.9.4
New features
-
Observation weights —
weightsargument inhazard()applies Fisher weighting to the log-likelihood fordist = "weibull"anddist = "multiphase". Each observation’s contribution is multiplied by its weight, enabling severity-weighted event analyses. Implements the SASWEIGHTstatement. The original 0.9.4 entry claimed coverage of all distribution paths; the 0.9.5 patch corrected the claim and fixed a gradient wire-up bug in the Weibull path. -
Repeating events —
Surv(start, stop, event)start-stop notation is parsed. The original 0.9.4 entry claimed each epoch contributedH(stop) - H(start)to the likelihood, but the downstream likelihoods never applied the lower bound for counting-process rows; the 0.9.5 patch narrowed the feature to the trivialstart = 0case and added an explicit error for nonzero starts.
TemporalHazard 0.9.3
New features
-
hzr_deciles()— Decile-of-risk calibration function comparing observed vs. expected event counts across risk groups with chi-square GOF testing. Implements the SASdeciles.hazard.sasmacro workflow. -
hzr_gof()— Goodness-of-fit function comparing parametric predictions against nonparametric (Kaplan-Meier) estimates with observed vs. expected event counting. Implements the SAShazplot.sasmacro workflow. -
hzr_kaplan()— Kaplan-Meier survival estimator with logit-transformed confidence limits that respect the [0, 1] boundary, interval hazard rate, density, and restricted mean survival time (life integral). Implements the SASkaplan.sasmacro output structure. -
hzr_calibrate()— Variable calibration function for assessing functional form before model entry. Groups a continuous covariate into quantile bins and applies logit, Gompertz, or Cox link transforms. Supports stratification via thebyparameter. Implements the SASlogit.sasandlogitgr.sasmacros. -
hzr_nelson()— Wayne Nelson cumulative hazard estimator with lognormal confidence limits. Supports weighted events for severity-adjusted repeated event analyses. Implements the SASnelsonl.sasmacro. -
hzr_bootstrap()— Bootstrap resampling for hazard model coefficients with bagging support (fractional sampling). Returns per-replicate estimates and summary statistics (mean, SD, percentile CI). Implements the SASbootstrap.hazard.sasmacro workflow. -
hzr_competing_risks()— Competing risks cumulative incidence using the Aalen-Johansen estimator with Greenwood variance. Handles any number of competing event types. Implements the SASmarkov.sasmacro. -
Conservation of Events (CoE) — Turner’s theorem is now integrated into the multiphase optimizer. One phase’s log_mu scaling parameter is solved analytically at each iteration, reducing the optimization dimension by 1 and improving numerical stability and convergence. Enabled by default; disable with
control = list(conserve = FALSE). Implements the core algorithm from C HAZARDsetcoe.c/consrv.c. - New vignette: “Complete Clinical Analysis Walkthrough” — end-to-end workflow from Kaplan-Meier baseline through validated multivariable model, mirroring the SAS HAZARD analytical sequence.
Improvements
- Multi-start optimizer now respects user-set RNG seeds for reproducibility (removed
set.seed(NULL)that was actively breaking determinism). - Vignette metadata normalized to YAML
vignette:key across all 8 files. -
fitparameter documentation corrected to state default is FALSE. - README now includes key capabilities table and development plan link.
TemporalHazard 0.9.1
New features
- G3 late-phase decomposition (
hzr_phase("g3", ...)) now fully integrated into the multiphase optimizer, Hessian, and prediction pipeline. -
fixed = "shapes"parameter inhzr_phase()allows fixing shape parameters during estimation (matching C/SAS HAZARD workflow of estimating only log-mu scale parameters).
Bug fixes
-
summary.hazard()now correctly reports standard errors when some parameters are fixed. Previously,anyNA(vcov)rejected the entire variance-covariance matrix when fixed parameters had NA entries. -
print.summary.hazard()coefficient table now shows the correct label for G3 phases (was printing empty parentheses). -
print.summary.hazard()phase listing now uses the phase name in CDF labels (e.g., “cdf (late risk)”) instead of hardcoded “early risk”. - SAS missing value markers (
.) in CSV datasets are now handled viana.strings = c("NA", ".")indata-raw/make_data.R, preventing numeric columns from being read as character.
Documentation
- Seven Quarto vignettes: getting-started, fitting-hazard-models, prediction-visualization, inference-diagnostics, mathematical-foundations, package-architecture, and sas-to-r-migration.
- Roxygen examples now include both single-phase and multiphase models.
- README switched to self-contained CABGKUL examples with G3 late phase.
- Dataset axis labels corrected to “Months” (not “Years”).
Infrastructure
- CI workflows updated to use
roxygen2::load_pkgloadfor lazy data compatibility. - Added lintr CI workflow with
.lintrconfiguration. - pkgdown action bumped to
peaceiris/actions-gh-pages@v4. - Added
use-public-rspm: trueto all CI workflows. - Added
lintrto Suggests.
TemporalHazard 0.9.0
New features
- Multiphase engine: N-phase additive cumulative hazard models via
dist = "multiphase"withhzr_phase()specification. -
hzr_decompos()parametric family implementing the three-parameter temporal decomposition of Blackstone, Naftel, and Turner (1986). - Multi-start optimizer with Hessian-based variance-covariance estimation.
- C binary parity tests against the KUL CABG reference dataset.
- Five clinical reference datasets:
avc,cabgkul,omc,tga,valves.
TemporalHazard 0.1.0
New features
- Single-phase engine: Weibull, exponential, log-logistic, and log-normal distributions with formula interface.
-
hazard()API withpredict(),summary(),coef(),vcov()S3 methods. - Golden fixture regression testing system.
- Numerically stable helper primitives (
hzr_log1pexp,hzr_log1mexp,hzr_clamp_prob).