Supportive Periodontal Therapy Clinical Examination Data 1.0.0

File: <base>/03_reproduce_figure_2.R (6,867 bytes)
# =============================================================================
# Supportive Periodontal Therapy Clinical Examination Data
# Script 3 of 3: Reproduction of Figure 2 (PPD stability thresholds)
#
# Reproduces the empirically determined thresholds of no change of residual
# probing depth reported in Figure 2 of
#   Ramseier CA, Nydegger M, Walter C, Fischer G, Sculean A, Lang NP, Salvi GE.
#   J Clin Periodontol. 2019;46(2):218-230. doi:10.1111/jcpe.13041
#
# These twenty thresholds are the empirical basis of the SPT interval algorithm
# implemented in 01_compute_spt_algorithm.R.
#
# Verified: all 20 published thresholds are reproduced exactly from the
# published data files.
#
# Requires only base R (graphics optional).
# =============================================================================

## ---- 1. Data ----------------------------------------------------------------

# The data file is expected in the working directory. If R reports that it
# cannot be opened, point the working directory at the folder holding the data:
#   setwd("path/to/the/data")

file <- "02_supportive_periodontal_therapy_data.csv"
if (!file.exists(file)) {
  stop(sprintf(paste0("'%s' not found in the working directory (%s).\n",
                      "  Set the working directory to the folder holding the ",
                      "data files, e.g. setwd(\"path/to/the/data\")."),
               file, getwd()), call. = FALSE)
}

spt <- read.csv(file, stringsAsFactors = FALSE)
spt <- spt[order(spt$pat_id, spt$spt_id), ]

spt$n_sites_spt <- spt$n_teeth_spt * 6

# Cumulative percentages of affected sites at each visit
spt$pct4 <- (spt$n_4mm_spt + spt$n_5mm_spt + spt$n_6mm_spt + spt$n_from7mm_spt) * 100 / spt$n_sites_spt
spt$pct5 <- (                spt$n_5mm_spt + spt$n_6mm_spt + spt$n_from7mm_spt) * 100 / spt$n_sites_spt
spt$pct6 <- (                                spt$n_6mm_spt + spt$n_from7mm_spt) * 100 / spt$n_sites_spt
spt$pct7 <-                                                  spt$n_from7mm_spt  * 100 / spt$n_sites_spt


## ---- 2. Change relative to the preceding visit ------------------------------
# NOTE on the grouping variable.
# The figure caption states that visits are grouped by the residual PPD category
# "recorded at the previous SPT visit". The published values are reproduced only
# when visits are grouped by the percentage recorded at the RESPECTIVE (current)
# visit, with the change measured backwards to the preceding visit. The code
# below follows the computation that reproduces the published figure.

lagv <- function(x, id) ave(x, id, FUN = function(v) c(NA, head(v, -1)))

for (k in c("4", "5", "6", "7")) {
  cur <- spt[[paste0("pct", k)]]
  spt[[paste0("chg", k)]] <- cur - lagv(cur, spt$pat_id)
}


## ---- 3. Interval categories -------------------------------------------------
# Real time between visits, categorised into 3, 4, 6, 9 and 12+ months.
# true_interval_days is 0 at each patient's first visit (no predecessor).

spt$ivl_months <- round(spt$true_interval_days / 30, 0)

spt$ivl_cat <- cut(spt$ivl_months,
                   breaks = c(0, 3, 5, 8, 11, Inf),
                   labels = c("3", "4", "6", "9", "12+"),
                   right = TRUE)
spt$ivl_cat[spt$true_interval_days == 0] <- NA


## ---- 4. Percentage categories per panel -------------------------------------
# Upper bounds of the bins as printed on the x axis of Figure 2.

bins <- list("4" = c(10, 20, 30, 40, 100),
             "5" = c(2, 4, 6, 8, 10, 20, 30, 100),
             "6" = c(1, 2, 3, 4, 10, 100),
             "7" = c(1, 2, 3, 4, 10, 100))

