Skip to content

Unify sparse and dense NCA parameters - #634

Open
billdenney wants to merge 14 commits into
mainfrom
claude/sparse-dense-nca-feasibility-68242b
Open

Unify sparse and dense NCA parameters#634
billdenney wants to merge 14 commits into
mainfrom
claude/sparse-dense-nca-feasibility-68242b

Conversation

@billdenney

Copy link
Copy Markdown
Member

Summary

Implements the sparse/dense parameter unification designed in
design/sparse-dense-unification-plan.md (included in this PR), in three
phases:

  1. One calculation pass with both sparse-data representations (the
    arithmetic-mean profile and the pooled individual samples) in scope per
    interval; output is byte-identical to the two-pass engine.
  2. FUN_sparse dispatch: on sparse data, auclast and aumclast are
    estimated with the Bailer point estimate and Nedelman-Jia/Holder standard
    error under their standard names, returning new auclast_se/auclast_df
    (and aumclast equivalents). Every AUC-derived parameter (cl.last,
    mrt.last, dose-normalized variants, ...) picks the sparse estimate up by
    name. Requesting an _se/_df companion on dense data is an error;
    pk.nca() notes once per call that the sparse estimators are
    linear-trapezoidal only when auc.method asks for anything else.
  3. Migration: the eleven legacy names (sparse_auclast family and
    *.sparse.last) still calculate with identical values but warn once per
    session, and will be an error in the next minor release. Presets and
    pknca_interval_table(sparse = TRUE) now select the same parameters as the
    matching dense design. CDISC codes gain a sparse-keyed mapping
    (AUCLST/SPARSEAL). The sparse vignette is rewritten around the unified
    names.

Behavior changes

  • auclast on sparse data is now the sparse estimate (linear trapezoidal on
    the mean concentrations) rather than auc.method applied to the mean
    profile, and brings auclast_se/auclast_df with it.
  • vz.last on sparse data uses the mean-profile lambda.z, not the 1/MRT
    chain that made the deprecated vz.sparse.last equal vss.sparse.last.
  • Exported pk.nca.interval() lost its sparse argument and gained
    pooled-sample arguments.

Verification

  • Full suite: 4719 passing, 0 failures (2 pre-existing Tobit warnings).
  • Phase 1 verified byte-identical pk.nca() output across 9 scenarios.
  • Unified values are test-pinned equal to the legacy sparse_* values.

Notes for review

  • CDISC has no sparse AUMClast code, so sparse aumclast keeps AUMCLST and
    is distinguished by PPTEST only; the mapping supports a code when one exists.
  • This branch touches R/sparse.R/test-sparse.R, as does the in-flight
    as_sparse_pk() subject-validation fix; merge one, then rebase the other.

🤖 Generated with Claude Code

billdenney and others added 14 commits August 31, 2026 16:11
Feasibility analysis for giving each registered parameter an optional
sparse-specific estimator (FUN_sparse) with fallback to the dense FUN,
collapsing the parallel sparse_* parameter vocabulary.  Includes an
appendix on why the sparse variance/df theory is tied to the linear
trapezoidal rule and what a lin up/log down extension would require.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sparse PK reached the calculations through two passes over the split
data: a dense pass over the arithmetic-mean profile and a second pass
over the pooled individual samples, with each registered parameter
computed only in the pass matching its `sparse` flag.  Per-parameter
dispatch needs both representations in scope at once, so the two passes
become one.

pk.nca() now runs a single purrr::pmap that carries `data_sparse_conc`
alongside `data_conc`.  pk.nca.intervals() filters and sorts both, and
pk.nca.interval() gains `conc.sparse`/`time.sparse` (plus the group-level
pair) in its source map beside the mean-profile `conc`/`time`.  Routing
still follows the registered flag: remap_sparse_sources() points a
sparse-flagged parameter's resolved formals map at the pooled samples,
and a sparse parameter is skipped when there are no pooled samples, as it
was for dense PK before.  The `sparse` argument is gone from both
functions; any_sparse_dense_in_interval() stays as an internal helper and
is now asked about both kinds.

The output is unchanged, row order included.  Results are marked with a
"sparse" attribute as they are calculated so that pk.nca.intervals() can
keep the two apart, and pk.nca() still reports every dense result before
any sparse one.  The imputation chain, factored out as
impute_conc_time(), runs over the pooled samples too, so a sparse
parameter sees the profile it saw when it had a pass of its own.

The one visible change is a verbose message: the two "Starting dense/
sparse PK NCA calculations" messages become one "Starting PK NCA
calculations", since there is now one pass.  The per-interval "No
dense/sparse calculations requested" messages are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pk.nca.interval() now holds the pooled individual samples for sparse PK
alongside the arithmetic-mean profile, so "conc.sparse", "time.sparse",
their group-level pair, and "subject" resolve like any other data source
named in a formalsmap.  Add them to the source list in
add.interval.col(), saying that they are NULL with dense PK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
For most NCA parameters the sparse method is the dense method applied to
the arithmetic-mean profile, and that fallback has always been what
sparse data got.  A few parameters have a real sparse estimator instead,
and until now those lived under a parallel vocabulary (`sparse_auclast`
and the four parameters derived from it) that had to be maintained
pairwise and that no other derived parameter could reach.

