A Package That Argues With You: kkstatfun, One Analysis Start to Finish

One cohort, one deliberately misleading crude estimate, and a worked tour of kkstatfun — from 2×2 tables and colour-blindness audits to targeted causal inference, interrupted time series and cost-effectiveness curves. Then the public-health workflow it sits inside: getting NHIS data out of the system under ЗДОИ, the pipeline that cleans it, where AI actually helps, and how it reaches a manuscript.
R
kkstatfun
epidemiology
causal inference
public health
reproducibility
workflow
Author

Kostadin Kostadinov

Published

August 16, 2026

Modified

August 16, 2026

Every applied statistics package makes a claim about what analysis is. Most claim it is a sequence of tests: you hand over data, you get back a p-value, and the interpretation is left as an exercise. kkstatfun claims something else, and this post tries to demonstrate that claim rather than assert it — that an analysis is a chain of decisions, that most of those decisions should be made once and then reused, and that a good tool makes the uncomfortable ones visible instead of hiding them behind a default.

So this is not a feature list — the reference page already is one. It is a single worked analysis, run end to end, on a dataset built so that the honest answer and the obvious answer point in opposite directions. Every number below was produced by the code shown above it when this page was rendered. Nothing is transcribed.

Then, in §12, the part that actually pays for the package: the public-health workflow it sits inside. How the data gets out of the National Health Information System in the first place, what has to happen to it before an estimate is legitimate, and which parts of that I now hand to a machine.

NoteThe short version

A crude 2×2 table on this cohort says that giving antibiotics within four hours increases 30-day mortality by half. It is wrong, and it is wrong for the most ordinary reason in clinical epidemiology: the sickest patients are treated fastest. Six kkstatfun functions — stratification, Mantel–Haenszel, risk-ratio regression, standardised differences, IPTW and TMLE — each recover the truth from a different direction. The package’s contribution is not any one of those estimators. It is that they all take the same data frame, honour the same group_by(), and return the same shape of tibble, so you can afford to run all six.

1. Setup, and a palette that fails its own audit

The opening lines of every analysis I write are the same four:

library(kkstatfun)
kk_setup()
#> ✅ kkstatfun environment configured:
#>    - Cores: 5
#>    - Backend: cmdstanr
#>    - Scientific notation disabled
myfont <- "Roboto Condensed"
set_plot_font(myfont, size = 14)
#> 🔍 Searching for font: Roboto Condensed 
#>  Checking system source...
#>  ✓ Found in system source!
#> ✓ Updated ggplot2 theme with font 'Roboto Condensed'
#> ✅ Font setup complete. Using: Roboto Condensed
set_plot_colors(c("#D62828", "#003049", "#F77F00"))
#> ✓ Default discrete palette set from 3 anchor colour(s): #D62828, #003049, #F77F00

kk_setup() configures parallel workers and disables scientific notation. set_plot_font() searches your system, then local font files, then Google Fonts, and updates the default ggplot2 theme, so every figure in the session inherits it without a theme() call. set_plot_colors() does the same for the discrete palette: three anchor colours, expanded on demand and installed as the session default.

Three colours I like. But liking a palette is not evidence that it works, and version 1.2.0 added the functions that check:

kk_pal_check(c("#D62828", "#003049", "#F77F00")) |>
  filter(!distinct | !graphic)
#> # A tibble: 4 × 8
#>   color   vision  simulated contrast graphic nearest min_dist distinct
#>   <chr>   <fct>   <chr>        <dbl> <lgl>   <chr>      <dbl> <lgl>   
#> 1 #F77F00 normal  #F77F00       2.63 FALSE   #D62828    0.179 TRUE    
#> 2 #F77F00 deutan  #C0AA00       2.33 FALSE   #D62828    0.155 TRUE    
#> 3 #F77F00 tritan  #FF636C       2.90 FALSE   #D62828    0.120 TRUE    
#> 4 #F77F00 achroma #A0A0A0       2.63 FALSE   #D62828    0.162 TRUE

An empty result would mean the palette passes. This one does not. The three colours stay perceptually distinct under deuteranopia, protanopia, tritanopia and full achromatopsia — the distinct column is TRUE throughout, which is the check most people mean when they say “colour-blind safe”. What fails is a different thing: the orange has a WCAG contrast ratio against white of about 2.6, below the 3.0 needed for graphical objects. On a projector or a photocopy, an orange line on white disappears. An orange area is fine.

kk_show_cvd(c("#D62828", "#003049", "#F77F00"))
Figure 1: The requested palette simulated under four vision types. The categories never collapse into each other — the failure is contrast against the background, not confusability between hues.

I am keeping the palette, because I use those colours for fills and points rather than hairlines, and because I would rather hold a documented exception than pretend the audit passed. Had I wanted the machine to solve it instead, one call builds a palette that is safe by construction:

kk_pal_safe(4, seed_colors = "#003049")
#> [1] "#003049" "#A88E00" "#3453D3" "#704E00"
#> attr(,"min_dist")
#> [1] 0.1785405

That is the package’s whole attitude in one function. It did not silently fix my figure, and it did not refuse to run. It told me what was wrong, in units I can act on, and left the decision with me.

2. The data, and why it is synthetic

The cohort below is generated, not downloaded. That is a deliberate choice over a Kaggle dataset, and for one reason: I know the true effect. A real dataset lets you demonstrate that a function runs. A simulated one lets you check whether it is right — every estimator in this post can be scored against a number I put into the data myself.

Three thousand adults admitted with community-acquired pneumonia to eight hospitals, 2018–2024. The exposure is antibiotics within four hours of arrival; the outcome is death within 30 days. The generator has two features that matter. First, the sickest patients are treated fastest — severity raises the probability of early treatment, and it raises mortality independently. That is confounding by indication, and it is not a pathological edge case; it is the default state of every observational treatment comparison in acute medicine. Second, the true causal effect of early treatment is a risk ratio of 0.70, written into the data as log(0.70) in a log-binomial model.

The data generator (click to expand)
set.seed(2026)

n <- 3000
hospitals <- paste("Hospital", LETTERS[1:8])

