Supportive Periodontal Therapy Clinical Examination Data 1.0.0

File: <base>/01_compute_spt_algorithm.py (9,409 bytes)
"""
Supportive Periodontal Therapy Clinical Examination Data
Residual-PPD-based algorithm for computing SPT intervals

Python equivalent of 01_compute_spt_algorithm.R. Both implementations were
written independently against the same specification and both reproduce every
derived variable in 03_supplementary_data.csv exactly.

The algorithm implements the empirically determined PPD stability thresholds
reported in Ramseier et al. 2019 (J Clin Periodontol 46:218-230), Figure 2.

Requires: pandas, numpy. Run from the folder holding the data files:
    python 01_compute_spt_algorithm.py
"""

import os
import sys

import numpy as np
import pandas as pd


# ---- 1. Read the data -------------------------------------------------------

def read_data(filename):
    if not os.path.exists(filename):
        sys.exit(
            f"'{filename}' not found in the working directory ({os.getcwd()}).\n"
            "  Run this script from the folder holding the data files, or pass "
            "that folder as the first argument."
        )
    return pd.read_csv(filename)


if len(sys.argv) > 1:
    os.chdir(sys.argv[1])

spt = read_data("02_supportive_periodontal_therapy_data.csv")
spt = spt.sort_values(["pat_id", "spt_id"]).reset_index(drop=True)


# ---- 2. Cumulative site counts at each SPT visit ----------------------------
# PPD categories are recorded as counts of sites at exactly 4, 5, 6 mm and at
# >= 7 mm. The algorithm operates on cumulative counts (>= 4, >= 5, >= 6 mm).
#
# min_count keeps a sum missing when any of its parts is missing, which is what
# we want for the visits flagged by `imputed`.

PPD = ["n_4mm_spt", "n_5mm_spt", "n_6mm_spt", "n_from7mm_spt"]

spt["n_from4mm_spt"] = spt[PPD].sum(axis=1, min_count=4)
spt["n_from5mm_spt"] = spt[PPD[1:]].sum(axis=1, min_count=3)
spt["n_from6mm_spt"] = spt[PPD[2:]].sum(axis=1, min_count=2)


# ---- 3. Site percentages ----------------------------------------------------
# PPD was recorded at six sites per tooth, so the denominator is n_teeth * 6.

n_sites = spt["n_teeth_spt"] * 6

for name, source in [
    ("percent_4mm_spt", "n_4mm_spt"),
    ("percent_5mm_spt", "n_5mm_spt"),
    ("percent_6mm_spt", "n_6mm_spt"),
    ("percent_from7mm_spt", "n_from7mm_spt"),
    ("percent_from4mm_spt", "n_from4mm_spt"),
    ("percent_from5mm_spt", "n_from5mm_spt"),
    ("percent_from6mm_spt", "n_from6mm_spt"),
]:
    spt[name] = spt[source] * 100 / n_sites


# ---- 4. PPD stability thresholds -------------------------------------------
# For each residual PPD category (>= 4, >= 5, >= 6 mm) and each candidate SPT
# interval (3, 4, 6, 12 months), a threshold percentage of affected sites was
# determined below which no increase of residual PPD was observed between two
# consecutive visits. A candidate interval is admissible only when the observed
# percentage lies at or below its threshold.
#
#   PPD >= 4 mm :  3 months if > 20% ; 4 and 6 months if <= 20% ; 12 months if <= 10%
#   PPD >= 5 mm :  3 months if > 10% ; 4 months if <= 10% ; 6 months if <= 6% ; 12 months if <= 2%
#   PPD >= 6 mm :  3 months if >  3% ; 4 months if <=  3% ; 6 months if <= 2% ; 12 months if <= 1%
#
# PPD >= 7 mm is deliberately NOT used: sites of >= 8 mm were not recorded at
# the MSDH, so >= 7 mm did not qualify as a threshold variable
# (Ramseier et al. 2019, section 3.2).
#
# NaN encodes "this interval is not admissible for this PPD category".

def threshold(condition, months):
    return np.where(condition.fillna(False), float(months), np.nan)


p4 = spt["percent_from4mm_spt"]
p5 = spt["percent_from5mm_spt"]
p6 = spt["percent_from6mm_spt"]

spt["from4mm_3months"] = threshold(p4 > 20, 3)
spt["from4mm_4months"] = threshold(p4 <= 20, 4)
spt["from4mm_6months"] = threshold(p4 <= 20, 6)
spt["from4mm_12months"] = threshold(p4 <= 10, 12)

spt["from5mm_3months"] = threshold(p5 > 10, 3)
spt["from5mm_4months"] = threshold(p5 <= 10, 4)
spt["from5mm_6months"] = threshold(p5 <= 6, 6)
spt["from5mm_12months"] = threshold(p5 <= 2, 12)

spt["from6mm_3months"] = threshold(p6 > 3, 3)
spt["from6mm_4months"] = threshold(p6 <= 3, 4)
spt["from6mm_6months"] = threshold(p6 <= 2, 6)
spt["from6mm_12months"] = threshold(p6 <= 1, 12)


# ---- 5. Longest admissible interval per PPD category ------------------------
# NaN here has two distinct meanings and they must not be conflated: an interval
# that is not admissible for an observed percentage, and a percentage that was
# never measured. The first counts as 0 and competes in the maximum; the second
# makes the whole result unknown. Visits flagged by `imputed` fall in the second
# case, and every value derived from them stays missing.

def longest_admissible(percentage, *columns):
    stacked = np.vstack([np.nan_to_num(spt[c].to_numpy(dtype=float), nan=0.0)
                         for c in columns])
    result = stacked.max(axis=0)
    result[percentage.isna().to_numpy()] = np.nan
    return result


spt["max_from4mm"] = longest_admissible(
    p4, "from4mm_3months", "from4mm_4months", "from4mm_6months", "from4mm_12months")
spt["max_from5mm"] = longest_admissible(
    p5, "from5mm_3months", "from5mm_4months", "from5mm_6months", "from5mm_12months")
spt["max_from6mm"] = longest_admissible(
    p6, "from6mm_3months", "from6mm_4months", "from6mm_6months", "from6mm_12months")


# ---- 6. Computed SPT interval ----------------------------------------------
# The binding constraint is the shortest of the three category-wise intervals.
# A category contributing 0 (no admissible interval) is ignored.

def ignore_zero(x):
    return np.where(x != 0, x, 999.0)


spt["algorithm_based_interval_months"] = np.minimum(
    np.minimum(ignore_zero(spt["max_from4mm"]), ignore_zero(spt["max_from5mm"])),
    ignore_zero(spt["max_from6mm"]),
)


# ---- 7. Adherence to the computed interval ----------------------------------
# true_interval_days is 0 at each patient's first visit (no preceding visit
# exists). Months are obtained by rounding days / 30, as in the original
# analysis. R rounds halves to even, and numpy does the same, so the two
# implementations agree.

spt["true_interval_months"] = np.round(spt["true_interval_days"] / 30)

interval_dif = pd.Series(
    np.where(spt["true_interval_days"] == 0, 0.0,
             spt["true_interval_months"] - spt["algorithm_based_interval_months"]),
    index=spt.index,
)
interval_dif[spt["algorithm_based_interval_months"].isna()] = np.nan

spt["interval_shorter"] = np.where(interval_dif < 0, 1.0, np.nan)
spt["interval_exact"] = np.where(interval_dif == 0, 1.0, np.nan)
spt["interval_longer"] = np.where(interval_dif > 0, 1.0, np.nan)

# NOTE: interval_shorter / interval_exact / interval_longer compare the actual
# interval against the ALGORITHM-BASED interval, not against the interval
# assigned by the dental hygienist (assigned_spt_interval_months).


# ---- 8. Runs of consecutive visits in the same adherence category -----------

def run_length(patient_ids, flags):
    flags = np.nan_to_num(np.asarray(flags, dtype=float), nan=0.0).astype(int)
    patient_ids = np.asarray(patient_ids)
    out = np.zeros(len(flags), dtype=int)
    run = 0
    for i in range(len(flags)):
        if i > 0 and patient_ids[i] != patient_ids[i - 1]:
            run = 0
        run = run + 1 if flags[i] == 1 else 0
        out[i] = run
    return out


for target, source in [("consecutive_shorter", "interval_shorter"),
                       ("consecutive_exact", "interval_exact"),
                       ("consecutive_longer", "interval_longer")]:
    spt[target] = run_length(spt["pat_id"], spt[source])


# ---- 9. Verification against the published supplementary file ---------------

CHECK = [
    "n_from4mm_spt", "n_from5mm_spt", "n_from6mm_spt",
    "percent_4mm_spt", "percent_5mm_spt", "percent_6mm_spt",
    "percent_from7mm_spt", "percent_from4mm_spt",
    "percent_from5mm_spt", "percent_from6mm_spt",
    "from4mm_3months", "from4mm_4months", "from4mm_6months", "from4mm_12months",
    "from5mm_3months", "from5mm_4months", "from5mm_6months", "from5mm_12months",
    "from6mm_3months", "from6mm_4months", "from6mm_6months", "from6mm_12months",
    "max_from4mm", "max_from5mm", "max_from6mm",
    "algorithm_based_interval_months",
    "interval_shorter", "interval_exact", "interval_longer",
    "consecutive_shorter", "consecutive_exact", "consecutive_longer",
]

if os.path.exists("03_supplementary_data.csv"):
    reference = pd.read_csv("03_supplementary_data.csv")
    reference = reference.sort_values(["pat_id", "spt_id"]).reset_index(drop=True)

    print("\nVerification against 03_supplementary_data.csv")
    print("---------------------------------------------")
    failures = 0
    for variable in CHECK:
        a = spt[variable].astype(float)
        b = reference[variable].astype(float)
        identical = ((a.isna() & b.isna())
                     | (a.notna() & b.notna() & ((a - b).abs() < 1e-9)))
        n_ok = int(identical.sum())
        if n_ok < len(reference):
            failures += 1
        print(f"  {variable:<32} {n_ok:>6} / {len(reference)} identical"
              f"{'   <-- MISMATCH' if n_ok < len(reference) else ''}")

    print()
    if failures == 0:
        print("All derived variables reproduce exactly.")
    else:
        print(f"{failures} variable(s) did not reproduce.")

print(f"\npandas {pd.__version__}, numpy {np.__version__}, "
      f"Python {sys.version.split()[0]}")