add.interval.col() gains `FUN_sparse` and `formalsmap_sparse`:  the
calculation function and formals map to use when the data are sparse.
pk.nca.interval() calls `FUN_sparse` when the pooled samples are in
scope, resolving `formalsmap_sparse` and then pointing `conc`/`time` at
the pooled samples the way a sparse-flagged parameter's are pointed, and
reports the result among the sparse results.  A parameter with no
`FUN_sparse` still falls back to `FUN` on the mean profile.
parameter_arg_spec() caches the two calling conventions separately.

`auclast` and `aumclast` register the estimators, wrapping the existing
sparse machinery and reporting under the unified names, with the new
`auclast_se`/`auclast_df` and `aumclast_se`/`aumclast_df` companions
(underscore suffixes, following `sparse_auc_se`).  The values equal
`sparse_auclast` and `sparse_aumclast`, which are unchanged, and every
parameter derived from an AUC now picks the sparse estimate up by name:
`cl.last` on sparse data equals `cl.sparse.last`, and the rest of the
derived graph comes along without a hand-written sparse twin.

Two guards go with it.  A companion that only a sparse estimator
produces would silently give nothing for dense data, so assert_intervals()
refuses one instead; the older `sparse = TRUE` registrations keep being
skipped rather than refused.  And because the sparse variance theory
needs the linear trapezoidal rule, pk.nca() says once per call that
`auc.method` does not reach the estimators when the option asks for
anything else.

Dense results are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Record the behavior change (auclast and aumclast on sparse data now use
the sparse estimators, with _se/_df companions, and no longer follow
auc.method), the exported pk.nca.interval() signature change from the
one-pass refactor, and the verbose start-message class collapse.

The sparse vignette gains a short section showing the unified request
path as the recommended one, noting that the derived-parameter graph now
reaches sparse data and that the sparse_* names still work.  The full
rewrite waits for the deprecation.

Note in the plan that the companion-name convention is settled on the
underscore suffix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`auclast` and `aumclast` now carry the sparse estimators and every
derived parameter reaches them by name, so the parallel vocabulary has
nothing left to do.  Requesting one of `sparse_auclast`, `sparse_auc_se`,
`sparse_auc_df`, `sparse_aumclast`, `sparse_aumc_se`, `sparse_aumc_df`,
`cl.sparse.last`, `mrt.sparse.last`, `kel.sparse.last`,
`vss.sparse.last`, or `vz.sparse.last` in an interval specification warns
once per session with the unified name to use, and will be an error in
the next minor release.  The values are unchanged this release.

These are interval-specification columns rather than functions, so there
is no call for lifecycle to attach itself to; the warning is a classed
rlang condition raised where the intervals are validated.  It names only
the columns the specification itself wrote, not the deprecated
dependencies they drag in, which is why interval_requested_params() gains
an `expand` argument.

Ten of the eleven replacements give the same number as the name they
replace -- `kel.last`, like the rest of the `kel` family, is 1/MRT, so it
matches `kel.sparse.last`.  `vz.last` is the exception and the warning
says so:  it divides the clearance by the lambda.z fitted on the mean
profile instead of by 1/MRT, which is what made `vz.sparse.last`
numerically identical to `vss.sparse.last`.

The functions behind the estimators (`pk.calc.sparse_auclast()`,
`var_sparse_auc()`, `cov_holder()`, ...) are not deprecated:  they are
what the unified estimators call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… dense one

A sparse context used to select only the parameters flagged sparse in the
registry, which is now exactly the deprecated set, so the
`sparse_single_dose` preset emitted names that warn.  Sparseness is no
longer one of the selection dimensions:  a sparse design reports the same
parameters as the dense design it corresponds to -- `auclast` and
`aumclast` estimated with the sparse methods, the rest calculated from
the arithmetic-mean profile -- and differs only in imputing nothing.

What is never selected, in any context, is a parameter that *needs*
sparse data.  classify_sparse() now covers both kinds:  the deprecated
registrations it already found through the registry flag, and the
standard errors and degrees of freedom that only a sparse estimator
returns, which arrive as estimator output whether or not they were asked
for.  Those companions are added directly rather than through
deps_union(), which reaches everything sharing a calculation function and
would otherwise sweep in the parameter each one annotates along with its
whole downstream family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… sparse

`auclast` calculated from the pooled samples and `auclast` integrated
from one subject's profile are different estimators reported under one
name, and CDISC distinguishes them (SPARSEAL against AUCLST).
`pptestcd_cdisc` and `pptest_cdisc` gain a sparse-keyed form,
`list(sparse = list(dense = ..., sparse = ...))`, alongside the existing
route-keyed one, and resolve_cdisc_value() picks between them.