cap <- tibble(
  id       = seq_len(n),
  hospital = sample(hospitals, n, replace = TRUE,
                    prob = c(.18, .16, .14, .12, .11, .10, .10, .09)),
  year     = sample(2018:2024, n, replace = TRUE),
  age      = pmin(pmax(round(rnorm(n, 68, 14)), 18), 98),
  sex      = sample(c("Male", "Female"), n, TRUE, c(.54, .46)),
  smoker   = rbinom(n, 1, 0.31),
  charlson = rpois(n, 1.8)
) |>
  mutate(
    # severity rises with age and comorbidity
    curb65 = pmin(rbinom(n, 4, plogis(-1.4 + 0.030 * (age - 68) + 0.16 * charlson)), 4),
    # the sickest are triaged fastest -> confounding by indication
    early_abx = rbinom(n, 1, plogis(-1.05 + 0.95 * curb65 +
                                      0.012 * (age - 68) - 0.20 * smoker)),
    # true log-binomial risk model; TRUE risk ratio of early_abx = 0.70
    p_death = pmin(exp(-3.55 + 0.78 * curb65 + 0.028 * (age - 68) + 0.22 * smoker +
                       0.17 * charlson + log(0.70) * early_abx), 0.95),
    death30 = rbinom(n, 1, p_death),
    # bacterial aetiology, procalcitonin, and a rapid antigen test
    bacterial = rbinom(n, 1, plogis(-0.35 + 0.35 * curb65 - 0.012 * (age - 68))),
    pct       = round(exp(rnorm(n, -0.55 + 1.55 * bacterial + 0.18 * curb65, 0.85)), 2),
    rapid_ag  = rbinom(n, 1, ifelse(bacterial == 1, 0.74, 0.07)),
    # 90-day time to death
    t_event = rexp(n, exp(-6.9 + 0.72 * curb65 + 0.026 * (age - 68) +
                            log(0.72) * early_abx)),
    time    = pmin(round(t_event), 90),
    status  = as.integer(t_event <= 90),
    timing  = factor(early_abx, 0:1,
                     c("Antibiotics ≥ 4 h", "Antibiotics < 4 h"))
  ) |>
  select(-p_death, -t_event)

Table 1 first, because you should always look before you estimate:

kk_table1(cap,
          by = "early_abx",
          variables = c("age", "sex", "curb65", "charlson", "smoker", "death30"))
#> # A tibble: 13 × 5
#>    Characteristic N     `0   N = 1,617`      `1   N = 1,383`      `p-value`
#>    <chr>          <chr> <chr>                <chr>                <chr>    
#>  1 __age__        3,000 65.00 (56.00, 75.00) 70.00 (61.00, 80.00) <0.001   
#>  2 __sex__        3,000 <NA>                 <NA>                 0.8      
#>  3 Female         <NA>  756 (47%)            639 (46%)            <NA>     
#>  4 Male           <NA>  861 (53%)            744 (54%)            <NA>     
#>  5 __curb65__     3,000 <NA>                 <NA>                 <0.001   
#>  6 0              <NA>  757 (47%)            205 (15%)            <NA>     
#>  7 1              <NA>  624 (39%)            555 (40%)            <NA>     
#>  8 2              <NA>  210 (13%)            446 (32%)            <NA>     
#>  9 3              <NA>  23 (1.4%)            155 (11%)            <NA>     
#> 10 4              <NA>  3 (0.2%)             22 (1.6%)            <NA>     
#> 11 __charlson__   3,000 2.00 (1.00, 2.00)    2.00 (1.00, 3.00)    <0.001   
#> 12 __smoker__     3,000 531 (33%)            412 (30%)            0.073    
#> 13 __death30__    3,000 161 (10.0%)          209 (15%)            <0.001

The imbalance is already visible in the curb65 rows. Among patients treated later, 47 % had a CURB-65 of zero; among those treated within four hours, 15 % did. The two columns are not describing the same patients, which is exactly what the rest of this post has to deal with.

3. The estimate that would get published

crude <- cap |> kk_twobytwo(early_abx, death30)

crude |>
  filter(Metric %in% c("Odds Ratio", "Relative Risk", "Risk Difference",
                       "Risk in Exposed", "Risk in Unexposed", "NNH")) |>
  select(Metric, Estimate, Lower, Upper) |>
  mutate_round(3)
#> # A tibble: 6 × 4
#>   Metric            Estimate  Lower  Upper
#>   <chr>                <dbl>  <dbl>  <dbl>
#> 1 Odds Ratio           1.61   1.29   2.00 
#> 2 Relative Risk        1.52   1.25   1.84 
#> 3 Risk Difference      0.052  0.028  0.075
#> 4 Risk in Exposed      0.151 NA     NA    
#> 5 Risk in Unexposed    0.1   NA     NA    
#> 6 NNH                 19.4   13.3   36.1

There it is. A relative risk of 1.52 (95% CI 1.25–1.84) — early antibiotics associated with a 50 % higher 30-day mortality, comfortably significant, with a number-needed-to-harm of about 19. Written up carelessly, this is a paper. Written up carelessly by someone with a hypothesis, it is a paper with a mechanism section.

Two things about kk_twobytwo() are worth noticing. It returns nineteen quantities, not one — odds ratio, risk ratio, risk difference, attributable and preventable fractions, phi, Yule’s Q and Y, Cohen’s h, excess cases per 1,000, NNH — because the choice between them is yours and the arithmetic should not be a reason to avoid reporting the right one. And it returns them as a tibble, which is why the line above could filter() and select() it like any other data frame, and why §5 can collect this estimate and three others into a single figure without a number being retyped. (kk_risk_plot() will take the whole object and draw a forest plot in one call; I use it while exploring, but it builds its own theme, so the figures below are drawn with kkplot() instead.)

4. group_by() is the whole design

Here is where the package’s core convention earns its keep. Every analysis function honours dplyr::group_by(): pipe in a grouped data frame and the analysis runs once per group, with the grouping columns prefixed to the result. Not a loop, not a lapply() with bind_rows() — the same function call, one verb longer.

cap |>
  group_by(curb65) |>
  kk_twobytwo(early_abx, death30) |>
  filter(Metric == "Relative Risk") |>
  select(curb65, Estimate, Lower, Upper, P_Value) |>
  mutate_round(3)
#> # A tibble: 5 × 5
#>   curb65 Estimate Lower Upper P_Value
#>    <dbl>    <dbl> <dbl> <dbl>   <dbl>
#> 1      0    0.369 0.114 1.20    0.081
#> 2      1    0.66  0.447 0.975   0.035
#> 3      2    0.748 0.549 1.02    0.068
#> 4      3    0.721 0.496 1.05    0.127
#> 5      4    0.909 0.797 1.04    0.586

Every stratum-specific risk ratio is below one. The crude estimate was above 1.5. This is Simpson’s paradox arriving in a form that will actually turn up in your data, and one added line of dplyr is what exposed it.

The pooled version, with the test for whether pooling was legitimate in the first place:

st <- cap |> kk_stratified_2x2(early_abx, death30, curb65)

st$pooled_rr
#> # A tibble: 1 × 4
#>   Estimate Lower Upper Method         
#>      <dbl> <dbl> <dbl> <chr>          
#> 1    0.690 0.561 0.849 Mantel-Haenszel
st$breslow_day
#> # A tibble: 1 × 5
#>   Test        Statistic    DF P_Value Interpretation        
#>   <chr>           <dbl> <dbl>   <dbl> <chr>                 
#> 1 Breslow-Day      1.52     4   0.823 ORs appear homogeneous

Mantel–Haenszel pooled risk ratio: 0.69 (95% CI 0.56–0.85). The truth I wrote into the generator was 0.70. Breslow–Day gives no evidence of heterogeneity across strata (p = 0.82), so a single pooled number is a fair summary here — which is a claim the function makes me check rather than assume.

plot_df <- cap |>
  group_by(curb65, timing) |>
  summarise(n = n(), deaths = sum(death30), .groups = "drop") |>
  mutate(risk = deaths / n,
         lo   = qbeta(.025, deaths, n - deaths + 1),
         hi   = qbeta(.975, deaths + 1, n - deaths)) |>
  filter(curb65 <= 3)

kkplot(plot_df, aes(curb65, risk, colour = timing)) +
  geom_line(linewidth = .8) +
  geom_pointrange(aes(ymin = lo, ymax = hi), size = .5) +
  scale_color_kk() +
  scale_y_continuous(labels = label_percent(1)) +
  labs(x = "CURB-65 score", y = "30-day mortality", colour = NULL,
       title = "Within every severity stratum, early antibiotics do better",
       subtitle = "Yet the crude comparison across strata says the opposite") +
  theme(legend.position = "top")
Figure 2: The same data twice. Within each severity stratum early treatment does better; pooled across strata it appears to do worse, because severity drives both treatment speed and death.

kkplot() is a drop-in replacement for ggplot() that adds capped axes and, with rangeframe = TRUE, Tufte’s range frame. The font and the palette are already set from §1, so the figure needs no styling code at all — scale_color_kk() and nothing else.

5. Adjusted, weighted, and doubly robust

Stratification handles one confounder. For several at once there are three routes, with different assumptions and, the point, one interface.

Regression, on the right scale. For a common binary outcome the odds ratio overstates the risk ratio, so kk_rr_reg() fits modified Poisson regression with robust standard errors (Zou 2004) and returns risk ratios directly. It runs the univariate models alongside the multivariable one, so confounding is visible as a shift between rows rather than something you have to reconstruct from two printouts:

rr <- kk_rr_reg(cap, "death30",
                c("early_abx", "curb65", "age", "smoker", "charlson"))

rr |>
  select(term, model_type, risk_ratio, conf.low, conf.high) |>
  mutate_round(3)
#> # A tibble: 10 × 5
#>    term      model_type    risk_ratio conf.low conf.high
#>    <chr>     <chr>              <dbl>    <dbl>     <dbl>
#>  1 early_abx univariate         1.52     1.25      1.84 
#>  2 curb65    univariate         2.31     2.14      2.49 
#>  3 age       univariate         1.04     1.03      1.05 
#>  4 smoker    univariate         1.15     0.945     1.41 
#>  5 charlson  univariate         1.24     1.16      1.31 
#>  6 early_abx multivariable      0.684    0.565     0.828
#>  7 curb65    multivariable      2.15     1.97      2.34 
#>  8 age       multivariable      1.02     1.02      1.03 
#>  9 smoker    multivariable      1.09     0.906     1.30 
#> 10 charlson  multivariable      1.13     1.06      1.19

The early_abx row moves from 1.52 univariately to 0.68 adjusted. That reversal is the entire post.

Weighting. Before weighting anything, look at the imbalance you are trying to remove:

cap |>
  select(early_abx, age, sex, smoker, charlson, curb65) |>
  kk_smd(treatment = "early_abx")
#> # A tibble: 5 × 8
#>   variable level type        group1 group2     smd abs_smd imbalanced
#> * <chr>    <chr> <chr>        <dbl>  <dbl>   <dbl>   <dbl> <lgl>     
#> 1 age      <NA>  continuous  65.6   70.7   -0.371   0.371  TRUE      
#> 2 sex      Male  categorical  0.532  0.538 -0.0110  0.0110 FALSE     
#> 3 smoker   1     categorical  0.328  0.298  0.0658  0.0658 FALSE     
#> 4 charlson <NA>  continuous   1.70   1.96  -0.193   0.193  TRUE      
#> 5 curb65   <NA>  continuous   0.696  1.45  -0.883   0.883  TRUE

A standardised mean difference of 0.88 on curb65 — nine times the conventional 0.1 threshold — is the numerical statement of the problem. Then inverse-probability-of-treatment weighting, with stabilised weights and robust confidence intervals:

ipw <- kk_iptw(cap, "early_abx", "death30",
               covariates = c("age", "sex", "smoker", "charlson", "curb65"))
ipw |> mutate_round(4)
#> # A tibble: 2 × 7
#>   estimand metric          estimate conf.low conf.high p.value conf.level
#>   <chr>    <chr>              <dbl>    <dbl>     <dbl>   <dbl>      <dbl>
#> 1 ATE      Risk difference  -0.0588  -0.0926    -0.025  0.0006       0.95
#> 2 ATE      Risk ratio        0.639    0.505      0.808  0.0002       0.95

Double robustness. Targeted maximum likelihood estimation fits both the outcome model and the treatment model and remains consistent if either is right:

tm <- kk_tmle(cap,
              outcome    = "death30",
              treatment  = "early_abx",
              covariates = c("age", "sex", "smoker", "charlson", "curb65"))
tm |> mutate_round(4)
#> # A tibble: 1 × 8
#>       ate std.error conf.low conf.high p.value   ey1   ey0 conf.level
#>     <dbl>     <dbl>    <dbl>     <dbl>   <dbl> <dbl> <dbl>      <dbl>
#> 1 -0.0488    0.0124  -0.0731   -0.0246  0.0001 0.104 0.153       0.95

The counterfactual risks come back as ey1 and ey0: 10.4 % if everyone had been treated early, 15.3 % if nobody had — an absolute difference of 4.9 percentage points, which kk_nnt() turns into the number a clinician actually wants:

kk_nnt(estimate = tm$ate, type = "risk_diff",
       ci_low = tm$conf.low, ci_high = tm$conf.high)
#> # A tibble: 1 × 6
#>   Metric Estimate Lower Upper RD_Estimate Note       
#>   <chr>     <dbl> <dbl> <dbl>       <dbl> <chr>      
#> 1 NNT        20.5  13.7  40.7     -0.0488 Significant

Because every one of those functions returned a tibble, collecting them into one figure is a bind_rows() rather than a transcription exercise — and the figure is the argument of this whole post:

crude_rr <- crude |> filter(Metric == "Relative Risk")
mh_rr    <- st$pooled_rr
pois_rr  <- rr |> filter(term == "early_abx", model_type == "multivariable")
ipw_rr   <- ipw |> filter(metric == "Risk ratio")

estimates <- tibble(
  method = c("Crude 2×2", "Mantel–Haenszel", "Modified Poisson", "IPTW (ATE)"),
  fn     = c("kk_twobytwo()", "kk_stratified_2x2()", "kk_rr_reg()", "kk_iptw()"),
  rr     = c(crude_rr$Estimate, mh_rr$Estimate, pois_rr$risk_ratio, ipw_rr$estimate),
  lo     = c(crude_rr$Lower,    mh_rr$Lower,    pois_rr$conf.low,   ipw_rr$conf.low),
  hi     = c(crude_rr$Upper,    mh_rr$Upper,    pois_rr$conf.high,  ipw_rr$conf.high)
) |>
  mutate(label  = paste0(method, "\n", fn),
         label  = factor(label, levels = rev(label)),
         honest = rr < 1)

kkplot(estimates, aes(rr, label, colour = honest)) +
  geom_vline(xintercept = 1, colour = "grey60") +
  geom_vline(xintercept = 0.70, linetype = "dashed", colour = "#F77F00",
             linewidth = .8) +
  annotate("text", x = 0.70, y = levels(estimates$label)[4], vjust = -1.4,
           hjust = 1.05, label = "simulated truth", size = 3.5,
           colour = "#B85E00", family = myfont) +
  geom_pointrange(aes(xmin = lo, xmax = hi), size = .55, linewidth = .8) +
  scale_colour_manual(values = c(`TRUE` = "#003049", `FALSE` = "#D62828"),
                      guide = "none") +
  scale_x_continuous(transform = "log", breaks = c(0.5, 0.7, 1, 1.5, 2)) +
  coord_cartesian(clip = "off") +
  labs(x = "Risk ratio (log scale)", y = NULL,
       title = "One contrast, four estimators, one truth",
       subtitle = "Only the crude estimate lands on the wrong side of the null") +
  theme(plot.margin = margin(18, 10, 5, 5))
Figure 3: Four estimators of the same causal contrast, against the risk ratio of 0.70 written into the data generator. TMLE is not shown because it returns a risk difference; its implied ratio is ey1/ey0 = 0.68.
ImportantWhat the package cannot do

All of §5 assumes I measured the confounders that matter, and in this dataset I did — because I created them. In a real cohort that assumption is untestable, and no amount of double robustness rescues you from a confounder nobody recorded. That is not a limitation of the software; it is a limitation of the design, and the honest move is to say so in the paper. When the treatment was switched on by a rule rather than by a clinician, §8 gets you an estimate that does not need the assumption at all.

6. Diagnostics: validating a marker

Different question, same grammar. Procalcitonin as a marker of bacterial aetiology:

cap |> kk_roc(bacterial, pct) |> mutate_round(3)
#> # A tibble: 1 × 11
#>     auc auc_low auc_high youden_j optimal_threshold sensitivity specificity
#>   <dbl>   <dbl>    <dbl>    <dbl>             <dbl>       <dbl>       <dbl>
#> 1 0.898   0.887    0.909    0.625              1.68       0.767       0.858
#>     ppv   npv     n conf.level
#>   <dbl> <dbl> <dbl>      <dbl>
#> 1 0.847 0.782  3000       0.95

AUC with a DeLong interval, the Youden-optimal cut-off, and the sensitivity, specificity, PPV and NPV at that cut-off, in one row. And because the function is grouped-data-aware, discrimination across severity strata is the same call plus one line:

cap |>
  group_by(curb65) |>
  kk_roc(bacterial, pct) |>
  select(curb65, auc, auc_low, auc_high, n) |>
  mutate_round(3)
#> # A tibble: 5 × 5
#>   curb65   auc auc_low auc_high     n
#>    <dbl> <dbl>   <dbl>    <dbl> <dbl>
#> 1      0 0.889   0.869    0.91    962
#> 2      1 0.894   0.876    0.912  1179
#> 3      2 0.908   0.886    0.929   656
#> 4      3 0.9     0.855    0.946   178
#> 5      4 0.984   0.946    1        25

For a binary test rather than a continuous marker, kk_diagnostic() gives the confusion-matrix work-up:

cap |> kk_diagnostic(bacterial, rapid_ag) |> mutate_round(3)
#> # A tibble: 7 × 2
#>   Metric      Value
#>   <chr>       <dbl>
#> 1 Sensitivity 0.75 
#> 2 Specificity 0.928
#> 3 PPV         0.914
#> 4 NPV         0.784
#> 5 Accuracy    0.838
#> 6 F1 Score    0.824
#> 7 AUC         0.839

A model is not finished when it discriminates. kk_reg() auto-detects the outcome type and keeps the fitted object attached, so kk_model() hands it back for anything downstream instead of forcing a refit:

fit <- kk_reg(cap |> mutate(death30 = factor(death30)),
              outcome    = "death30",
              predictors = c("early_abx", "curb65", "age", "charlson"))

cap$p_death <- predict(kk_model(fit), type = "response")

kk_calibration(cap, death30, p_death) |>
  select(hl_chi2, df, p.value, oe_ratio, calib_slope, calib_intercept) |>
  mutate_round(3)
#> # A tibble: 1 × 6
#>   hl_chi2    df p.value oe_ratio calib_slope calib_intercept
#>     <dbl> <dbl>   <dbl>    <dbl>       <dbl>           <dbl>
#> 1    7.71     8   0.462        1           1               0

And discrimination plus calibration still does not tell you whether using the model helps anyone. Decision-curve analysis asks the clinical question directly — across the range of threshold probabilities at which a clinician would act, does the model beat treating everybody and treating nobody?

kk_decision_curve(cap, death30, p_death, thresholds = seq(.05, .35, .05)) |>
  tidyr::pivot_wider(names_from = strategy, values_from = c(net_benefit, std_net_benefit)) |>
  select(threshold, net_benefit_Model, `net_benefit_Treat all`) |>
  mutate_round(4)
#> # A tibble: 7 × 3
#>   threshold net_benefit_Model `net_benefit_Treat all`
#>       <dbl>             <dbl>                   <dbl>
#> 1      0.05            0.0819                  0.0772
#> 2      0.1             0.0595                  0.0259
#> 3      0.15            0.048                  -0.0314
#> 4      0.2             0.034                  -0.0958
#> 5      0.25            0.0251                 -0.169 
#> 6      0.3             0.0218                 -0.252 
#> 7      0.35            0.0195                 -0.349

Above a threshold of about 10 %, treat-all goes negative and the model does not. That is the sentence a clinical reader needs, and it is not derivable from an AUC.

7. Time to event

kk_survival_plot(cap, time, status, timing) draws the curves, the confidence bands, the log-rank p and a risk table in a single call, and that is what I use while exploring. It is not what I put in a paper. The object it returns is a ggsurvfit composite that carries its own theme, so it ignores the font and palette set in §1, and its risk table has no rules to guide the eye across the row.

For the figure that ships, I rebuild it with kkplot() — which is simply ggplot() with capped axes, already wearing the house font and palette — and stack the risk table underneath with patchwork, with visible rules between the strata:

km   <- survfit(Surv(time, status) ~ timing, data = cap)
lvls <- levels(cap$timing)
lab  <- function(x) factor(sub(".*=", "", x), levels = lvls)
xsc  <- scale_x_continuous(breaks = seq(0, 90, 15),
                           expand = expansion(add = c(4, 6)))

# curves
km_dat <- broom::tidy(km) |> mutate(timing = lab(strata))

p_km <- kkplot(km_dat, aes(time, estimate, colour = timing, fill = timing)) +
  geom_ribbon(aes(ymin = conf.low, ymax = conf.high), alpha = .15, colour = NA) +
  geom_step(linewidth = .9) +
  scale_color_kk() + scale_fill_kk() + xsc +
  scale_y_continuous(labels = label_percent(1), limits = c(.7, 1)) +
  labs(x = NULL, y = "Survival", colour = NULL, fill = NULL,
       title = "Ninety-day survival by antibiotic timing",
       subtitle = "Unadjusted — the same confounding as before, in survival form") +
  theme(legend.position = "top",
        axis.text.x = element_blank(), axis.ticks.x = element_blank())

# risk table
s   <- summary(km, times = seq(0, 90, 15))
tab <- tibble(time = s$time, timing = lab(as.character(s$strata)),
              n_risk = s$n.risk, n_event = s$n.event) |>
  group_by(timing) |>
  mutate(deaths = cumsum(n_event)) |>
  ungroup()

p_tab <- kkplot(tab, aes(time, factor(timing, levels = rev(lvls)))) +
  geom_hline(yintercept = c(0.5, 1.5, 2.5), colour = "grey80", linewidth = .4) +
  geom_text(aes(label = paste0(n_risk, " (", deaths, ")"), colour = timing),
            size = 3.3, family = myfont, show.legend = FALSE) +
  scale_color_kk() + xsc +
  labs(x = "Days since admission", y = NULL,
       caption = "Number at risk (cumulative deaths)") +
  theme(panel.grid = element_blank(),
        axis.text.y = element_text(hjust = 0),
        plot.caption = element_text(hjust = 0, colour = "grey40"))

p_km / p_tab + plot_layout(heights = c(4, 1))
Figure 4: Kaplan–Meier curves with the risk table built as a second panel: rules between strata, counts coloured to match their curve, one shared axis. Unadjusted, so it carries the same confounding as the crude 2×2.

The log-rank test itself is a separate function, because a figure is not a result:

kk_logrank(cap, time, status, timing) |>
  select(group, n, observed, expected, oe_ratio, chisq, p_value) |>
  mutate_round(3)
#> # A tibble: 2 × 7
#>   group                 n observed expected oe_ratio chisq p_value
#>   <chr>             <dbl>    <dbl>    <dbl>    <dbl> <dbl>   <dbl>
#> 1 Antibiotics ≥ 4 h  1617      258     315.    0.819  22.9       0
#> 2 Antibiotics < 4 h  1383      316     259.    1.22   22.9       0
cox <- kk_coxph(cap, time, status,
                c("early_abx", "curb65", "age", "charlson"))

cox |>
  filter(model_type == "multivariable") |>
  select(term, hazard_ratio, conf.low, conf.high, ph_p, ph_global_p) |>
  mutate_round(4)
#> # A tibble: 4 × 6
#>   term      hazard_ratio conf.low conf.high   ph_p ph_global_p
#>   <chr>            <dbl>    <dbl>     <dbl>  <dbl>       <dbl>
#> 1 early_abx        0.728    0.607     0.873 0.517       0.0058
#> 2 curb65           2.06     1.88      2.27  0.554       0.0058
#> 3 age              1.03     1.02      1.04  0.031       0.0058
#> 4 charlson         1.02     0.964     1.08  0.0023      0.0058

Adjusted hazard ratio for early treatment: 0.73. Note the columns you did not ask for. ph_p is the Schoenfeld test per term and ph_global_p the global one, returned beside the estimate rather than left to a separate cox.zph() call — and here the global test is significant, driven by age. Proportional hazards does not hold, which means the hazard ratio is an average over a changing effect.

The reasonable response is an estimand that does not need the assumption. Restricted mean survival time is the area under the curve to a horizon you choose, in days:

kk_rmst(cap, time, status, timing, tau = 90) |> mutate_round(3)
#> # A tibble: 4 × 8
#>   group                                                     tau   rmst     se
#>   <chr>                                                   <dbl>  <dbl>  <dbl>
#> 1 Antibiotics ≥ 4 h                                          90 82.5    0.498
#> 2 Antibiotics < 4 h                                          90 79.3    0.621
#> 3 RMST difference (Antibiotics < 4 h - Antibiotics ≥ 4 h)    90 -3.23   0.796
#> 4 RMST ratio (Antibiotics < 4 h / Antibiotics ≥ 4 h)         90  0.961 NA    
#>   conf.low conf.high p.value conf.level
#>      <dbl>     <dbl>   <dbl>      <dbl>
#> 1   81.6       83.5       NA       0.95
#> 2   78.1       80.5       NA       0.95
#> 3   -4.79      -1.67       0       0.95
#> 4    0.942      0.98       0       0.95

Reported as “on average, X fewer days alive out of 90”, this is both assumption-free and comprehensible to a non-statistician — two properties that rarely travel together. (Unadjusted, it still carries the confounding; the point here is the estimand, not the estimate.)

8. When assignment was a rule, not a judgement

The functions above all need the no-unmeasured-confounding assumption. The quasi-experimental family, new in 1.3.0, does not: when an intervention was switched on by a policy date, an eligibility threshold or a staggered rollout, that rule identifies the effect on its own.

A stewardship programme introduced in month 25 of a 48-month series of antibiotic consumption:

Monthly stewardship series (click to expand)
set.seed(11)
steward <- tibble(
  month = 1:48,
  ddd   = round(92 - 0.18 * month +
                ifelse(month > 24, -9.5 - 0.75 * (month - 24), 0) +
                3.2 * sin(2 * pi * month / 12) + rnorm(48, 0, 2.4), 1)
)
its <- kk_its(steward, ddd, month,
              intervention_time = 24,
              harmonic = 1, period = 12)

its |> select(term, estimate, conf.low, conf.high, p.value) |> mutate_round(3)
#> # A tibble: 6 × 5
#>   term           estimate conf.low conf.high p.value
#>   <chr>             <dbl>    <dbl>     <dbl>   <dbl>
#> 1 intercept        91.3     89.4      93.2     0    
#> 2 baseline_trend   -0.192   -0.312    -0.072   0.002
#> 3 level_change     -6.92   -11.3      -2.57    0.003
#> 4 trend_change     -0.828   -1.05     -0.61    0    
#> 5 sin1              2.92     1.84      4.00    0    
#> 6 cos1              0.206   -1.04      1.45    0.74

Segmented regression with Newey–West standard errors and a seasonal harmonic, separating the two things a policy can do: an immediate level change of -6.9 DDD per 100 bed-days, and a trend change of -0.83 per month on top of the pre-existing decline. Reporting only the first would badly understate the programme; reporting only their sum would overstate the launch.

The counterfactual series and the cumulative impact come attached as attributes:

attr(its, "impact") |>
  select(final_observed, final_counterfactual, final_relative,
         cumulative_difference) |>
  mutate_round(1)
#> # A tibble: 1 × 4
#>   final_observed final_counterfactual final_relative cumulative_difference
#>            <dbl>                <dbl>          <dbl>                 <dbl>
#> 1           54.7                 82.3           -0.3                 -442.
cf <- attr(its, "counterfactual")

kkplot(cf, aes(time)) +
  geom_ribbon(aes(ymin = cf.low, ymax = cf.high), fill = "grey80", alpha = .5) +
  geom_line(aes(y = counterfactual), linetype = "dashed", colour = "#003049") +
  geom_point(aes(y = observed), colour = "#D62828", size = 1.5) +
  geom_line(aes(y = fitted), colour = "#D62828", linewidth = .8) +
  geom_vline(xintercept = 24.5, colour = "grey40") +
  annotate("text", x = 25.5, y = 95, hjust = 0, size = 3.6, colour = "grey30",
           family = myfont, label = "Stewardship programme") +
  labs(x = "Month", y = "DDD per 100 bed-days",
       title = "Interrupted time series: antibiotic consumption",
       subtitle = "Dashed line and band: the counterfactual, had nothing changed")
Figure 5: Observed series against the modelled counterfactual — what consumption would have been had the pre-intervention level, trend and seasonality simply continued.

With a control group instead of a single series, the same question becomes difference-in-differences — four hospitals adopting in 2021, four not:

Hospital panel (click to expand)
set.seed(77)
panel <- expand.grid(hospital = hospitals, year = 2018:2024,
                     stringsAsFactors = FALSE) |>
  as_tibble() |>
  mutate(treated = as.integer(hospital %in% hospitals[1:4]),
         post    = as.integer(year >= 2021),
         resist  = round(22 + 2.1 * treated - 0.55 * (year - 2018) +
                         rnorm(n(), 0, 1.6) - 3.4 * treated * post, 1))
kk_did(panel, resist, treated, post, unit = hospital, time = year) |>
  select(term, estimate, conf.low, conf.high, p.value, n_clusters) |>
  mutate_round(3)
#> # A tibble: 1 × 6
#>   term  estimate conf.low conf.high p.value n_clusters
#>   <chr>    <dbl>    <dbl>     <dbl>   <dbl>      <dbl>
#> 1 did      -4.26    -7.27     -1.25   0.012          8

Two-way fixed effects with standard errors clustered on the hospital, recovering the −3.4 percentage-point effect I built in. What makes this family usable rather than merely available is that each function returns its own identifying diagnostic: kk_event_study() for the parallel-trends leads, kk_rd_density() for manipulation at an RD threshold, the first-stage F for an instrument, the placebo distribution for a synthetic control. The assumption and the estimate arrive together, which makes leaving the assumption out of the paper a deliberate act rather than an oversight.

9. Costing it

Suppose the stewardship programme comes with a diagnostic strategy decision. Three options, their costs in euro and their QALYs:

strategies <- tibble(
  strategy = c("Standard care", "PCT-guided therapy", "Rapid PCR panel"),
  cost     = c(2840, 3260, 4510),
  qaly     = c(6.42, 6.61, 6.73)
)

kk_icer(strategies, cost, qaly, strategy) |> mutate_round(1)
#> # A tibble: 3 × 7
#>   strategy            cost effect inc_cost inc_effect   icer status  
#>   <chr>              <dbl>  <dbl>    <dbl>      <dbl>  <dbl> <chr>   
#> 1 Standard care       2840    6.4       NA       NA      NA  frontier
#> 2 PCT-guided therapy  3260    6.6      420        0.2  2210. frontier
#> 3 Rapid PCR panel     4510    6.7     1250        0.1 10417. frontier

Strategies ranked by effect, incremental ratios computed against the next-best non-dominated option, and dominance flagged. All three sit on the efficiency frontier here. At a willingness to pay of €32,000 per QALY — roughly twice Bulgarian GDP per capita, the upper end of the WHO-CHOICE convention — net monetary benefit picks the winner:

kk_nmb(strategies, cost, qaly, wtp = 32000, strategy = strategy) |>
  mutate_round(2)
#> # A tibble: 3 × 7
#>   strategy             wtp  cost effect    nmb   nhb optimal
#>   <chr>              <dbl> <dbl>  <dbl>  <dbl> <dbl> <lgl>  
#> 1 Standard care      32000  2840   6.42 202600  6.33 FALSE  
#> 2 PCT-guided therapy 32000  3260   6.61 208260  6.51 FALSE  
#> 3 Rapid PCR panel    32000  4510   6.73 210850  6.59 TRUE

A point estimate is not a decision, though. With probabilistic draws over cost and effect, the acceptability curve shows how much of that ranking is real and how much is noise:

PSA draws (click to expand)
set.seed(303)
psa <- bind_rows(lapply(seq_len(2000), function(i) {
  tibble(sim      = i,
         strategy = strategies$strategy,
         cost     = rnorm(3, strategies$cost, c(320, 380, 520)),
         qaly     = rnorm(3, strategies$qaly, c(0.42, 0.44, 0.48)))
}))
ce <- kk_ceac(psa, sim, strategy, cost, qaly, wtp = seq(0, 80000, 2000))

kkplot(ce, aes(wtp, prob_ce, colour = strategy)) +
  geom_line(linewidth = .9) +
  geom_vline(xintercept = 32000, linetype = "dashed", colour = "grey45") +
  annotate("text", x = 33000, y = .05, hjust = 0, size = 3.6, colour = "grey35",
           family = myfont, label = "2 × GDP per capita") +
  scale_color_kk() +
  scale_y_continuous(labels = label_percent(1), limits = c(0, 1)) +
  scale_x_continuous(labels = label_number(scale = 1e-3, suffix = "k")) +
  labs(x = "Willingness to pay (EUR per QALY)", y = "Probability cost-effective",
       colour = NULL, title = "Cost-effectiveness acceptability curve",
       subtitle = "2,000 probabilistic draws per strategy") +
  theme(legend.position = "top")
Figure 6: Cost-effectiveness acceptability curve from 2,000 probabilistic draws. The optimal strategy is never more than 45 % likely to be optimal — the honest headline.

The rapid panel has the best expected net benefit at €32,000 and is optimal in only about 44 % of draws. Both statements are true, and a decision-maker needs the second one.

10. Everything else, briefly

The same conventions run through parts of the package this cohort has no use for. Sample size for the study I have just pretended to analyse:

kk_sample_size_epi(design = "cohort", p0 = 0.12, rr_or = 0.7,
                   power = 0.8, alpha = 0.05) |>
  select(design, p0, p1, alpha, power, n_group1, n_total)
#> # A tibble: 1 × 7
#>   design    p0    p1 alpha power n_group1 n_total
#>   <chr>  <dbl> <dbl> <dbl> <dbl>    <dbl>   <dbl>
#> 1 cohort  0.12 0.084  0.05   0.8     1109    2218

Outbreak dynamics, where the reproduction number and the final attack rate follow from the transmission parameters:

kk_r0(beta = 0.42, gamma = 0.2)
#> # A tibble: 1 × 2
#>      R0 method
#>   <dbl> <chr> 
#> 1   2.1 params
kk_final_size(R0 = 2.1)
#> # A tibble: 1 × 3
#>      R0 attack_rate herd_immunity
#>   <dbl>       <dbl>         <dbl>
#> 1   2.1       0.822         0.524

And, because most of my data comes out of Bulgarian national systems, the registry helpers: extract_egn_info() parses date of birth, sex, age and birth region out of an EGN; kk_std_rates() and kk_smr() do direct and indirect standardisation; kk_nb_scan() looks for space–time clusters under overdispersion.

11. The philosophy, stated plainly

Having shown it, let me name it. Four commitments, in the order they matter.

Tidy data in, tidy data out. Every function takes a data frame first and returns a tibble. Never a print-only object you have to scrape, never a list of matrices, never a summary() you parse with regular expressions. This is why §5 could collect four estimates from four different estimators into one figure without a number being retyped, and why any result in this post could be written to a CSV and included in a manuscript with no step in between.

group_by() is not decoration. Stratified analysis is the default posture of epidemiology, not an advanced option. When running an analysis by site, by sex or by severity costs one line, you actually do it — and effect modification stops being something you notice at peer review.

Decide once, then reuse. Which confidence-interval method for a rate? Which correction for a sparse cell? Which test when the assumption fails? Each of those was decided once, tested against the worked examples in Rothman, Zar and Sheskin, and frozen in a function. The value is not brevity. It is that my 2023 paper and my 2026 paper used the same method, and a reviewer asking “which one?” gets an answer rather than an archaeology project.

Return the diagnostic beside the estimate. kk_coxph() hands back the Schoenfeld test with the hazard ratio. kk_stratified_2x2() hands back Breslow–Day with the pooled estimate. kk_pal_check() fails my own palette. The design principle is that the check you would rather skip should arrive unrequested, in the same object, so that omitting it from the write-up is a choice you have to make on purpose.

WarningWhat this is not

It is not a replacement for understanding the method. kk_tmle() will happily estimate an average treatment effect from a dataset where the exchangeability assumption is nonsense, and it will do so with a tidy confidence interval and no complaint. Convenience functions lower the cost of doing an analysis, including a bad one. The books remain the load-bearing part; the package is only the part that stops me re-deriving them at midnight.

12. Where this actually gets used: NHIS data

None of the above is hypothetical tooling. The synthetic cohort is a teaching device. The real work is population-level, and the analysis is its last step. A public-health question here — how many people were treated for a condition, where, with what, and what changed after a policy — is answered from the National Health Information System (НЗИС, NHIS): the national e-health infrastructure through which prescriptions, referrals, hospitalisations and immunisations are recorded. It covers the country rather than a sample, and it is where most of my data comes from.

That coverage is also its main analytical hazard. The NHIS records what clinicians entered into it, which is not the same as what happened to patients: completeness differs by module, by year and by how strongly a given field was enforced when the record was made. A count that rises in 2022 may be a rise in disease, a rise in reporting, or a change in what the system required — and you cannot tell which from the file. So the first analysis on any NHIS extract is always the same one: plot the raw counts by month and look for the step change that corresponds to a software release rather than an epidemic.

Where the question is about money rather than activity, the National Health Insurance Fund (НЗОК, NHIF) is the second source: claims under clinical pathways (КП), reimbursed medicines by ATC code, dialysis, dental. The two are not interchangeable. The NHIS records clinical activity; the NHIF records what was paid for. Care that generates no claim exists only in the first, and a claim that was never clinically recorded exists only in the second, so which one you ask decides what your denominator means.

Asking for the data

Very little of either is published in usable form, so the work starts with a written request under the Access to Public Information Act (ЗДОИ). After a fair number of these I would say the request itself is a skill, with rules:

  • Ask for aggregates, never person-level records. Counts by code, month, district (област), age band and sex. That is not only the lawful form of the request — it removes the institution’s best reason to refuse, because with no personal data there is no data-protection ground for saying no.
  • Name the fields and the period exactly. “Брой хоспитализации по КП 39 за периода 2019–2024 г., по области и по пол” is answerable by a clerk running a query. “Data on hospitalisations” is answerable only by a meeting.
  • Say the format in the request. Write .csv or .xlsx explicitly, or the answer arrives as a scan of a printed table and the institution has complied.
  • Ask for the codelist alongside the data — the version of the МКБ, КП or ATC list in force in each year of the period. Otherwise you will spend a week reconstructing which code was renumbered when.
  • Pre-empt the small-cell refusal. Propose the suppression rule yourself: cells below five reported as “<5”. It turns a likely refusal into a qualified release.
  • Keep the reference number and the response date. They become the provenance record in the deposit metadata, and they are what makes the resulting dataset citable rather than anecdotal.

The reply usually arrives inside the statutory two weeks, and it is usually incomplete in some interesting way. That is normal, and it is why the next step exists.

The pipeline

What arrives is never clean. Merged header cells, footer totals rows, comma decimal separators, Cyrillic and Latin mixed inside one column, МКБ codes that Excel has helpfully converted to dates, and a schema that changes between years. So the pipeline is four numbered scripts and one rule:

scripts/01-ingest.R      # raw -> tidy, one function per source file
scripts/02-clean.R       # validation, deduplication, type resolution
scripts/03-standardise.R # currency, encoding, codelist crosswalks
scripts/04-publish.R     # sha256 hashes, metadata, deposit package

The rule is that data/raw/ is never edited. Every correction happens in a script, with a comment saying what was wrong — which means the pipeline can always be re-run from the true origin and “what exactly did you change?” has an answer.

Three conventions do most of the work. Money is stored in both BGN and EUR at the fixed rate of 1.95583, which matters across the 2025–2026 adoption boundary, where a careless join double-converts a column and nobody notices. CSV output is UTF-8 with BOM, so Cyrillic survives the round trip through Excel. And every dataset ships a data-dictionary.csv with one row per column: Bulgarian label, English label, type, unit, codelist, the original Cyrillic field name from the source, and the caveats. The dictionary is the artefact that survives schema drift. The code is comparatively disposable.

Alongside it goes a data-quality-report.md logging, per source file, the rows ingested, the rows surviving, and each irreversible decision. That log is more valuable than perfect code, because it is the only record of judgements that the data itself no longer shows.

The finished product is deposited on Zenodo with a DOI, CC-BY, and a CITATION.cff — which turns a cleaning job into a citable output, and means the next person (often me, two years later) starts from the clean version.

Then, and only then, the analysis

At this point the file is a tidy panel of counts by district, year, age band and code, and everything in §§3–9 applies, with the population-health functions doing the work the clinical ones did above. Rates per person-time with exact Poisson intervals from kk_incidence_rate(). Direct age-standardisation against the European Standard Population with kk_std_rates(), or kk_std_rates_ci() where the counts are sparse enough that the Gamma interval matters. Indirect standardisation and an SMR by district with kk_smr(). Period trends with kk_apc(). The spatial-temporal outlier hunt with kk_nb_scan().

When the question is about a policy — a new reimbursement rule, a pathway price change, a programme rolled out in some districts before others — it is §8 verbatim: kk_its() on the national monthly series, kk_did() or kk_did_staggered() across districts, with kk_event_study() for whether the parallel-trends story survives contact with the pre-period. National data suits those designs unusually well, because the intervention really was switched on by a rule with a date, and the date is in the Държавен вестник.

That is the argument for building the package. The interesting part of a public-health analysis is deciding what to ask for and what the resulting estimate can carry. The part in between should be a function call I have already tested.

Where AI fits, and where it does not

I run this with Claude Code, and what made it useful rather than merely fast is skills: plain Markdown files in ~/.claude/skills/, one per recurring task, each stating the conventions for that task. The data-pipeline skill holds the directory layout, the currency rule, the encoding rule and the data-dictionary schema. kkstatfun-epi says to reach for kk_* functions over epitools or gtsummary, and how they compose. bibliography-management holds the citation rules below. humanizer-bg strips the English calques out of Bulgarian prose, and humanizer-en does the same job in the other direction — this post went through it.

The distinction I would insist on is this: the conventions are written by me, in version control, once. The model applies them. That is a different activity from asking a chatbot how to clean health data. The model is fast at the mechanical middle: parsing a malformed sheet, writing the ingest function for the twelfth source file, drafting the data dictionary from the column names, catching the year the schema changed, putting a ЗДОИ request into the register’s own idiom. It is not the thing deciding whether a join is legitimate or what an estimate means. Every number it produces gets checked against the source, because a fluent wrong answer is the characteristic failure mode and costs nothing to generate.

The small tools around it matter more than they sound. anydoc converts the legacy .doc and .xls files institutions still send; pandoc will silently emit binary garbage for an .xls instead of failing, which is exactly the class of error that ends up in a published table. DBeaver is where I check a join before writing analysis code against it. Local models through Ollama take the bulk mechanical passes — summarising, extracting, first-draft translation — for text that should not leave the machine. And all of it is in Git, because the value of version control here is not collaboration but a record of my own reasoning: the commit that dropped 1,400 rows says why it dropped them.

Writing it up

The manuscript is Quarto, the bibliography is a plain .bib file next to it, and entries are fetched by DOI or PMID rather than typed:

getbib 10.1016/S0140-6736(20)30183-5 33301246
getbib -c    # audit: duplicate keys, and entries with no abstract

getbib populates the abstract field automatically, and that field is the point. Each entry stores what the source actually found, so when I need support for a claim I search my own bibliography and what comes back is a finding rather than a title. A claim followed by [@key] has to be supported by the abstract recorded in that entry. It is the best guard I have against citing a paper for something it does not say, which fluent drafting makes easier rather than harder. Bulgarian sources — NHIS and NHIF releases, legal acts, NCPHA reports — are not in PubMed and have no abstract to fetch, so I write the findings statement by hand or the entry does not get cited at all.

The rest follows the structure in my writing setup post: numbered scripts produce output/figures/ and output/tables/, the manuscript consumes them, no number is ever transcribed by hand, and 00_run_all.R regenerates everything from the raw files in one command. kkstatfun occupies exactly one layer of that stack — the layer between a cleaned dataset and a defensible estimate. It is a small layer. It is also the one where the mistakes are hardest to see afterwards, which is why it is the one I automated first.

Reproducing this post

Everything here runs from a clean R session with the package installed:

if (!require("devtools")) install.packages("devtools")
devtools::install_github("kostadinoff/kkstatfun")

The .qmd source for this page is in the website repository; every figure and every number above was produced when it rendered. If you cite the package, the concept DOI 10.5281/zenodo.18936019 always resolves to the latest release, or run citation("kkstatfun") for the version you actually used.

Session info
sessionInfo()$otherPkgs |> names()
#> [1] "survival"  "patchwork" "scales"    "ggplot2"   "dplyr"     "kkstatfun"
Session info
packageVersion("kkstatfun")
#> [1] '1.3.0'
NoteColophon

Written in Quarto, rendered with knitr against R 4.6.0 and kkstatfun 1.3.0. The cohort is synthetic and the hospitals are fictional; the confounding is not. Corrections and disagreements to my inbox.