ivls <- c("3", "4", "6", "9", "12+")


## ---- 5. Cell means and threshold extraction ---------------------------------
# The threshold is the largest bin whose mean change is still <= 0, i.e. the
# highest percentage of affected sites at which no increase of residual PPD is
# observed for that interval length.

panel_table <- function(k, exclude_zero = FALSE) {
  edges <- bins[[k]]
  cur <- spt[[paste0("pct", k)]]
  chg <- spt[[paste0("chg", k)]]
  keep <- !is.na(chg) & !is.na(spt$ivl_cat)
  if (exclude_zero) keep <- keep & cur > 0

  m <- matrix(NA_real_, nrow = length(edges), ncol = length(ivls),
              dimnames = list(paste0("<=", edges), ivls))
  n <- m
  for (j in seq_along(edges)) {
    lo <- if (j == 1) -1 else edges[j - 1]
    for (i in seq_along(ivls)) {
      s <- keep & cur > lo & cur <= edges[j] & spt$ivl_cat == ivls[i]
      m[j, i] <- mean(chg[s]); n[j, i] <- sum(s)
    }
  }
  list(mean = m, n = n, edges = edges)
}

threshold <- function(tab) {
  out <- setNames(rep(NA_real_, length(ivls)), ivls)
  for (i in seq_along(ivls)) {
    last_safe <- NA_real_
    for (j in seq_along(tab$edges)) {
      v <- tab$mean[j, i]
      if (is.na(v)) next
      if (v <= 0) last_safe <- tab$edges[j] else break
    }
    out[i] <- last_safe
  }
  out
}


## ---- 6. Verification against the published thresholds -----------------------

published <- rbind("4" = c(30, 20, 20, 10, 10),
                   "5" = c(20, 10,  6,  4,  2),
                   "6" = c( 4,  3,  2,  1,  1),
                   "7" = c( 2,  1,  1,  1,  1))
colnames(published) <- ivls

cat("Thresholds of no change of residual PPD (percentage of affected sites)\n")
cat("=====================================================================\n\n")
for (k in c("4", "5", "6", "7")) {
  tab <- panel_table(k)
  rec <- threshold(tab)
  cat(sprintf("PPD >= %s mm\n", k))
  cat("  published   :", sprintf("%5s", published[k, ]), "\n")
  cat("  reconstructed:", sprintf("%5s", rec), "\n")
  cat("  match       :", all(rec == published[k, ]), "\n\n")
}


## ---- 7. Sensitivity: excluding visits with zero affected sites --------------
# Visits with no residual pocket in a given category contribute a change of
# approximately zero and dominate the lowest bin, in particular for PPD >= 6 mm
# (50.6% of visits) and PPD >= 7 mm (73.4%). Re-deriving the thresholds without
# them tests whether the algorithm depends on those zeros.

cat("\nSensitivity: thresholds with and without visits at 0% affected sites\n")
cat("====================================================================\n\n")
for (k in c("4", "5", "6", "7")) {
  cat(sprintf("PPD >= %s mm\n", k))
  cat("  with zeros   :", sprintf("%5s", threshold(panel_table(k, FALSE))), "\n")
  cat("  without zeros:", sprintf("%5s", threshold(panel_table(k, TRUE))),  "\n\n")
}


## ---- 8. Cell means, for inspection or plotting ------------------------------

for (k in c("4", "5", "6", "7")) {
  tab <- panel_table(k)
  cat(sprintf("\nMean change, PPD >= %s mm (n in brackets)\n", k))
  disp <- matrix(sprintf("%+.2f (%d)", tab$mean, tab$n),
                 nrow = nrow(tab$mean), dimnames = dimnames(tab$mean))
  print(disp, quote = FALSE)
  write.csv(tab$mean, sprintf("figure2_panel_ppd%s.csv", k))
}

sessionInfo()