The consumer has the context it needs: pknca_cdisc_translate() holds the
PKNCAresults, a PKNCAconc is entirely sparse or entirely dense, and a
parameter with a FUN_sparse used it for every row of a sparse analysis,
so sparseness is settled once for the whole result rather than per row.

Unlike routes, the keys are a closed set, so both must be given.
`auclast` gets AUCLST/SPARSEAL and the matching test names; `aumclast`
gets only the test name, because CDISC has no separate code for a
sparsely estimated AUMClast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewrite the sparse vignette around the standard parameter names:  the
only difference from a dense analysis is `sparse = TRUE` on
`PKNCAconc()`, the derived-parameter section is the ordinary derived
graph rather than five hand-maintained twins, and the deprecated names
move to a table saying what to use instead and that they become errors in
the next minor release.

The Vz change gets its own explanation.  `vz.last` divides the clearance
by the lambda.z fitted on the mean profile, which the example data cannot
fit -- so it is NA there, where the retired `vz.sparse.last` always
produced a number by dividing by 1/MRT and thereby made Vz identical to
Vss.  `kel.last` is 1/MRT like the rest of the family, so it is
unchanged; `lambda.z` is what to ask for instead.

NEWS records the deprecation with its removal release, the Vz semantics,
the preset change, and the sparse-keyed CDISC mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ca-feasibility-68242b

# Conflicts:
#	NEWS.md
An imputed measurement belongs to no animal, but pk.nca.interval() ran
the imputation chain over the pooled samples as well as over the mean
profile, leaving `subject` one element short of `conc`.  Before the
as_sparse_pk() subject check became a real assertion this quietly
recycled into the covariance bookkeeping; since then it dies with an
internal assertion failure asking for a bug report.

The combination is now refused where it is diagnosed, with a
pknca_error_sparse_impute naming the interval, the imputation method, and
the parameters that would have been calculated from the pooled samples.
It is refused only when the imputation actually changed those samples:
one that finds a concentration already measured at the interval start
changes nothing and still calculates, and one requested alongside only
mean-profile parameters was never a problem.  The mean-profile imputation
path is untouched, and dense analyses are unaffected.

Which function calculates a parameter, whether it reads the pooled
samples, and whether it can run at all move into parameter_dispatch(), so
that the new check and the calculation loop cannot disagree about what
counts as a sparse calculation.

Imputation that a sparse estimator could use -- a known point with zero
variance, say -- is a statistical question, not a plumbing one, and is
left for later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`FUN_sparse` made the `sparse` flag redundant:  a parameter that only
sparse data can produce is exactly one with a sparse estimator and no
dense function.  The flag is retired.  `add.interval.col(sparse = TRUE)`
is an error pointing at the replacement pattern, an explicit FALSE is
still accepted, and registry entries no longer carry the field.

The seven flagged parameters are re-registered that way -- `FUN = NA`
with their old function as `FUN_sparse` and their old formals map as
`formalsmap_sparse` -- and everything that read the flag now derives it:
the calculation loop (where the skip condition collapses to "has a
function to call" and a sparse result is exactly one the estimator
produced), any_sparse_dense_in_interval(), classify_sparse(),
sparse_only_params(), and fun_sparse_params().

Two readers had to learn that a sparse-only parameter's calculation
function is its estimator, because both used to read `FUN` directly and
would otherwise have seen `NA`:  get.parameter.deps() would have hit its
"no function and multiple dependencies" abort for mrt/vss/vz.sparse.last,
and parameter_direct_refs() would have lost the dose that makes
cl.sparse.last uncalculable without dosing.  interval_col_fun() and
interval_col_formalsmap() answer that once for everyone.

Two behaviors are held fixed.  Requesting a deprecated name for dense data
is still a silent skip, so the dense-data guard refuses the sparse-only
parameters that are not deprecated -- today the four `_se`/`_df`
companions, and any newly registered sparse-only parameter, which is
refused rather than skipped now that the two are the same kind of thing.
And the values, row order, PPANMETH, classification table, CDISC codes,
and interval-table presets are unchanged.

The note that `auc.method` does not reach the sparse estimators now names
only the areas, since the seven newly-estimator-bearing parameters
include clearances and residence times that the option never described.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan sketched `lifecycle` deprecation with interval-spec translation
and said Vz and Kel both change.  As built, the deprecation is a classed
warning raised where interval requests are validated -- these are
interval-specification columns, not functions -- nothing is translated,
and only Vz changes value.  Retiring the `sparse` argument followed from
`FUN_sparse` making the flag derivable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parameters-available chunk in v03 assumed every parameter with FUN = NA
names at most one dependency; the sparse-only re-registrations made
mrt/vss/vz.sparse.last FUN = NA with two dependencies each, so the vapply
building the table failed with a length-2 result and broke the vignette
build on every R CMD check platform.  The helper now reports the sparse
estimator as the calculation function for a sparse-only parameter and
collapses multiple dependency names into one string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant