Claude Skill

analyze-stats

Statistical analysis for medical research papers. Generates reproducible Python/R code with publication-ready tables and figures. Supports diagnostic accuracy, inter-rater agreement, meta-analysis, survival analysis, survey data, group comparisons, regression, propensity score, a

LLM Mart · 0 points · 3 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download aperivue-medsci-skills-skills_analyze-stats-815765c.zip · 204 KB
Part of aperivue/medsci-skills — 47 skills

Install

skills CLI npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/analyze-stats
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
Git git clone https://github.com/Aperivue/medsci-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole aperivue/medsci-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Statistical Analysis Skill

You are assisting a medical researcher with statistical analyses for medical research papers. Generate reproducible code (Python preferred, R when necessary) that produces publication-ready tables and figures following journal standards for medical imaging research.

Data Privacy Check

Before reading any data file, check whether it might contain Protected Health Information (PHI):

  1. If *_deidentified.* files exist in the working directory, use those preferentially.
  2. If only raw CSV/Excel files exist (no *_deidentified.* counterpart), warn the user (ask in the user's preferred language):

    "Does this data contain patient identifiers (names, national ID / RRN, contact details, etc.)? If so, please de-identify it first with the /deidentify skill."

  3. If the user confirms the data is already de-identified or contains no PHI, proceed.
  4. NEVER display raw PHI values (names, phone numbers, RRN) in your output. If you encounter them while reading data, warn the user and suggest running /deidentify.

Reference Files

  • Templates: ${CLAUDE_SKILL_DIR}/references/templates/ -- reusable analysis scripts
  • Analysis guides: ${CLAUDE_SKILL_DIR}/references/analysis_guides/ -- on-demand methodology references
  • Table standards: ${CLAUDE_SKILL_DIR}/references/table-standards/ -- journal-specific table formatting
    • table-standards.md -- universal rules, AMA rules, footnote system, mistakes checklist
    • journal-profiles/ -- YAML profiles per journal (radiology, jama, nejm, lancet, eur_rad, ajr)
    • table-types/ -- templates per table type (Table 1, diagnostic accuracy, regression, survival/Cox, agreement/reliability, meta-analysis, model comparison, incremental value, reader study (MRMC))
    • tool-comparison.md -- R/Python tool comparison and recommended pipelines
  • Figure style: ${CLAUDE_SKILL_DIR}/references/style/figure_style.mplstyle
  • Project data: See CLAUDE.md for data locations under 2_Data/

Read relevant templates before generating analysis code. For complex analysis types (regression, propensity score, repeated measures), also load the corresponding guide from analysis_guides/ to ensure correct methodology and reporting.

Workflow

Phase 1: Data Assessment

  1. Read the data file (CSV, Excel, TSV, or other tabular format).
  2. Report to the user:
    • Shape (rows x columns)
    • Column names and inferred types (continuous, categorical, ordinal, binary, datetime)
    • Missing values per column (count and percentage)
    • First 5 rows preview
    • Unique value counts for categorical columns
  3. Identify the analysis unit: patient, exam, lesion, image, rater, study, etc.

Phase 2: Analysis Plan

Precondition (observational studies). Before proposing an analysis plan for an observational design (cohort, case-control, cross-sectional, registry, or survey), confirm that a literature-grounded variable operationalization exists — a variable_operationalization.md from /define-variables, or an equivalent codebook-backed definition table. If none exists, warn the user and recommend running /define-variables first, so exposure / outcome / covariate definitions and cutoffs are citation-backed rather than invented ad hoc from the data dictionary (ad-hoc phenotype/cutoff definitions are a common reviewer-rejection trigger for observational work — see the dictionary-first discipline). This is a WARN, not a hard block: proceed on explicit user confirmation, recording that the operationalization artifact was not available. For stricter projects, treat the missing artifact as a hard stop until /define-variables has run. (This mirrors the same precondition already enforced in /write-protocol before drafting Methods.)

Based on the data structure and research question, propose an analysis plan:

  1. Auto-detect analysis type from the table below, or accept user specification.

  2. List specific tests to be performed.

  3. Identify primary and secondary endpoints.

  4. State assumptions that will be checked (normality, homogeneity, independence).

  5. Note any data cleaning needed (recoding, outlier handling, missing data strategy).

  6. Anchor the estimand to the research question. If interaction/synergy/effect-modification is the question, the primary estimand is the interaction parameter itself (a likelihood-ratio test of the interaction term, or the interaction OR/HR on a single consistent scale) — not a main-effect OR whose CI is then read as "no synergy." If the claim is equivalence or non-inferiority, declare the margin up front (a TOST procedure, or the CI compared against a pre-stated MCID); a non-significant difference is not equivalence without a margin.

  7. Screen every categorical/binary predictor for separation — before fitting anything. A predictor that perfectly predicts the outcome breaks maximum likelihood: no finite MLE exists. The failure is silent — glm does not error, it returns an odds ratio near 0 (or enormous), p ≈ 0.99, and an AUC that then gets written into a table. This is routine in diagnostic imaging, because the good signs are the pathognomonic ones (T2-FLAIR mismatch, the string sign, a halo sign): 100% specificity means an empty cell by construction.

    python3 "${CLAUDE_SKILL_DIR}/scripts/check_separation.py" \
      --data cohort.csv --outcome idh_mutant --auto --strict
    

    COMPLETE_SEPARATION (an empty cell) and QUASI_SEPARATION (a cell below the sparsity floor) both halt the plan. The remedy is a design decision, not a numerical one: Firth's penalised likelihood keeps one model, while a two-stage rule — classify the sign-positive cases directly, model only the sign-negative remainder — is usually the clinically meaningful choice for a pathognomonic sign, because a sign-positive patient is already diagnosed and the real question is what to do with everyone else. Decide this in the plan; do not discover it in the output.

Present the plan and wait for user approval before executing.

Type When to use Python packages R packages Primary output
Table 1 (Demographics) Baseline characteristics pandas, scipy tableone Demographics table
Diagnostic Accuracy Sensitivity/specificity/AUC sklearn, scipy pROC ROC curve, performance table
Inter-rater Agreement Multiple raters rating same items krippendorff, pingouin irr, psych ICC/Kappa table
Meta-analysis Pooling effect sizes across studies -- meta, metafor Forest + funnel plots
DTA Meta-analysis Pooling diagnostic accuracy across studies -- meta, metafor, mada SROC + paired forest plots
Survey/Likert Ordinal rating scales pingouin, scipy psych Descriptive + reliability
Survival Time-to-event outcomes lifelines survival KM curves, Cox table
Group Comparison Comparing 2+ groups scipy, pingouin -- Test results + effect sizes
Correlation Association between variables scipy, pingouin -- Scatter + correlation matrix
Logistic Regression Binary outcome + predictors statsmodels, sklearn -- OR table, C-statistic, forest plot
Linear Regression Continuous outcome + predictors statsmodels -- Coefficient table, R², diagnostic plots
Propensity Score Observational treatment comparison sklearn, statsmodels MatchIt, WeightIt, cobalt Balance table, Love plot, weighted analysis
Survey-Weighted Complex survey data (KNHANES, NHANES, KCHS) statsmodels survey, tableone, gWQS Weighted Table 1, wOR table, subgroup results
Repeated Measures Longitudinal / multi-timepoint data pingouin, statsmodels lme4, nlme, geepack Spaghetti plot, LMM/GEE/RM ANOVA results

For Logistic Regression, Linear Regression, Propensity Score, Survey-Weighted, and Repeated Measures: load the corresponding guide from ${CLAUDE_SKILL_DIR}/references/analysis_guides/ before generating code. For Survey-Weighted analysis, also load survey_weighted.md. For NHIS claims-based studies, load nhis_icd10_mapping.md. For test selection guidance, load ${CLAUDE_SKILL_DIR}/references/analysis_guides/test_selection.md.

Phase 3: Execute

Generate and run a Python (preferred) or R script following these rules:

Script Structure

Every script MUST start with a reproducibility header:

"""
Analysis: {description}
Date: {YYYY-MM-DD}
Random seed: 42
Python: {version}
Key packages: {package==version, ...}
"""
import numpy as np
import pandas as pd
np.random.seed(42)

Execution Rules

  1. Random seed: Always np.random.seed(42) or set.seed(42).
  2. Figure style: Always load the matplotlib style file:
    import matplotlib.pyplot as plt
    style_path = os.path.join(os.environ.get('CLAUDE_SKILL_DIR', '.'), 'references/style/figure_style.mplstyle')
    if os.path.exists(style_path):
        plt.style.use(style_path)
    
  3. Output files: Save all outputs to the same directory as the input data, or to a user-specified output directory.
  4. Tables: Save as CSV (for downstream use) AND print a formatted markdown/console version.
  5. Figures: Save as both PDF (vector) and PNG (300 DPI).
  6. Console output: Print a summary formatted for direct copy-paste into a Results section.

Assumption Checking

Before running parametric tests, always check and report:

  • Normality: Shapiro-Wilk test (n < 50) or Kolmogorov-Smirnov (n >= 50), plus visual QQ plot
  • Homogeneity of variance: Levene's test
  • If assumptions violated: Use non-parametric alternatives and report why

Multiple Comparisons

  • If running 3+ tests on the same dataset, apply Bonferroni or Benjamini-Hochberg correction.
  • Always report both uncorrected and corrected p-values.
  • State the correction method used.

Stratified & Ordinal-Trend Reporting

  • Strata disjointness gate (before any ordinal trend test). Before running a Cochran-Armitage trend test (or any analysis that treats tiers as an ordered partition), assert the strata are mutually exclusive and exhaustive: sum(n per stratum) == unique N and sum(events per stratum) == total events. A trend test on overlapping or non-exhaustive strata is invalid. Emit the per-stratum N/event table and the reconciliation in the output (this is the analysis-side mirror of /self-review check_cohort_arithmetic.py PARTITION_OVERLAP).
  • Secondary stratum-HR validation checklist. Every secondary stratum hazard/odds ratio must be reported with (a) its reference contrast (which category is the referent), (b) the event count in each stratum, and (c) a sparse-stratum caveat when any stratum has a low event count (a rule of thumb: < 10 events makes the estimate unstable). A bare "HR 1.55 in lean participants" without the referent and the events is uninterpretable.
  • Proportion CI lower-bound clamp. Clamp every proportion confidence-interval lower bound to max(0, lower); a zero-event Wilson/score interval can emit a negative or absurd tiny-exponent lower bound (e.g., 3.47e-16) that is a display artifact, not a real bound. Report 0 (or 0.0%) instead, and prefer an exact (Clopper-Pearson) interval for zero/near-zero cells.

Output Manifest

After all analyses complete, save _analysis_outputs.md in the output directory. Use the output format and bound binary workflow when producing the analysis outputs.

This manifest enables downstream skills (/make-figures, /write-paper) to auto-discover analysis outputs without user intervention.

For prespecified binary predictions on independent units, use the bundled scripts/run_analysis.py run workflow described in references/analysis_run_workflow.md. It executes the existing diagnostic template and embeds data/configuration/code/ output hashes, exact counts, metric-specific denominators and the reproduction command in this same manifest. audit checks recorded versions without rewriting them; compare separates declared context and recorded numeric equality from byte drift. It does not select thresholds or establish study validity, privacy clearance or reuse rights. The original synthetic example runs with python3 ${CLAUDE_SKILL_DIR}/scripts/demo_analysis_run.py --out demo-project.

Phase 3.5: Generated-Code Quality Gate

Before reporting any script as final, lint every emitted .py/.R file for the reproducibility-hygiene "slop" that AI-generated analysis code recurrently carries:

python3 ${CLAUDE_SKILL_DIR}/scripts/check_generated_code.py {script.py} --strict
# or scan a whole output directory:
python3 ${CLAUDE_SKILL_DIR}/scripts/check_generated_code.py --code-dir {analysis_dir} --strict

Major findings (fix before reporting the script):

  • MISSING_SEED — randomness used (sampling, bootstrap, train/test split, rng) with no np.random.seed / set.seed / random_state= / default_rng. Non-reproducible.
  • HARDCODED_DATA_LITERAL — a hand-typed, table-shaped numeric literal instead of read_csv()/read.csv() + subset. This is the data-integrity rule "never hand-type CSV data into scripts."
  • HARDCODED_ABS_PATH — an absolute path literal (/Users/, /home/, C:\, ~/Documents). Non-portable and a PII risk.
  • INPLACE_SOURCE_OVERWRITE — writing to the same path read as input; this overwrites raw data. Write derived outputs to a new path ("never modify raw data").

Flags (fix when tidying): DEBUG_LEFTOVER (a breakpoint() / browser() / debug print / TODO marker left in) and UNUSED_IMPORT (a dead Python dependency).

The gate is conservative on the Major checks — it fires HARDCODED_DATA_LITERAL only on genuinely table-shaped literals and MISSING_SEED only on a real randomness call — so it stays quiet on legitimate analysis code. It is the analysis-side mirror of the data-integrity and reproducibility checks /self-review is built to catch downstream.

Phase 4: Report

After execution, generate manuscript-ready text:

  1. Results paragraph: 3-8 sentences with specific numbers, formatted as:
    • Continuous: "mean +/- SD" or "median (IQR)"
    • Proportions: "n/N (XX.X%)"
    • Test results: "statistic = X.XX, p = 0.XXX"
    • Effect sizes: "Cohen's d = X.XX (95% CI: X.XX-X.XX)"
    • AUC: "AUC = 0.XXX (95% CI: 0.XXX-0.XXX)"
  2. Table/figure captions: Draft captions referencing table/figure numbers.
  3. Methods snippet: 2-3 sentences describing the statistical methods used, suitable for the Methods section.

Statistical Reporting Rules (Always Enforced)

These rules apply to ALL analyses without exception:

  1. Exact p-values: Report exact values (e.g., p = 0.034), not inequalities. Exception: report as p < 0.001 when the value is below 0.001.
  2. Confidence intervals: Always report 95% CIs for primary endpoints.
  3. Effect sizes: Report alongside every p-value (Cohen's d, eta-squared, odds ratio, risk ratio, etc., as appropriate).
  4. Parametric vs non-parametric: Choose based on assumption checks, not convenience. Report the assumption test results.
  5. Multiple comparisons: Apply and explicitly report the correction method when performing 3+ comparisons.
  6. Sample size reporting: Always state n for each group/analysis.
  7. Missing data: Report how many cases were excluded and why.
  8. Decimal places: p-values to 3 decimals, proportions to 1 decimal, means/SDs to appropriate precision for the measurement.
  9. Design/power statistics are code outputs, never hand-computed. Any minimum detectable effect (MDE), a-priori or post-hoc power, or required sample size that will appear in the manuscript MUST be emitted by this committed script — printed with its method and inputs (n per arm, alpha, power, allocation ratio, one/two-sided) — not computed in a side tool (G*Power, an online calculator) and pasted in. Use one method family consistently (e.g. the exact noncentral-t via statsmodels TTestIndPower or scipy's nct); do not mix a normal approximation for some values with exact-t for others. A value that exists only in the manuscript with no script that reproduces it is the failure mode /self-review Phase 2.5a-2 is built to catch.
  10. Estimand & CI output contract. Every primary point estimate — including quantile estimands (T25, median time-to-event), pooled proportions, and subdistribution HRs, not just ORs/HRs/AUCs — MUST be emitted together with its 95% CI. In the output CSV, carry the interval as explicit columns (estimate, ci_lower, ci_upper) or as a single text column in est (lo–hi) form; never emit a point estimate with no interval in an adjacent column. Round ORs/HRs/sHRs to 2 decimals and AUC/C-statistic to 3. This is the output side of the /self-review §C assertion that "all primary metrics have 95% CIs."

Effect-Size Real-World Translation

Whenever a primary result is a correlation, a standardized coefficient, a regression slope, an OR/HR/RR, or a Cohen's d, also report it as a plain-language unit shift a non-statistician can act on. The coefficient answers "is there an association"; the translation answers "how much, in units I use". This complements rule 3 above (report effect sizes) — it does not replace it.

When to apply

  • Any continuous-exposure to continuous-outcome association reported as Spearman's rho, Pearson's r, or a standardized slope.
  • Any OR/HR/RR where the audience needs an absolute-risk feel.
  • Reader / expert-elicitation studies, clinical-utility framing, abstracts, and figure captions.

Procedure

  1. Pick an anchored contrast on the exposure, not a 1-unit step. Default: 25th to 75th percentile (IQR). State both endpoints in native units.
  2. Translate to the outcome scale.
    • For a rank/standardized association (Spearman's rho or a per-SD slope) under an approximately monotonic-linear assumption: delta_outcome ~= ((x_p75 - x_p25) / SD_x) * |rho| * SD_outcome. Report as: "going from to is associated with about on average."
    • For a regression slope b: delta_outcome = b * (x_p75 - x_p25) (cleaner; no monotonicity caveat).
    • State the assumption explicitly; the IQR translation is a more defensible verbal guide than an SD-scaled one.
  3. For OR/HR/RR, accompany the relative measure with an absolute one at a stated baseline risk: the absolute risk difference, and NNT = 1 / ARR (or NNH = 1 / ARI). Always state the baseline risk used.
  4. Bound the claim: report the contrast, the assumption, and a CI on the coefficient; do not imply causation from a crude or unadjusted estimate.

Worked example (synthetic) rho = 0.39 between a fasting marker (IQR 0.6 to 3.5 units, SD 3.05) and an index (SD 2.13): ((3.5 - 0.6) / 3.05) * 0.39 * 2.13 ~= 0.8 -> "Going from the 25th to the 75th percentile of the marker is associated with about 0.8 index units higher on average (monotonic-linear approximation; crude, unadjusted)."

Output contract (clinical-utility is a default, not an optional add-on). Report every primary effect in units a clinician acts on, by default — do not leave these as prose to be added later:

  • OR/HR/RR primary outcomes → report the relative measure and the absolute risk at a stated baseline + absolute risk difference + NNT (or NNH = 1/ARI), baseline risk explicit. A relative-only headline is incomplete.
  • Continuous outcomes → add the IQR/clinically-anchored "Real-world translation" line beneath the effect size.
  • Prediction / classification (incl. medical-AI) models → a decision-curve / net-benefit pass at the relevant threshold is standard output, not just AUC + calibration. An incremental claim reports added net benefit / NRI / IDI over the established clinical model, not the new model's AUC alone. See references/table-standards/table-types/incremental_value.md and the make-figures decision_curve exemplar (and render_core_figures.py for the rendered curve).

Error Handling

  • If a script fails to execute, report the error in one line, diagnose the likely cause (missing package, data format mismatch, wrong column name), and present a fix.
  • Do NOT retry the same script more than once without modifying it or asking the user.
  • If an R package is unavailable, suggest install.packages() and wait for user confirmation.
  • For prediction models: always include calibration assessment (Brier score, calibration plot, or calibration slope/intercept) alongside discrimination metrics. AUC alone is insufficient.

Output Conventions

Tables

Before generating any publication table, load the journal profile and table type template:

  1. Load ${CLAUDE_SKILL_DIR}/references/table-standards/journal-profiles/{journal}.yaml if a target journal is known
  2. Load ${CLAUDE_SKILL_DIR}/references/table-standards/table-types/{type}.md for the relevant table type
  3. If no journal specified, default to AMA style (Radiology profile)

Output formats (always generate all three):

  • CSV file (for downstream use and archival)
  • Console markdown rendering (for user review)
  • R gtsummary code (for publication-quality Word/LaTeX export)

Universal rules (enforced regardless of journal):

  • No vertical lines — horizontal rules only (top, below header, bottom)
  • Binary variables: show only one level (e.g., Male only, not Male + Female)
  • Units in column headers, not repeated in cells
  • Consistent decimal places within each column
  • All abbreviations defined in footnotes, self-contained per table
  • Exact P values always (never "NS" or "significant")
  • Name the statistical test in footnote or general note
  • Variability measure always stated: mean (SD) or median (IQR)

Journal-specific parameters (from loaded YAML profile):

  • Footnote markers: letters (AMA) vs symbols (NEJM/Lancet)
  • P value format: case, leading zero, italic
  • CI separator: comma (Radiology) vs "to" (JAMA/NEJM/Lancet)
  • Title format: period (AMA) vs colon (Lancet)
  • Abbreviation order: appearance (Radiology) vs alphabetical (JAMA)

Footnote placement order (universal):

  1. General note (no marker) — e.g., "Data are mean (SD) unless noted"
  2. Abbreviations — in order per journal convention
  3. Specific notes (superscript markers) — per-cell explanations
  4. Probability notes — significance thresholds (if applicable)

gtsummary pipeline (recommended for R table generation):

theme_gtsummary_journal("{journal}")  # "jama", "lancet", "nejm"
theme_gtsummary_compact()
# ... build table ...
tbl %>% as_flex_table() %>% flextable::save_as_docx(path = "table.docx")

Validation checklist (run before finalizing any table):

  • Binary variables show only one level
  • Units in headers, not cells
  • Consistent decimal places per column
  • Statistical test named (footnote or general note)
  • Effect sizes per clinically meaningful unit (per 10 years, not per 1 year)
  • Reference category stated for categorical predictors
  • No "NS" — exact P values only
  • Abbreviations defined in footnotes

Figures

  • Format: PDF (vector, for journal) + PNG (300 DPI, for review)
  • Style: Use figure_style.mplstyle for consistent appearance
  • Font: Arial, 8-10pt
  • Colors: Colorblind-safe palette
  • Size: 3.5 inches (single column) or 7.0 inches (double column) width
  • Always include axis labels with units

Console Output

  • Formatted for direct copy-paste into the Results section of a manuscript
  • Include all numbers that would appear in the text
  • Use the reporting format conventions above

Analysis-Specific Guidelines

Table 1 (Demographics)

  • Template: references/templates/table1_demographics.py
  • Table type guide: references/table-standards/table-types/table1_demographics.md
  • Continuous variables: mean +/- SD if normal, median (IQR) if skewed
  • Categorical variables: n (%)
  • Binary variables: show only one level (e.g., Male n (%), not both Male and Female)
  • Compare groups: t-test/Mann-Whitney for continuous, chi-square/Fisher for categorical
  • Report standardized mean differences (SMD) if requested (preferred over P for PS-matched studies)
  • RCTs: P values in Table 1 are usually unnecessary per CONSORT
  • gtsummary tbl_summary() with journal theme for R pipeline

Diagnostic Accuracy

  • Methodology guide: references/analysis_guides/diagnostic_accuracy.md (load before generating code — every metric with a CI on a stated analysis unit; the confidence-weighted trap [unweighted-baseline AUC + monotonic-encoding check, produce-side of probe D9]; paired DeLong vs MRMC for reader-generalising claims; per-stratum admissibility [D10]; one-scale-per-comparison [D11])
  • Template: references/templates/diagnostic_accuracy.py
  • Always report: sensitivity, specificity, PPV, NPV, accuracy, AUC
  • CIs: Wilson score for proportions, DeLong for AUC
  • ROC curve: include diagonal reference line, AUC in legend
  • If comparing models: DeLong test for AUC comparison
  • Youden's index for optimal threshold when applicable
  • Include calibration assessment (Brier score, calibration plot) for prediction models
  • NRI/IDI: When comparing two models (e.g., base model vs model + AI score), report:
    • Category-based NRI (with clinically defined risk categories)
    • Continuous NRI (note: tends to be inflated — report alongside category-based)
    • IDI (Integrated Discrimination Improvement)
    • Bootstrap 95% CIs (1000+ iterations)
    • These supplement, not replace, DeLong AUC comparison
  • Table type guide (added value beyond a baseline): references/table-standards/table-types/incremental_value.md (paired ΔAUC + DeLong CI, continuous NRI with event/non-event split, IDI, net benefit at a prespecified threshold, same-patient/calibrated-first discipline). Pairs the decision-curve exemplar make-figures references/exemplar_plots/decision_curve.md.
  • Reader study (MRMC): references/table-standards/table-types/reader_study.md (per-reader + reader-averaged AUC with an Obuchowski–Rockette/DBM reader+case CI, per-patient vs per-lesion unit, superiority vs non-inferiority margin). Use an MRMC method (not a fixed-reader DeLong CI) for a claim that generalises to readers. Pairs make-figures references/exemplar_plots/mrmc_roc.md.

Inter-rater Agreement

  • Methodology guide: references/analysis_guides/agreement_reliability.md (load before generating code — the pseudoreplication trap for clustered/repeated measurements + the pseudoreplication-safe per-subject / mixed-effects code, ICC model/type selection, agreement-vs-reliability distinction; pairs with self-review probe O18)
  • Table type guide: references/table-standards/table-types/agreement.md (ICC with model/type + CI, weighted κ for ordinal, Bland–Altman bias + LoA, reliability-vs-agreement distinction, common errors)
  • Template: references/templates/agreement_analysis.py
  • 2 raters + categorical: Cohen's kappa
  • 2+ raters + categorical: Fleiss' kappa (or Krippendorff's alpha)
  • Continuous: ICC (specify model: one-way, two-way random/mixed; type: single/average)
  • Always report interpretation labels (Landis & Koch or Cicchetti)
  • Bland-Altman plot for continuous paired measurements
  • Bootstrap CIs (1000 iterations, seed=42)

Meta-analysis

  • Prefer R (meta/metafor packages) for meta-analysis
  • Comparative: metabin() for binary outcomes (OR/RR), metagen() for continuous
    • Use method = "Inverse", method.tau = "DL", method.random.ci = "HK"
    • Avoid deprecated args: comb.fixed → common, hakn → method.random.ci
  • Single-arm pooled proportion: metaprop() with sm = "PLOGIT", method.ci = "CP"
    • Small-study test branch: do not use Egger's regression for a single-arm proportion meta-analysis — funnel-asymmetry tests assume an effect-size-vs-SE relationship that does not hold for raw proportions. If a small-study assessment is needed, use Peters' test or an arcsine-based variant, and only when k >= 10 (note underpowered otherwise)
    • Standard output: report tau-squared on the logit scale and a 95% prediction interval (metaprop(..., prediction = TRUE)) in addition to the pooled estimate; the PI conveys where a future study's proportion is expected to fall under the random-effects model
  • Nested observation units: if the proportion's unit is nested within study (e.g., per-lesion within study, per-image within patient), do not report a naive Wilson/binomial CI that ignores clustering — use a cluster-bootstrap or a GLMM with a random intercept per study so the CI reflects the design
  • Heterogeneity: I-squared, Q test, tau-squared, and a 95% prediction interval for the random-effects pooled estimate
  • Forest plot: individual studies + pooled estimate
  • Funnel plot + Egger's test for publication bias (comparative effect sizes only; note: underpowered k<10)
  • Sensitivity analysis: leave-one-out (metainf())
  • Subgroup: update(res, subgroup = variable)

DTA Meta-Analysis

  • Template: references/templates/dta_meta_analysis.R
  • Prefer R (mada, meta, metafor packages) for DTA meta-analysis
  • Bivariate model (Reitsma): mada::reitsma() — recommended over separate pooling of Se/Sp
    • Accounts for correlation between sensitivity and specificity
    • Produces SROC curve with confidence + prediction regions
  • Key outputs: Pooled Se/Sp (95% CI), positive/negative LR, DOR, SROC AUC
  • Threshold effect: Spearman correlation between logit(Se) and logit(FPR)
    • If significant: interpret single pooled Se/Sp with caution, emphasize SROC curve
  • Forest plots: Paired (sensitivity + specificity side by side)
  • Publication bias: Deeks' funnel plot asymmetry test (NOT standard funnel plot)
    • Standard funnel plots are inappropriate for DTA studies
    • Note: underpowered for k < 10
  • Dual approach (comparative + single-arm):
    • Primary: metabin() for comparative studies (OR/RR)
    • Secondary: metaprop() with sm = "PLOGIT" for single-arm pooled proportion
    • Use method = "Inverse", method.tau = "DL", method.random.ci = "HK"
  • Small studies (k < 10): bivariate model may not converge; consider narrative synthesis
  • Alternative: If mada unavailable, use metafor::rma.mv() with bivariate structure

Network Meta-Analysis

  • Guide: Load analysis_guides/network_meta_analysis.md before generating code
  • For ≥3 interventions via combined direct + indirect evidence (incl. component NMA); pairwise machinery (search/screening/random-effects model) via the Meta-analysis section above
  • Assess transitivity before pooling: compare effect-modifier distributions across comparisons (box plots / table) and/or network meta-regression — it is a clinical judgment, not a test
  • Test consistency globally (design-by-treatment) AND locally (node-split / back-calculation); a star network (no closed loops) cannot be checked — state it; investigate the source of any inconsistency (often one trial)
  • R netmeta (frequentist: netsplit, decomp.design, netheat, netrank P-scores, comparison-adjusted funnel) or Bayesian gemtc / multinma / BUGSnet (node-split, SUCRA, DIC)
  • Present a network plot (node ∝ sample size, edge ∝ #trials); report global τ²; ranking (SUCRA/P-score) is not a superiority test — report it with the league table, intervals, and certainty
  • Certainty per estimate via CINeMA / GRADE-NMA (downgrade indirect-only); component NMA assumes additivity (state/check it). Report against PRISMA-NMA; risk of bias via RoB-NMA. Review-side probes: NM1–NM8 in network_meta_analysis.md

Health Economic Evaluation

  • Guide: Load analysis_guides/health_economic_evaluation.md before generating code
  • For cost-effectiveness (CEA), cost-utility (CUA, QALY), cost-benefit (CBA), cost-minimisation, or budget-impact analyses; trial-based or decision-model-based (decision tree, Markov/state-transition, discrete-event simulation)
  • Compute incremental cost ΔC, incremental effect ΔE, and the ICER = ΔC/ΔE; with ≥3 options remove dominated / extended-dominated strategies before sequential ICERs; prefer net benefit (INMB = λΔE − ΔC) for regression/probabilistic summaries
  • State and justify the perspective, time horizon (lifetime for chronic disease), discount rate (both costs and outcomes), currency + price year; QALYs from a named preference-based instrument + value set
  • Uncertainty is the analytic core: one-way / tornado for drivers, probabilistic sensitivity analysis (PSA) with justified parameter distributions (beta for probabilities/utilities, gamma/log-normal for costs) → cost-effectiveness plane + CEAC; scenario analyses for structural choices
  • R heemod / dampack / hesim / BCEA (state-transition + PSA + CEAC + EVPI), flexsurv for survival extrapolation. Report against CHEERS 2022; make the "cost-effective" conclusion conditional on a stated willingness-to-pay threshold. Review-side probes: HE1–HE8 in health_economic_evaluation.md

Survey/Likert

  • Descriptive: median, IQR, frequency distribution per item
  • Internal consistency: Cronbach's alpha with item-total correlations
  • Reverse-coding guard (run before reliability): a negatively-worded scale item must be recoded (min+max) - x before computing the scale total or Cronbach's alpha. An un-recoded reverse item produces a negative item-rest correlation and a negative alpha — which is a coding bug, not evidence of a multidimensional construct (do not defend it as such; you lose a review round). likert_summary.py prints the per-item item-rest correlations, flags negative ones as reverse-code suspects, warns loudly on a negative alpha, and accepts --reverse-items E3 ... to apply the recode before scoring. To screen at cleaning time, run /clean-data scripts/check_reverse_coding.py. See the global rule survey-scale-reliability.md.
  • If comparing groups: Mann-Whitney or Kruskal-Wallis (ordinal data)
  • Visualization: diverging stacked bar chart

Survival Analysis

  • Methodology guide: references/analysis_guides/survival.md (load before generating code — competing risks first [naive 1−KM overestimates → produce the Aalen–Johansen/Fine–Gray CIF; cause-specific vs subdistribution for which question, produce-side of probe S3]; PH check → RMST when violated; reverse-KM follow-up + C-index variant [S6]; estimand provenance [S8])
  • Table type guide: references/table-standards/table-types/survival_results.md (Cox results table: events/person-time, reverse-KM median follow-up, univariable + adjusted HR with CI, PH-assumption footnote, EPV/sparse-stratum and RMST-when-PH-violated rules)
  • Kaplan-Meier curves with number-at-risk table
  • Log-rank test for group comparison
  • Cox proportional hazards: report HR (95% CI)
  • Events-per-variable (EPV) gate: check events / n_covariates >= 10 before fitting Cox (mirror of the logistic EPV rule). Warn if violated and fall back to a Firth/penalized Cox or profile-likelihood CIs; do not report Wald CIs from a sparse-event model as if stable
  • Nested observation units (cluster-robust CI): when a subject contributes more than one analysed unit (multiple lesions, both eyes, repeated episodes), pass a subject id so the HR CIs use a robust cluster-sandwich variance (coxph(..., cluster = id) / robust = TRUE in R, cluster_col= in lifelines, e.g. survival_analysis.py --cluster <id>). Treating correlated rows as independent understates the standard errors and narrows the CI artificially
  • Check proportional hazards assumption (Schoenfeld residuals)
  • PH violation → do not report a single time-averaged HR. If the Schoenfeld global test is significant (or a covariate's residual trends with time), a single Cox HR averages a changing effect and is misleading. Report a piecewise / time-stratified HR (split follow-up at a clinically sensible cut, or tt() time-transform), or switch to RMST difference at a fixed horizon, and state the violation explicitly
  • Horizon vs follow-up. Do not read a KM/CIF estimate at a horizon beyond the data: if a reported time point (e.g., a 15-year cumulative incidence) exceeds the reverse-KM median follow-up, either restrict the horizon to where the risk set is non-trivial or report the number-at-risk at that horizon so the reader can judge the extrapolation
  • Report median survival with 95% CI
  • Warranty period / quantile estimands (T25 etc.): Time to a fixed cumulative incidence. Use quantile() from the KM/survfit object and always emit the 95% CI (the lower/upper from quantile(km, conf.int=TRUE), or a log-transformed / bootstrap CI) alongside the events/n that define it. A quantile point estimate reported without its CI is incomplete. If the event rate is below the target quantile, report "not reached" and consider Weibull parametric extrapolation (also with an interval)

Interval-Censored Survival

When exact event times are unknown (e.g., health screening cohorts where status changes are detected at periodic visits), standard KM underestimates time-to-event. Use interval-censored methods:

  • R packages: icenReg (parametric/semi-parametric IC regression), interval (NPMLE/Turnbull), survival (Surv type "interval2")
  • Turnbull estimator: Non-parametric MLE for interval-censored data — analogous to KM but accounts for the interval between last negative and first positive observation
  • Parametric IC models: Weibull or log-logistic via icenReg::ic_par(). Report shape/scale parameters and compare AIC across distributions
  • Mid-point imputation: Simple approximation — event time = midpoint of (last negative, first positive). Acceptable as sensitivity analysis but NOT as primary method
  • When to use: Serial measurement cohorts (e.g., health screening databases), cancer screening intervals, repeated biomarker assessments
  • Auto-trigger: if the event date is defined by a periodic visit / scheduled re-examination (the event is detected at a visit, not observed exactly), interval-censoring is not optional — make an IC model the primary analysis, or at minimum a mandatory pre-specified sensitivity analysis, and do not present a right-censored Cox coxph() on visit-dated events as if the times were exact
  • Multistate / transition models: for repeated transitions (e.g., msm), account for subject-level clustering with a subject random effect or a sandwich (robust) variance, and check the time-homogeneity assumption (constant transition intensities) before trusting a single rate
  • Reporting: State the interval-censored nature of the data explicitly in Methods. Report both standard KM (for comparability with prior literature) and IC estimates (as primary or sensitivity)

Competing Risks

When death or other events preclude the outcome of interest, standard KM overestimates cumulative incidence (treats competing events as censored). Use competing risk methods:

  • R packages: cmprsk (Fine-Gray), tidycmprsk (tidy interface), survival (cause-specific Cox)
  • Cumulative incidence function (CIF): cmprsk::cuminc() — replaces 1-KM for each event type. Gray's test for group comparison
  • Fine-Gray subdistribution hazard: cmprsk::crr() or tidycmprsk::crr() — reports subdistribution HR (sHR) with 95% CI. Interpretable as effect on CIF directly. Check the subdistribution-PH assumption the same way you check it for Cox (a time-interaction term on the subdistribution scale, or inspection of scaled-residual analogues); a constant sHR is an assumption, not a given. Report the cause-specific HR alongside it so the etiologic and prognostic readings are both visible
  • Cause-specific Cox: Standard Cox censoring competing events — reports cause-specific HR. Better for etiology; Fine-Gray better for prognosis/prediction
  • When to use: Mortality studies with multiple causes of death, cardiovascular events when non-CV death is frequent, any outcome where competing events are common (>5% of total events)
  • Reporting: Present CIF plots (NOT 1-KM) when competing risks exist. Report both cause-specific HR and subdistribution HR when the research question is etiologic. State which competing events were defined. When a CIF is quoted at a horizon beyond the median follow-up, report the number-at-risk at that horizon (or restrict the horizon) — a CIF extrapolated past the data is not a stable estimate

Group Comparison

  • 2 independent groups: t-test or Mann-Whitney U
  • 2 paired groups: paired t-test or Wilcoxon signed-rank
  • 3+ independent groups: ANOVA or Kruskal-Wallis, with post-hoc
  • 3+ paired groups: repeated measures ANOVA or Friedman, with post-hoc
  • Always report: test statistic, degrees of freedom, p-value, effect size

Correlation

  • Pearson r (if bivariate normal) or Spearman rho (if not)
  • Report: coefficient, 95% CI, p-value
  • Scatter plot with regression line and CI band
  • For multiple variables: correlation matrix heatmap

Logistic Regression

  • Guide: Load analysis_guides/regression.md before generating code
  • Template: references/templates/regression.py (set regression_type = "logistic")
  • Run univariable analysis first, then multivariable with clinically selected variables
  • Required outputs: OR table (univariable + multivariable), C-statistic (95% CI), and calibration (intercept + slope + flexible plot — not Hosmer–Lemeshow, which is deprecated; see the calibration guide)
  • Prediction-model calibration guide: references/analysis_guides/calibration.md (load before generating code for any model that outputs a risk used for a decision — the apparent slope of exactly 1.00 is the in-sample tell, so produce the bootstrap optimism-corrected slope/intercept; Van Calster's calibration levels; scaled Brier; why Hosmer–Lemeshow is dropped; produce-side of probe S7)
  • Check VIF < 5, EPV >= 10 (warn if violated)
  • Nested observation units: when rows are clustered within subjects (multiple lesions/visits per patient), use cluster-robust standard errors (cov_type="cluster", cov_kwds={"groups": id} in statsmodels) or a mixed-effects logistic model — a naive logit CI assumes independent rows and is too narrow
  • Box-Tidwell test for continuous predictor linearity
  • Forest plot of adjusted ORs
  • NRI/IDI if comparing models (incremental value assessment)

Linear Regression

  • Guide: Load analysis_guides/regression.md before generating code
  • Template: references/templates/regression.py (set regression_type = "linear")
  • Required outputs: coefficient table (β, 95% CI, P), R²/adjusted R², VIF
  • Always generate 4-panel diagnostic plot (residuals vs fitted, Q-Q, scale-location, leverage)
  • Check assumptions: normality of residuals, homoscedasticity, multicollinearity
  • Report both unstandardized β (primary) and standardized β (for effect size comparison)

Propensity Score

  • Guide: Load analysis_guides/propensity_score.md before generating code
  • Template: references/templates/propensity_score.py
  • Step 1: PS estimation (logistic regression)
  • Step 2: Apply method (matching with caliper = 0.2 × SD logit PS, IPTW/SIPTW with stabilized weights, or overlap weighting)
  • Step 3: Balance assessment — SMD < 0.10 for all covariates, Love plot mandatory
  • Step 4: Weighted/matched outcome analysis with robust SE
  • Step 5: Sensitivity analysis (E-value for unmeasured confounding)
  • Always state the estimand (ATE/ATT/ATO) explicitly
  • Recommend overlap weighting as default (no extreme weight issues)
  • SIPTW: Stabilized IPTW variant used in emulated target trial frameworks; report effective sample size

Survey-Weighted Analysis

  • Guide: Load analysis_guides/survey_weighted.md before generating code
  • Template: references/templates/survey_weighted_analysis.py
  • For KNHANES/NHANES/KCHS and similar complex survey designs
  • Always declare survey design (strata, cluster/PSU, weight) before analysis
  • Use correct weight variable (interview vs exam vs nutrition)
  • R survey package strongly recommended over Python for publication
  • Sequential model building: Model 1 (age+sex) → Model 2 (full adjustment)
  • Report weighted odds ratios (wOR) with 95% CI
  • Cross-national: analyze each country separately, never pool
  • Subgroup analysis: exclude the stratification variable from covariates

Mediation Analysis

  • Guide: Load analysis_guides/mediation.md before generating code
  • Bootstrapped product-of-coefficients (a×b) indirect effect (R mediation / CMAverse / PROCESS); ≥2000 resamples, bias-corrected percentile CI — not the Sobel test
  • Binary outcome: counterfactual / natural-effects decomposition (CMAverse, regmedint), not the naive OR product
  • Report total, direct, indirect effects each with a bootstrap CI; proportion mediated only with uncertainty and only when the total effect is well-estimated (unstable / can exceed 100% when total is near-null)
  • Identification, not the bootstrap, is the issue: mediation needs no unmeasured mediator–outcome confounding (sequential ignorability) → report an E-value for the indirect effect (or ρ-based sensitivity). A cross-sectional design cannot order X→M→Y — frame as association-level (review probe O13)
  • Report against AGReMA

Interaction & Effect Modification

  • Choose and state the scale. A public-health / biological synergy claim is an additive-scale statement → report RERI, AP (attributable proportion), or S (synergy index), each with a CI — not only a multiplicative OR/HR product term. A non-significant multiplicative interaction is compatible with a large additive one (and vice versa)
  • "Joint association" via a combined multi-level exposure (high/high vs low/low) shows joint categories, not interaction — add the product term (multiplicative) and/or RERI (additive) to claim interaction
  • Stratified-only "stronger in A than B" is the difference-in-significance fallacy — report the formal interaction term, not two separate stratum estimates
  • R interactionR / epiR for RERI/AP/S with CIs; follow Knol & VanderWeele interaction-reporting recommendations. Review-side probe: O14 in observational_confounding.md

Multiple Testing & High-Dimensional Screening

  • Guide: Load analysis_guides/multiplicity.md before generating code
  • For agnostic many-exposure scans (ExWAS / EWAS / MWAS / proteome-/nutrient-wide) and any "screen N predictors, report the significant ones" pass
  • Match the correction to the claim: FWER (Bonferroni / Holm / permutation-based study-wide threshold) for a confirmatory single hit; FDR (Benjamini–Hochberg q-value) for discovery — then frame as hypothesis-generating
  • Report the correction method and the number of tests m (the denominator), applied to the whole tested set — never shrink m to the winners
  • Replication is the real safeguard: split-half / second cohort / cross-cycle with directional concordance and a reported replication rate; a single-cohort FDR-significant scan is exploratory
  • Correlated exposures → raw Bonferroni is over-conservative (permutation or effective-number-of-tests via poolr::meff()); a univariate hit may be a marker for a correlated cause (consider WQS / quantile g-computation / BKMR before causal reading)
  • Report full results (all effect sizes + p/q), not only winners; complex surveys combine design-based SEs (survey_weighted.md) WITH the correction. Review-side probe: O17 in observational_confounding.md

Mendelian Randomization

  • Guide: Load analysis_guides/mendelian_randomization.md before generating code
  • For genetic-instrument causal inference: two-sample summary-data MR, one-sample MR, MVMR, drug-target / cis-MR, non-linear MR
  • State and evidence the 3 IV assumptions: relevance (F-statistic / R²), independence (ancestry + confounder scan), exclusion restriction (no horizontal pleiotropy — the untestable one)
  • Pre-specify the full sensitivity suite, not IVW alone: IVW + MR-Egger (intercept = directional pleiotropy) + weighted median + weighted mode + MR-PRESSO; Cochran's Q + leave-one-out; concordance across methods is the robustness claim (R TwoSampleMR / MendelianRandomization)
  • Address reverse causation (Steiger / bidirectional), sample overlap (report fraction; overlap + weak instruments inflate type-1 error), winner's curse (select instruments in an independent GWAS), and ancestry matching
  • Drug-target / cis-MR: GLS-IVW for correlated cis variants + colocalization (coloc) + positive control + adverse-effect phenome scan. Non-linear MR: residual/doubly-ranked shapes can be artefactual → require negative/positive controls + extreme-stratum sensitivity
  • Interpret as a lifelong genetic-proxy effect direction, not a clinical-intervention magnitude; report against STROBE-MR. Review-side probes: MR1–MR8 in mendelian_randomization.md

Polygenic Risk Score (PRS / PGS)

  • Guide: Load analysis_guides/polygenic_risk_score.md before generating code
  • For developing/validating/applying a genome-wide polygenic score as a predictor or risk-stratifier (distinct from MR: PRS is prediction, MR is causal inference)
  • Base (discovery GWAS) and target/validation samples must be independent; tune (P+T / LDpred2 / PRS-CS shrinkage / quantile cut) on a separate tuning set and evaluate out-of-sample (avoid overfitting / winner's curse). Tools: PRSice-2, LDpred2 (bigsnpr), PRS-CS / PRS-CSx, BridgePRS
  • Ancestry portability is the central issue: report performance separately per target ancestry (within-group differences can rival between-group); prefer ancestry-matched / multi-ancestry discovery + PCs; do not extend a European-derived score to other ancestries without per-ancestry validation
  • Report OR/HR per SD (CI) + quantile absolute risk; discrimination (C/AUC) and calibration (plot + slope/intercept) in the target population — discrimination ≠ calibration
  • Incremental value is the clinical crux: report PRS on top of the guideline clinical model (SCORE2/QRISK3/PCE/Tyrer-Cuzick) — ΔC-statistic (CI), NRI/IDI, net benefit — not PRS-alone AUC. A screening claim needs detection-rate-at-fixed-FPR / likelihood ratio, not AUC
  • Prefer prospective/incident validation (prevalent case–control overstates utility); report against PGS-RS / TRIPOD+AI. Review-side probes: PG1–PG8 in polygenic_risk_score.md

NHIS Claims-Based Studies

  • Guide: Load analysis_guides/nhis_icd10_mapping.md for disease definition patterns
  • Claims-based algorithms: N-claim rule, claim+medication, look-back period
  • Always specify ICD-10 code ranges, claim count requirement, and time windows
  • Charlson comorbidity index: cite Quan 2005 adaptation
  • Anchor covariates to most recent data prior to index date
  • Sensitivity analysis: test stricter/looser disease definitions

Burden of Disease, Decomposition & Forecasting

  • Guide: Load analysis_guides/burden_decomposition_forecasting.md before generating code
  • For a burden-of-disease estimate, attributable-risk (PAF / comparative risk assessment), temporal-trend (joinpoint / AAPC), decomposition (Das Gupta; Arriaga life-expectancy), or forecast (BAPC / age-period-cohort)
  • The value-add-layer playbook — a descriptive rate is rarely publishable alone; bolt on ONE layer: decomposition (why the rate changed — aging vs population growth vs epidemiological change), PAF (how much is modifiable), joinpoint/AAPC pre-vs-post (did a datable policy/shock bend the trend), forecast (where it is going), Arriaga (which ages/causes drove ΔLE)
  • Uncertainty intervals, not CIs: report a draw-based 95% UI (2.5th–97.5th percentile of 250–500 draws propagated end-to-end); a UI crossing the null means insufficient evidence for direction, not a non-significant test
  • Single-center cohort adaptation: three layers port onto existing follow-up without new data — trend-break (reslice around a datable guideline/scanner-era change), forecast (project a serial imaging trajectory), and global framing (place the individual-level effect next to the published GBD burden from GHDx — contextualization, not re-estimation, so no GATHER trigger). The ecological "UI-replaces-confounding-control" shortcut does not port: individual-level data still needs the DAG / E-value / negative-control toolkit
  • Report the estimate against GATHER (/check-reporting); keep burden/attribution/decomposition/forecast descriptive or associational unless a causal design (natural experiment, MR — analysis_guides/mendelian_randomization.md) is in place

Repeated Measures

  • Guide: Load analysis_guides/repeated_measures.md before generating code
  • Template: references/templates/repeated_measures.py
  • Default method: LMM (handles missing data, no sphericity assumption)
  • RM ANOVA only if: no missing data AND few time points AND sphericity met
  • GEE for: population-averaged effects or non-normal outcomes
  • Always convert wide → long format first
  • Time × Group interaction is the key result — always report and interpret
  • Generate spaghetti plot (individual trajectories) + group mean trajectory plot
  • For LMM: report random effects structure, covariance structure (CS/AR1/UN), AIC/BIC
  • For RM ANOVA: report Mauchly's test, epsilon, correction method (Greenhouse-Geisser)
  • If missing > 5%: load analysis_guides/missing_data.md and apply MICE before analysis

Covariate Pitfalls: Structural Zeros & Dose/Duration Variables

Applies to any multivariable adjustment (logistic / linear / Cox / propensity-score / survey-weighted). Two coupled failure modes around a dose/duration variable anchored to a categorical exposure (pack-years under smoking status, grams/week under alcohol use, cessation-duration under former-smoker):

  • Structural-zero guard (do not impute): a never-smoker's pack_years is a structural zero, not missing-at-random — the value is known to be 0 by definition of the category. Feeding it to MICE/MNAR imputation as if it were missing fabricates a non-zero dose for unexposed subjects and corrupts the exposure contrast. Before imputing any dose/duration column, set the implied zero explicitly (IF status == 'never' THEN dose = 0) and impute only the genuinely-missing residual among the exposed. /clean-data flags categorical-implied-zero contradictions (a never row with a NULL dose) and ships scripts/check_structural_zero.py.
  • Complete-case collapse warning (use status, not dose, for adjustment): when a dose/duration variable enters a complete-case multivariable model, the unexposed stratum — which carries structural zeros often stored as NULL — is dropped wholesale, collapsing n (commonly 40–60%) and distorting subgroup estimates (a small stratum can shrink to a handful of subjects). For confounder adjustment use the categorical status variable (never/former/current); reserve the continuous dose for an exposed-only (e.g., ever-smoker-restricted) secondary analysis. Always report n before and after model fitting and confirm the denominator did not silently collapse.

Covariate Selection: Over-adjustment in a Cross-Sectional Outcome Model

Applies to any cross-sectional / single-visit outcome regression (the exposure and outcome are measured at one time point, so temporal order is not observed). The selection rule is causal, not statistical:

  • Do not adjust for a consequence or mediator of the outcome. A covariate that the outcome physiologically drives sits on or after the causal path; adjusting for it is over-adjustment / collider bias and removes part of the effect under study. The signature case is a renal-function outcome: with eGFR as the outcome, serum uric acid is renally excreted (a lower eGFR mechanically raises urate), so uric acid is an outcome-consequence, not a confounder; blood pressure and HbA1c are often similarly downstream. Classify each candidate covariate against a DAG as confounder / mediator / outcome-consequence / collider, and keep only confounders in the primary model.
  • "It differs in Table 1" is not a confounder-selection criterion. Baseline imbalance by exposure justifies considering a variable, but a mediator or outcome-consequence stays out regardless of how imbalanced it is. A kitchen-sink "adjust for everything that differs" model is over-adjusted by construction.
  • Report the suspect-covariate sensitivity + VIF. Make a parsimonious, history-/design-based model the primary one; report the fuller model as a sensitivity analysis that drops the suspect covariate (or adds it, if you start parsimonious), and show whether the headline estimate moves. Always print VIF (collinearity between an outcome-consequence and the outcome's other correlates is common) and the n actually fitted. If dropping the covariate materially changes the estimate, propagate to the abstract and conclusion.
  • Compare adjusted-vs-unadjusted on the SAME frame (extended-adjustment missingness trap). When an extended-adjustment model adds covariates that carry missingness, the analytic n shrinks (e.g. 84 → 49 events). Comparing that adjusted estimate to the full-frame unadjusted/base estimate confounds adjustment with case-concentrated missingness — it can look as if "adjustment inflated the estimate" when the drift is who-was-dropped. The fair anchor is the unadjusted estimate refit on the reduced complete-case frame (the same rows the adjusted model used): report unadjusted-and-adjusted on the reduced frame alongside the full-frame estimate, and never describe "adjustment changed the estimate" from a comparison across different frames. (Equivalently, use multiple imputation so all models share one frame.)

Language

  • Code and output: English
  • Communication with user: Match user's preferred language
  • Medical terms: English only

What This Skill Does NOT Do

  • Does not fabricate or simulate data to fill gaps
  • Does not choose analysis endpoints -- the user decides the research question
  • Does not interpret clinical significance -- only statistical results
  • Does not replace biostatistician review for complex designs (e.g., adaptive trials)

Anti-Hallucination

  • Never fabricate variable names, dataset column names, or variable codings. If a variable mapping is unce
Files (medsci-skills)
  • references
    • analysis_guides
      • agreement_reliability.md 6.5 KB
        # Inter-rater Agreement & Reliability Guide
        
        Quantifying how well two or more raters (or a rater and a reference, or repeated
        measurements) **agree**. The coefficient is easy to compute; the two ways these
        analyses fail review are (1) treating **clustered** measurements as independent
        (pseudoreplication) and (2) confusing **agreement** with **reliability**.
        
        ---
        
        ## When to Use
        
        - **Cohen's kappa** — 2 raters, categorical (nominal) labels.
        - **Weighted kappa** — 2 raters, **ordinal** labels (linear or quadratic weights; disagreement
          by one category counts less than by three).
        - **Fleiss' kappa** — ≥3 raters, categorical.
        - **Krippendorff's alpha** — any number of raters, any measurement level, tolerates missing data.
        - **ICC (intraclass correlation)** — **continuous** measurements; report the model + type (below).
        - **Bland–Altman** — two continuous methods/raters: bias (mean difference) + 95% limits of agreement.
        - NOT for: a single 2×2 vs a reference standard (that is diagnostic accuracy — see
          `table-types/diagnostic_accuracy.md`); not for a multi-reader AI-vs-human comparison with reader +
          case variance (that is an MRMC reader study — see `table-types/reader_study.md`).
        
        ---
        
        ## Pseudoreplication comes first (this, not the coefficient, is the issue)
        
        If each **subject contributes more than one measurement** — several lesions, aneurysms, nodules,
        slices, or time-points per patient — the rows are **not independent**. Computing agreement on the
        **pooled** rows (or on all pairwise distances) uses an inflated *n*, narrows the CI, and gives an
        **anti-conservative** p-value. This is the single most common reliability-study error a reviewer
        catches (it is flagged by the self-review probe **O18** in `observational_confounding.md`).
        
        Two correct paths — pick one and state it:
        
        1. **Aggregate to the independent unit first**, then compute agreement per subject. This is the
           simplest defensible analysis when a per-subject summary is meaningful (e.g. mean measurement,
           majority label, or one index lesion per subject).
        2. **Model the clustering** — a mixed-effects / variance-components ICC with a **subject random
           effect** (or a GEE with an exchangeable working correlation), so the within-subject correlation
           is estimated rather than ignored.
        
        A pooled-pairwise test can *flip* on correction: e.g. Mann–Whitney p = 0.02 on 448 pooled
        pairwise distances became p = 0.59 at the per-aneurysm level (n = 112). **Report the unit of
        analysis explicitly**, and when subjects have multiple measurements report a per-subject
        sensitivity analysis.
        
        ### Produce the pseudoreplication-safe version
        
        ```python
        import pandas as pd
        import pingouin as pg   # ICC with model/type + CI
        
        # long format: one row per (subject, measurement); rater columns rater1..raterK
        df = pd.read_csv("ratings.csv")
        
        # 1) DETECT clustering: more rows than independent subjects
        n_rows, n_subjects = len(df), df["subject_id"].nunique()
        if n_rows > n_subjects:
            print(f"CLUSTERED: {n_rows} measurements from {n_subjects} subjects "
                  f"({n_rows / n_subjects:.1f} per subject) — do NOT pool as independent.")
        
        # 2a) PER-SUBJECT AGGREGATION (continuous): mean per subject, then ICC on subject means
        per_subj = df.groupby("subject_id")[["rater1", "rater2"]].mean().reset_index()
        long = per_subj.melt(id_vars="subject_id", var_name="rater", value_name="score")
        icc = pg.intraclass_corr(data=long, targets="subject_id", raters="rater", nan_policy="omit")
        print(icc[["Type", "ICC", "CI95%"]])   # report Type (e.g. ICC2/ICC2k) + CI
        
        # 2b) OR MODEL THE CLUSTERING (keep every measurement, subject random effect)
        import statsmodels.formula.api as smf
        df_long = df.melt(id_vars="subject_id", value_vars=["rater1", "rater2"],
                          var_name="rater", value_name="score")
        m = smf.mixedlm("score ~ 1", data=df_long, groups=df_long["subject_id"])
        res = m.fit()
        var_between = float(res.cov_re.iloc[0, 0]); var_resid = float(res.scale)
        icc_clustered = var_between / (var_between + var_resid)
        print(f"variance-components ICC (subject random effect) = {icc_clustered:.3f}")
        ```
        
        ---
        
        ## ICC: state the model and the type (they are not interchangeable)
        
        - **Model**: one-way random (raters differ per subject), two-way random (same raters, generalise to
          a rater population), two-way mixed (same raters, these raters only).
        - **Type**: **agreement** vs **consistency** (agreement penalises systematic rater bias; consistency
          does not), and **single** vs **average** measurement (average-of-k is higher — only report it if
          the clinical use averages k raters).
        - Report as e.g. **ICC(2,1) = 0.82 (95% CI 0.74–0.88), two-way random, absolute agreement, single
          rater**. An ICC with no model/type is not interpretable.
        
        ---
        
        ## Agreement is not reliability
        
        - **Agreement** = do raters give the *same value* (absolute; Bland–Altman bias, absolute-agreement ICC).
        - **Reliability** = can raters *rank/discriminate subjects* consistently (relative; consistency ICC,
          Pearson/Spearman). A method can be highly reliable yet have poor agreement (a constant offset).
          State which one the clinical claim needs, and use the matching coefficient.
        
        ---
        
        ## Reporting
        
        - The coefficient **with a 95% CI** (bootstrap or analytic), the model/type (for ICC), and the
          **unit of analysis** (per-subject vs per-lesion, and the clustering handling).
        - The interpretation band used (e.g. Landis–Koch), but do not over-interpret a point estimate whose
          CI spans two bands.
        - For continuous methods: Bland–Altman **bias + 95% limits of agreement**, not just a correlation.
        
        ---
        
        ## Common failures (flag at review)
        
        - **Pooled/pairwise agreement on clustered data** (pseudoreplication) — the headline coefficient's
          CI is too narrow; re-run per-subject or with a subject random effect (probe O18).
        - **ICC reported with no model/type** — uninterpretable; the same data yields different ICCs.
        - **Reliability coefficient used to claim agreement** (or vice versa) — a high consistency ICC does
          not establish that the two methods are interchangeable.
        - **Correlation (r) reported as agreement** for two methods — r ignores a constant/proportional bias;
          Bland–Altman is required.
        - **Kappa on ordinal labels unweighted** — treats a one-category disagreement as a full disagreement.
        
        ---
        
        ## Anti-Hallucination
        
        - Never hand-type a coefficient or CI — compute it from the ratings CSV with a seeded script.
        - Do not quote an ICC without the model/type actually estimated by the code.
        - If subjects have multiple measurements, the per-subject sensitivity analysis is **mandatory** —
          do not report only the pooled number.
        
      • burden_decomposition_forecasting.md 8.8 KB
        # Burden of Disease, Decomposition & Forecasting
        
        Methodology reference for the **value-add analytic layers** that turn a plain descriptive
        result into a top-journal burden/estimate paper: population-attributable burden, rate
        decomposition, trend-break analysis, and forecasting. These are the reusable moves a
        high-output epidemiology group bolts onto a standing data platform (GBD/IHME, WHO Mortality
        Database, or a national registry): the disease is swapped, the shell stays fixed, and **one**
        value-add layer supplies the novelty. Report the estimate against **GATHER** (`/check-reporting`)
        and, for any individual-level cohort component, STROBE/RECORD.
        
        Load this guide before generating code for: a burden-of-disease estimate, an attributable-risk
        analysis, a temporal-trend / joinpoint analysis, a decomposition, or a forecast.
        
        ---
        
        ## 0. The value-add-layer playbook (pick ONE per paper)
        
        A descriptive number (an age-standardized rate, a prevalence) is rarely publishable alone. The
        lever is a single added analytic layer answering a *why / how-much / where-to* question:
        
        | Layer | Question it answers | Method | When the clinical need supports it |
        |---|---|---|---|
        | **Decomposition** | *Why* did the rate change? | Das Gupta (rate → aging / population growth / epidemiological change) | a rising/falling count where "is it just ageing?" is the reviewer's first question |
        | **Attribution** | *How much* is modifiable? | Comparative risk assessment / population-attributable fraction (PAF) vs a TMREL | an exposure with an established dose–response and a policy angle |
        | **Trend-break** | Did a *shock* bend the trend? | Joinpoint / AAPC, pre- vs post-period | a datable policy, guideline, or event (COVID, a screening-guideline change, a reimbursement shift) |
        | **Forecast** | *Where is it going*? | Age-period-cohort projection (BAPC / APC) | serial or repeated cross-sectional data with a stable trend |
        | **Life-expectancy decomposition** | Which ages/causes drove ΔLE? | Arriaga (age × cause partition) | a mortality/life-table framing |
        
        **For a single-institution health-checkup cohort** (no GBD platform): you cannot out-scale a
        burden study, but three of these layers port directly onto your existing follow-up —
        (1) **trend-break**: reslice your accrued follow-up around a datable guideline/scanner-era break
        rather than collecting new data; (2) **forecast**: project a serial imaging trajectory (CAC,
        emphysema, body-composition) forward; (3) **global framing by contextualization**: place your
        individual-level effect next to the *published* GBD burden number for that disease (free from the
        GHDx results tool) — this borrows the global frame without claiming to re-estimate it, so it does
        **not** trigger GATHER. The one thing you cannot borrow from the ecological template is its
        "robustness = uncertainty-interval propagation, no confounding control" shortcut: your data is
        individual-level, so the DAG / E-value / negative-control toolkit (see `propensity_score.md`,
        `survival.md`, `../style/`) still applies.
        
        ---
        
        ## 1. Uncertainty intervals (UI), not confidence intervals
        
        Model-based estimates carry a **95% uncertainty interval** = the 2.5th–97.5th percentile of the
        posterior/Monte-Carlo draws (GBD uses 250–500 draws), propagated through every modeling step.
        
        - Report a **UI**, not a CI; a UI crossing the null means "insufficient evidence for direction,"
          not "non-significant test." Never translate a UI into *P*-value language.
        - Propagate draws end-to-end: sample each input's distribution, run the full pipeline per draw,
          summarise percentiles of the output — do not add variances analytically at the end.
        
        ```r
        # draw-based UI for any derived quantity f(x)
        draws <- replicate(500, f(rinput()))                  # rinput() samples the input distribution
        est   <- quantile(draws, c(0.5, 0.025, 0.975))        # median + 95% UI
        ```
        
        ---
        
        ## 2. Attributable burden — comparative risk assessment / PAF
        
        Partition a disease burden into the fraction attributable to a modifiable exposure, against a
        **theoretical minimum-risk exposure level (TMREL)**.
        
        - **PAF** = Σ over exposure levels of `P(exposure) · (RR − 1)` / `[1 + Σ P(exposure)·(RR − 1)]`,
          evaluated relative to the TMREL. Attributable burden = PAF × total burden.
        - **Relative risks** come from a meta-analysis of prospective cohorts; carry the RR curve's
          uncertainty into the draws. Reclassify the dose–response shape honestly (monotonic vs J-shaped);
          a strictly harmful exposure has TMREL = 0.
        - **Mediation to avoid double-counting**: when risks act through measured mediators (a dietary
          factor → blood pressure / LDL / glucose → IHD), estimate the mediation matrix so combined PAFs
          do not exceed 100%.
        - Report against **GATHER** items 8–14 (bias correction, comparability, analysis detail,
          uncertainty). R: `epitools`, `graphPAF`, or a manual draw-based implementation.
        
        ## 3. Das Gupta decomposition — why did the count change?
        
        Partition a change in an aggregate count/rate into **population growth**, **population ageing**,
        and **epidemiological (rate) change** — the three components a reviewer of any "rising burden"
        claim asks to see separated.
        
        ```r
        # DemoDecomp implements Das Gupta / Horiuchi decomposition of a rate function
        # install.packages("DemoDecomp")
        library(DemoDecomp)
        # func: maps a parameter vector (age-specific rates, age structure, total pop) -> summary measure
        delta <- horiuchi(func = burden_fn, pars1 = year1_vec, pars2 = year2_vec, N = 20)
        # sum(delta) == observed change; group delta by its structural / rate / size components
        ```
        
        Report each component with a UI; the headline is usually "X% of the increase was population
        growth/ageing, not a real rise in risk."
        
        ## 4. Trend-break — joinpoint / AAPC, pre- vs post-shock
        
        Detect where a log-linear trend changes slope and summarise it as an **average annual percentage
        change (AAPC)**; compare a pre-shock and a during/post-shock window as a natural-experiment layer.
        
        ```r
        library(segmented)
        m  <- lm(log(rate) ~ year, data = d)                  # log-linear trend
        jp <- segmented(m, seg.Z = ~year)                     # estimate joinpoint(s)
        # APC per segment = 100*(exp(slope) - 1); AAPC = segment-length-weighted average of APCs
        # pre/post split: fit two windows (e.g. 2010-2019 vs 2020-2023) and compare AAPCs, do not pool
        ```
        
        The pre/post AAPC split is itself a robustness layer (two independent windows compared, not one
        pooled trend). For a checkup cohort, the "shock" is a datable guideline/reimbursement/scanner-era
        change; reslice existing follow-up around it.
        
        ## 5. Forecasting — age-period-cohort projection
        
        Project a rate forward with a **Bayesian age-period-cohort (BAPC)** model (integrated nested
        Laplace approximation), which yields predictive intervals natively.
        
        ```r
        # install.packages("BAPC"); needs INLA (r-inla.org)
        library(BAPC); library(INLA)
        proj <- BAPC(APCList(cases, population, gf = 5),
                     predict = list(npredict = 27, retro = TRUE))   # e.g. forecast to 2050 + back-test
        ```
        
        - **Back-test**: withhold the last k years, forecast them, report calibration — do not ship a
          projection with no retrospective validation.
        - A single predictor (SDI alone) or a single model is an ensemble caveat: state that no ensemble
          / no formal back-testing was done if it was not, rather than implying robustness.
        - Alternatives: `demography`/`StMoMo` (Lee–Carter family), or a simple age-period GLM with a
          clearly stated extrapolation assumption.
        
        ## 6. Life-expectancy decomposition (Arriaga)
        
        Partition a change in life expectancy into contributions by **age group** and, with cause-deleted
        life tables, by **cause of death** — the standard framing for a mortality life-table paper.
        
        ```r
        # DemoDecomp::stepwise_replacement or a direct Arriaga implementation on two life tables
        library(DemoDecomp)
        contrib <- stepwise_replacement(func = e0_from_mx, pars1 = mx_year1, pars2 = mx_year2)
        # contrib sums to Δe0; aggregate by age band (and by cause using cause-specific mx)
        ```
        
        ---
        
        ## Reporting & reproducibility
        
        - **GATHER** (`/check-reporting` `GATHER.md`) governs the estimate: define indicator/population/
          period (item 1), data sources and their biases (5–6), analysis detail with formulae (11),
          model evaluation and sensitivity (12–13), uncertainty methods and what was/was not accounted
          for (14), code access (15), and a machine-readable results file with a UI (17–18).
        - Seed every RNG; emit the draw-level results, not only summaries. State the platform/version
          (GBD round, WHO Mortality Database vintage) and cite it via its data DOI (GHDx for GBD).
        - Keep the causal claim honest: burden/attribution/decomposition/forecast are **descriptive or
          associational** unless an explicit causal design (a natural experiment with identification, an
          MR — see `mendelian_randomization.md`) is in place. Pair "adjustment covariate" with a
          non-causal disclaimer; do not call an adjustment covariate an instrument.
        
      • calibration.md 6.3 KB
        # Prediction-Model Calibration Guide
        
        For a clinical prediction model (a logistic risk score or a Cox/survival model at a fixed
        horizon), **discrimination (AUC / C-index) is not enough** — a model that ranks well can still
        output probabilities that are systematically too high or too low. The ways calibration fails
        review are (1) reporting **apparent (in-sample)** calibration — a slope of exactly 1.00 is the
        fingerprint — with no internal-validation correction, (2) leaning on the **Hosmer–Lemeshow**
        test (deprecated), and (3) omitting calibration entirely for a model meant to guide care. This
        guide produces the corrected estimand; it is the produce-side of probe **S7**.
        
        ---
        
        ## When to Use
        
        - Any model that outputs a **risk / probability** used for a decision (surveillance intensity,
          treatment eligibility, triage) — logistic or survival-at-a-horizon.
        - Reported **alongside** discrimination and, for a utility claim, decision-curve net benefit —
          never discrimination alone.
        - NOT a substitute for external validation: internal (bootstrap/CV) calibration corrects
          optimism but does not establish transportability.
        
        ---
        
        ## Apparent calibration is optimistic — correct it (this is the S7 issue)
        
        Fit a logistic model by maximum likelihood and its **apparent** calibration slope on the same
        data is **exactly 1.00** and its calibration-in-the-large intercept **exactly 0** — by
        construction, not because the model is well-calibrated. A slope printed as `1.00` (or metrics
        with no `bootstrap` / `cross-valid` / `optimism` / `held-out` token nearby) is presumptively
        in-sample. Produce the **bootstrap optimism-corrected** slope instead (Harrell/Steyerberg).
        
        ```python
        import numpy as np, statsmodels.api as sm
        X = ...            # design matrix (add_constant), y = 0/1 outcome
        def cal_slope(y, lp):
            m = sm.GLM(y, sm.add_constant(lp), family=sm.families.Binomial()).fit()
            return m.params[1], m.params[0]          # slope, calibration-in-the-large intercept
        
        full = sm.GLM(y, X, family=sm.families.Binomial()).fit()
        app_slope, app_int = cal_slope(y, X @ full.params)     # apparent: slope ~1.00, intercept ~0
        
        rng = np.random.default_rng(42); n = len(y); opt = []
        for _ in range(500):                                    # bootstrap optimism (Harrell)
            idx = rng.integers(0, n, n)
            bm = sm.GLM(y[idx], X[idx], family=sm.families.Binomial()).fit()
            s_boot, _ = cal_slope(y[idx], X[idx] @ bm.params)   # boot model on boot data (apparent)
            s_orig, _ = cal_slope(y,      X      @ bm.params)   # boot model on original data (test)
            opt.append(s_boot - s_orig)
        corrected_slope = app_slope - float(np.mean(opt))       # < 1.00 when the model overfits
        print(f"apparent slope {app_slope:.3f} -> optimism-corrected {corrected_slope:.3f}")
        ```
        
        A corrected slope **< 1** means predictions are too extreme (overfit) and should be shrunk
        (a penalized/uniform-shrinkage refit); a slope **> 1** means they are too moderate.
        
        ---
        
        ## The four levels of calibration (report weak calibration at least)
        
        Van Calster's hierarchy — report at minimum **weak calibration** (intercept + slope):
        
        - **Mean** (calibration-in-the-large): mean predicted = observed event rate (the intercept).
        - **Weak**: intercept ≈ 0 **and** slope ≈ 1.
        - **Moderate**: a **flexible calibration curve** (loess / spline of observed on predicted),
          not decile bins — the plot most reviewers now expect.
        - **Strong**: correct per-covariate (rarely achievable; not required).
        
        ```python
        import matplotlib.pyplot as plt
        from sklearn.calibration import calibration_curve
        phat = full.predict(X)
        frac_pos, mean_pred = calibration_curve(y, phat, n_bins=10, strategy="quantile")
        plt.plot([0, 1], [0, 1], "--"); plt.plot(mean_pred, frac_pos, "o-")   # add a loess curve for moderate
        plt.xlabel("Predicted probability"); plt.ylabel("Observed frequency")
        ```
        
        ---
        
        ## Brier score; do NOT rely on Hosmer–Lemeshow
        
        - **Brier score** = mean squared error of the probabilities; report the **scaled Brier**
          (1 − Brier / Brier_null) so it is interpretable against the event rate.
        - **Hosmer–Lemeshow is deprecated** (Van Calster 2016; Austin & Steyerberg): its p-value depends
          on an arbitrary number of bins, it is underpowered in small samples and rejects trivially in
          large ones, and it gives no direction or magnitude. Report the **calibration slope + intercept
          + a flexible calibration plot** instead of an H–L p-value.
        
        ```python
        from sklearn.metrics import brier_score_loss
        brier = brier_score_loss(y, phat); scaled = 1 - brier / (y.mean() * (1 - y.mean()))
        ```
        
        ## Survival models
        
        For a Cox/survival model, calibrate the **predicted vs observed risk at a fixed horizon**
        (e.g. 3-year): group by predicted-risk decile and compare to a Kaplan–Meier / pseudo-value
        estimate at that time, or use `rms::calibrate` / `pec` in R with bootstrap optimism correction.
        State the horizon; a model can be well-calibrated at 1 year and not at 5.
        
        ---
        
        ## Reporting
        
        - Calibration **intercept and slope** with the **internal-validation method named**
          (bootstrap/CV), not the apparent slope of 1.00; the flexible calibration plot.
        - Scaled Brier; the horizon (survival); the cohort each metric was computed on (development vs
          held-out vs external) stated explicitly.
        - Discrimination **and** calibration together; add decision-curve net benefit for a utility claim
          (see `table-standards/table-types/incremental_value.md` and the `make-figures` decision-curve
          exemplar).
        
        ---
        
        ## Common failures (flag at review)
        
        - **Apparent calibration slope of exactly 1.00** (and intercept 0) with no bootstrap/CV/external
          token — in-sample fit presented as calibration (S7).
        - **Hosmer–Lemeshow p-value** offered as the calibration evidence (deprecated).
        - **Discrimination reported without calibration** for a model meant to guide care (S7 → MAJOR).
        - **Decile-bin calibration only**, no flexible curve; or a survival calibration with no stated
          horizon.
        
        ---
        
        ## Anti-Hallucination
        
        - Never hand-type a calibration slope/intercept, Brier, or CI — compute it from predictions with
          a seeded script.
        - Do not report a calibration slope of 1.00 as evidence of good calibration — it is the
          apparent-fit artifact; report the optimism-corrected value the bootstrap produced.
        - Name the validation source of every calibration number (development / bootstrap-corrected /
          external); do not present development-sample calibration as validated performance.
        
      • diagnostic_accuracy.md 9.1 KB
        # Diagnostic Accuracy & Reader-Study Guide
        
        Estimating how well an index test (or an AI model / reader) separates disease from
        no-disease against a reference standard. The point estimates are easy to compute; the
        ways these analyses fail review are (1) reporting AUC / sensitivity / specificity **without
        CIs or on the wrong analysis unit**, (2) a **confidence-weighted / rating** score whose
        novelty is never tested against the simpler **unweighted** baseline, and (3) comparing two
        AUCs with a **fixed-reader** test when the claim is meant to **generalise to readers**.
        
        ---
        
        ## When to Use
        
        - **Single index test** vs a reference standard → sensitivity, specificity, PPV, NPV, accuracy
          (each with a 95% CI), and AUC (with a DeLong or bootstrap CI).
        - **Two tests / models on the same cases** → a **paired** AUC comparison (DeLong `roc.test`,
          `paired = TRUE`) — never two independent CIs eyeballed for overlap.
        - **Multi-reader multi-case (MRMC)** reader study (AI-vs-reader, modality comparison) → an
          MRMC method (Obuchowski–Rockette / DBM) that carries **reader + case** variance; see
          `table-standards/table-types/reader_study.md` and `make-figures` `exemplar_plots/mrmc_roc.md`.
        - NOT for: pooling accuracy across studies (that is a DTA meta-analysis — bivariate / HSROC via
          `mada`; see the `/meta-analysis` DTA path); not for rater **agreement** (that is
          `analysis_guides/agreement_reliability.md`).
        
        ---
        
        ## Every metric with a CI, on a stated analysis unit (the floor)
        
        A bare AUC / sensitivity / specificity is not reportable. Compute the CI, and state the unit
        (**per-patient vs per-lesion** — multiple lesions per patient are clustered, exactly as in
        `agreement_reliability.md`; a per-lesion count inflates *n*).
        
        ```python
        import numpy as np, pandas as pd
        from sklearn.metrics import roc_auc_score
        from statsmodels.stats.proportion import proportion_confint
        
        df = pd.read_csv("reader_calls.csv")   # truth (0/1), call (0/1), confidence (1..K), stratum
        
        # sensitivity / specificity / PPV / NPV at the operating point, each with a Wilson CI
        tp = int(((df.truth == 1) & (df.call == 1)).sum()); fn = int(((df.truth == 1) & (df.call == 0)).sum())
        tn = int(((df.truth == 0) & (df.call == 0)).sum()); fp = int(((df.truth == 0) & (df.call == 1)).sum())
        for name, num, den in [("sensitivity", tp, tp+fn), ("specificity", tn, tn+fp),
                               ("PPV", tp, tp+fp), ("NPV", tn, tn+fn)]:
            lo, hi = proportion_confint(num, den, method="wilson")
            print(f"{name} = {num/den:.3f} (95% CI {lo:.3f}-{hi:.3f}), n={den}")
        ```
        
        PPV and NPV are **prevalence-dependent** — do not transport them to a population with a
        different base rate; report sensitivity/specificity (prevalence-invariant) plus the study
        prevalence, and recompute predictive values at the target prevalence with Bayes' rule.
        
        ---
        
        ## The confidence-weighted trap comes first (this, not the AUC, is the issue)
        
        When the novelty is a **confidence-weighted / rating-collapsed** score used as the ROC
        predictor, you must (a) confirm the (call × confidence) encoding is **strictly monotone** — a
        folded encoding silently collides the most-confident-positive with a negative call — and
        (b) report the **unweighted binary-call AUC** beside the weighted one, so the weighting earns
        its place. This is the produce-side of self-review / peer-review probe **D9**.
        
        ```python
        K = int(df.confidence.max())
        
        # intended strictly-monotone composite: negative calls rank below positive calls, and within
        # a call higher confidence pushes further from the decision boundary.
        def composite(call, conf, K):
            return np.where(call == 1, K + conf, K + 1 - conf)   # neg: 1..K, pos: K+1..2K
        
        # (1) MONOTONIC-ENCODING CHECK — every distinct (call, confidence) must map to a distinct,
        #     correctly-ordered score. A collision is the folded-score bug (e.g. real/1 == ai/1).
        combos = df[["call", "confidence"]].drop_duplicates().copy()
        combos["score"] = composite(combos.call.values, combos.confidence.values, K)
        if combos.score.nunique() != len(combos):
            raise ValueError("ENCODING COLLISION — the confidence weighting is not strictly monotone "
                             "(folded-score bug); fix the encoding before computing AUC.")
        
        # (2) UNWEIGHTED BASELINE beside the weighted primary
        df["score"] = composite(df.call.values, df.confidence.values, K)
        print(f"AUC (confidence-weighted) = {roc_auc_score(df.truth, df.score):.3f}")
        print(f"AUC (unweighted binary call) = {roc_auc_score(df.truth, df.call):.3f}")
        ```
        
        If the weighted AUC materially exceeds the unweighted one, the weighting must be justified;
        if it does not, report the simpler estimator as primary. A weighted score that changed a
        hypothesis's direction versus its unweighted baseline is a **post-lock change to disclose**,
        not a silent primary.
        
        ---
        
        ## AUC confidence intervals and comparing two AUCs
        
        DeLong is the field-standard analytic AUC CI and paired comparison; `pROC` (R) is canonical.
        
        ```r
        library(pROC)
        d  <- read.csv("reader_calls.csv")
        rw <- roc(d$truth, d$score, quiet = TRUE)
        ci.auc(rw)                                   # DeLong 95% CI for the weighted AUC
        ru <- roc(d$truth, d$call,  quiet = TRUE)
        roc.test(rw, ru, method = "delong", paired = TRUE)   # paired, SAME cases (fixed-reader)
        ```
        
        A portable Python bootstrap CI (use when a DeLong implementation is unavailable):
        
        ```python
        def auc_ci_bootstrap(y, s, n_boot=2000, seed=42):
            rng = np.random.default_rng(seed); y = np.asarray(y); s = np.asarray(s); n = len(y)
            boots = [roc_auc_score(y[i], s[i]) for i in (rng.integers(0, n, n) for _ in range(n_boot))
                     if len(np.unique(y[i])) == 2]
            return tuple(np.percentile(boots, [2.5, 97.5]))
        ```
        
        **Fixed-reader vs MRMC.** A paired DeLong test treats the readers/cases as fixed. If the claim
        is that the result **generalises to readers** (a reader sample), a fixed-reader CI understates
        uncertainty — use an MRMC method (Obuchowski–Rockette / DBM) that carries reader + case
        variance, report per-reader and reader-averaged AUC, and state the unit (per-patient vs
        per-lesion) and the superiority/non-inferiority margin. This is probe D9's MRMC lead.
        
        ---
        
        ## Per-stratum admissibility: produce the table the claim is checked against (D10)
        
        A blanket "no subgroup met AUC ≥ 0.75 with lower bound ≥ 0.70" is falsified by a single tabled
        stratum that meets it. Produce the per-stratum AUC + CI and test each row against the rule
        rather than asserting a global negative.
        
        ```python
        rule = lambda auc, lo: (auc >= 0.75) and (lo >= 0.70)
        for name, g in df.groupby("stratum"):
            if g.truth.nunique() < 2:                       # single-class stratum: not estimable
                print(f"{name}: not estimable (one class)"); continue
            auc = roc_auc_score(g.truth, g.score); lo, hi = auc_ci_bootstrap(g.truth, g.score)
            print(f"{name}: AUC {auc:.3f} (95% CI {lo:.3f}-{hi:.3f}) "
                  f"{'QUALIFIES' if rule(auc, lo) else 'below'}")
        ```
        
        With *k* strata some crossings are expected — frame qualifying strata as hypothesis-generating
        (note the multiplicity), but do **not** deny a row the paper's own table satisfies.
        
        ---
        
        ## One scale per comparison (D11)
        
        Two values sharing a comparison column (or a row-wise "A vs B") must be computed under the
        **same normalisation / definition** — e.g. both volume errors as a standard relative error, not
        one relative and one range-normalised. If they are not identical, recompute on a common scale
        before any superiority/comparability claim, or footnote "not on the same scale".
        
        ---
        
        ## Reporting
        
        - Sensitivity, specificity, PPV, NPV, accuracy, and AUC each **with a 95% CI**; the operating
          point and how it was chosen (Youden vs a prespecified clinical threshold — a threshold picked
          on the same data is optimistic and needs a held-out or cross-validated estimate).
        - The **analysis unit** (per-patient vs per-lesion) and clustering handling; study prevalence.
        - For a weighted-score primary: the **unweighted-baseline AUC** beside it (D9).
        - AUC alone is insufficient for a clinical claim — pair discrimination with **calibration** and a
          **decision-curve / net-benefit** pass at the relevant threshold (see the incremental-value
          table type and the `make-figures` decision-curve exemplar).
        
        ---
        
        ## Common failures (flag at review)
        
        - **AUC / sensitivity / specificity with no CI**, or a per-lesion count reported as if per-patient.
        - **Confidence-weighted AUC with no unweighted baseline** and no monotonic-encoding check (D9) —
          the weighting may have created the result or hidden a folded-score bug.
        - **Two AUCs compared by CI overlap** instead of a paired DeLong test; a **fixed-reader** CI used
          for a claim that generalises to readers (needs MRMC).
        - **"No stratum met the rule" contradicted by the per-stratum table** (D10).
        - **Mixed-normalisation head-to-head** in one column (D11).
        - **PPV/NPV transported** across a prevalence change.
        
        ---
        
        ## Anti-Hallucination
        
        - Never hand-type an AUC, sensitivity/specificity, or CI — compute it from the calls CSV with a
          seeded script; carry each estimate together with its CI (never a bare AUC).
        - Do not report a weighted-score AUC without the unweighted baseline the code produced.
        - Do not quote a DeLong comparison p-value that a paired test on the same cases did not produce.
        
      • health_economic_evaluation.md 5.2 KB
        # Health Economic Evaluation Analysis Guide
        
        Comparing the **costs and consequences** of two or more interventions to inform a coverage,
        adoption, or treatment decision. The headline — an **incremental cost-effectiveness ratio (ICER)**
        — is arithmetically trivial; what fails review is **the structural choices behind it** (perspective,
        time horizon, discounting, the effectiveness source, the cost basis, the model, and the propagation
        of uncertainty). So the analysis is mostly the costing, the model, and the sensitivity suite, not the
        ratio. This is the analysis-side companion to review probes **HE1–HE8** in
        `health_economic_evaluation.md` and the **CHEERS 2022** reporting checklist.
        
        ---
        
        ## When to Use
        
        - A comparative decision question (adopt / reimburse / treat) where both **cost** and **health
          outcome** differ between options: cost-effectiveness (CEA, natural units — life-years, events
          avoided), **cost-utility** (CUA, QALYs — the default for reimbursement), cost-benefit (CBA,
          monetised outcomes), cost-minimisation (only when outcomes are demonstrably equivalent), or
          **budget-impact** (affordability, distinct from cost-effectiveness).
        - **Trial-based** (within-RCT patient-level costs and outcomes) or **decision-model-based** (decision
          tree for short horizons; **Markov / state-transition** or **discrete-event simulation** when timing
          and recurrence matter; lifetime horizons).
        - NOT for: a costing/burden-of-illness description with no comparator (not an economic *evaluation*);
          asserting "cost-effective" from a point ICER with no uncertainty analysis; cost-minimisation when a
          non-inferiority outcome claim has not actually been established.
        
        ## Core quantities
        
        - **Incremental cost** ΔC and **incremental effect** ΔE between an intervention and its comparator;
          the **ICER = ΔC / ΔE** (e.g. cost per QALY gained). With ≥3 options, rank by cost, remove
          **dominated** (more costly, less effective) and **extended-dominated** strategies, then compute
          ICERs sequentially along the efficient frontier.
        - **Net benefit** at a willingness-to-pay threshold λ: **INMB = λ·ΔE − ΔC** (monetary) or
          **INHB = ΔE − ΔC/λ** (health). Net benefit is linear and avoids the ICER's quadrant ambiguity, so
          it is preferred for regression and for probabilistic summaries.
        - **QALYs** = time × **utility** (preference-based, 0=dead, 1=full health) from a named instrument
          (EQ-5D-3L/5L, SF-6D, HUI) and a stated **value set/tariff** for the relevant country.
        
        ## The structural choices (state and justify each)
        
        - **Perspective** — healthcare-system/payer vs **societal**; determines which costs count (societal
          adds productivity and informal-care costs). Apply it consistently.
        - **Time horizon** — long enough to capture all differential costs and effects; **lifetime** for
          chronic disease or interventions with lasting effects. Extrapolation beyond trial data must be
          modelled explicitly (e.g. parametric survival extrapolation) and tested.
        - **Discounting** — apply the jurisdiction's reference-case rate (commonly ~3% or 3.5%) to **both
          costs and outcomes**; sensitivity at alternative rates.
        - **Costing** — report **resource quantities and unit costs separately**; state **currency, price
          year**, and inflation/currency conversion. Match cost categories to the perspective.
        - **Model** — justify structure against natural history; state cycle length and half-cycle
          correction (Markov); validate (internal/face/external/predictive); test **structural** uncertainty
          via scenario analysis.
        
        ## Uncertainty (the analytic core)
        
        - **Deterministic** — one-way and **tornado** diagrams to find the drivers; multi-way / scenario
          analyses for methodological and structural choices (discount rate, time horizon, alternative
          model structures).
        - **Probabilistic (PSA)** — assign each uncertain parameter a **distribution** (beta for
          probabilities and utilities; gamma or log-normal for costs; Dirichlet for transition-probability
          sets), propagate by **Monte Carlo**, and report the **cost-effectiveness plane** (the cloud of ΔC,
          ΔE draws) and the **cost-effectiveness acceptability curve (CEAC)** — P(cost-effective) across a
          range of λ. Report results as net benefit at the relevant threshold with its uncertainty, not a
          bare point ICER.
        - **Value of information** (EVPI/EVPPI) is an optional extension quantifying the expected cost of
          current decision uncertainty / the priority parameters for further research.
        
        ## Reporting & tools
        
        - Report to **CHEERS 2022** (28 items): perspective, horizon, discount rate, currency/price year,
          model rationale, study parameters with distributions, disaggregated costs/outcomes, the ICER,
          and the uncertainty analysis (plane + CEAC). State the **willingness-to-pay threshold** and make
          the "cost-effective" conclusion conditional on it and on the CEAC probability.
        - Tools: R — `heemod` / `dampack` / `hesim` (state-transition + PSA + CEAC), `BCEA` (Bayesian
          cost-effectiveness, CEAC/EVPI), `survival`/`flexsurv` (survival extrapolation); also TreeAge, or
          spreadsheet models with a documented PSA. Make the model or the health-economic analysis plan
          available for scrutiny.
        - Companion review probes: **HE1–HE8** (`peer-review`/`self-review`
          `references/domain-probes/health_economic_evaluation.md`).
        
      • mediation.md 4.5 KB
        # Mediation Analysis Guide
        
        Decomposing an exposure–outcome association into a path **through** a mediator (indirect
        effect) and a path **not through** it (direct effect). The estimate is easy; the
        *identification* is hard — that is where mediation analyses fail review.
        
        ---
        
        ## When to Use
        
        - A pre-specified hypothesis that exposure X affects outcome Y **partly via** mediator M
        - M is measured, plausibly on the causal path, and (ideally) measured **after X and before Y**
        - NOT for: a single-timepoint design used to assert a causal X→M→Y chain (see the design caveat
          below — that is the most common reason mediation papers are rejected); not a substitute for a
          longitudinal or experimental test of the mechanism
        
        ---
        
        ## Identification comes first (this, not the bootstrap, is the issue)
        
        A bootstrapped indirect-effect CI quantifies the **sampling variability** of the a×b product. It
        says nothing about whether the indirect effect is **identified**. Causal mediation requires, beyond
        no exposure–outcome and no exposure–mediator confounding:
        
        - **No unmeasured mediator–outcome confounding (sequential ignorability).** This must hold *even in
          a trial that randomizes X*, because M is not randomized. In observational data it essentially
          never holds unmodelled — so a **sensitivity analysis is mandatory**, not optional.
        - **No mediator–outcome confounder affected by the exposure** (an exposure-induced M–Y confounder
          breaks the standard product/difference method; use a method that allows it, e.g. an interventional
          or G-computation estimator).
        - **Correct temporal order.** X precedes M precedes Y. A **cross-sectional** design measures all
          three together and cannot establish this — report the indirect effect as *consistent with*
          mediation, state plainly that the design cannot order X/M/Y, and reserve the causal claim for a
          two-wave / longitudinal design (review probe O13 in `observational_confounding.md`).
        
        ## Method
        
        - **Estimator**: bootstrapped product-of-coefficients (a×b) is standard for continuous M and Y
          (R `mediation`, `CMAverse`, or Hayes PROCESS; Python `pingouin`/`statsmodels`). For a binary
          outcome use the **counterfactual / natural-effects** decomposition (e.g. `CMAverse`,
          `regmedint`), not the naive product on the odds-ratio scale (non-collapsibility distorts it).
        - **Bootstrap** the indirect effect (≥ 2000, ideally 5000 resamples) and report the **bias-corrected
          percentile CI** — not the Sobel test (Sobel assumes normality of a×b and is underpowered).
        - **Exposure–mediator interaction**: with the counterfactual approach, report the natural direct and
          indirect effects allowing X×M interaction rather than assuming it away.
        
        ## Reporting (AGReMA)
        
        Report against **AGReMA** (A Guideline for Reporting Mediation Analyses). Minimum:
        
        - The full mediation model (every path), the estimator, and the confounder set for **each** of the
          X→Y, X→M, and M→Y relationships (they differ — name them separately).
        - Total, direct, and indirect effects, each with a bootstrap CI.
        - **Proportion mediated only with uncertainty, and only when the total effect is well-estimated.**
          Proportion mediated = indirect / total is **unstable when the total effect is small or near-null**
          — it can exceed 100% or flip sign. Do not headline a proportion-mediated when the total-effect CI
          is wide or crosses the null.
        - The **sensitivity analysis** for unmeasured mediator–outcome confounding: an **E-value for the
          indirect effect** (or a ρ-/correlation-based mediation sensitivity, e.g. `mediation::medsens`),
          stating how strong unmeasured M–Y confounding would have to be to null the indirect effect.
        - The temporal structure of X, M, Y (and, if cross-sectional, the explicit caveat).
        
        ## Common failures (flag at review)
        
        - Causal "X affects Y through M" from cross-sectional data with no temporal caveat → reframe to
          association-level + add the sensitivity analysis (O13).
        - A significant bootstrap CI treated as evidence of identification.
        - No sensitivity analysis for unmeasured M–Y confounding.
        - Proportion-mediated quoted as a stable figure when the total effect is small.
        - Binary-outcome mediation via the naive OR product instead of a counterfactual decomposition.
        
        ## Anti-Hallucination
        
        - Never fabricate path coefficients, indirect effects, or bootstrap CIs — all from executed code.
        - Do not assert a causal direction the design cannot support; state the identification assumptions.
        - Generate references via `/search-lit` (AGReMA, VanderWeele).
        
      • mendelian_randomization.md 5.2 KB
        # Mendelian Randomization Analysis Guide
        
        Using germline genetic variants as **instrumental variables (IVs)** for an exposure to
        strengthen causal inference when an RCT is infeasible or unethical. The point estimate (a ratio
        of GWAS associations) is easy; what fails review is **instrument validity, pleiotropy, and the
        assumptions** — so the analysis is mostly the sensitivity suite, not the headline number. This is
        the analysis-side companion to review probes **MR1–MR8** in `mendelian_randomization.md`.
        
        ---
        
        ## When to Use
        
        - A causal question where an RCT is infeasible/unethical AND a **strong genetic instrument** for
          a **modifiable, well-defined, heritable** exposure exists.
        - two-sample summary-data MR (most common), one-sample MR, multivariable MR (MVMR), drug-target /
          cis-MR, non-linear MR (NLMR).
        - NOT for: a non-modifiable / ill-defined / composite exposure; a confirmatory **clinical-effect-
          size** claim — MR estimates a **lifelong genetic-proxy effect direction**, not the magnitude of
          a drug/behaviour change started in adulthood.
        
        ## The three IV assumptions (state and evidence each)
        
        - **Relevance** — the instrument is strongly associated with the exposure → report the
          **F-statistic** and variance explained (R²).
        - **Independence / exchangeability** — the instrument is independent of confounders → ancestry-
          matched GWAS + principal components; a PhenoScanner-style confounder scan.
        - **Exclusion restriction** — the instrument affects the outcome only through the exposure (no
          **horizontal pleiotropy**). Untestable directly → addressed via the sensitivity suite below.
        
        ## Instrument construction
        
        - Select at genome-wide significance (P < 5 × 10⁻⁸), **LD-clump** (e.g. r² < 0.001, 10 Mb window;
          report a stricter-r² sensitivity), handle **palindromic/ambiguous** SNPs, and **harmonize effect
          alleles** across the exposure and outcome GWAS (a harmonization error flips the sign).
        - **Instrument strength**: per-SNP F = (β_GX / se_GX)²; overall F = [R²(N − k − 1)] / [(1 − R²)k];
          a mean F well above ~10. For **MVMR** report the **conditional F**.
        - **Winner's curse**: select instruments in a GWAS **independent** of the one used for the β_GX
          estimate; selecting and estimating in the same sample inflates the instrument–exposure effect.
        - **cis / drug-target**: variants within ±(gene window) of the target; correlated cis variants need
          a **GLS-corrected IVW** with the LD matrix (naive IVW assumes independent instruments).
        
        ## Estimation + sensitivity suite (pre-specify ALL, not IVW alone)
        
        - **IVW** (random-effects) as the primary estimator.
        - **MR-Egger** (its **intercept** tests directional pleiotropy), **weighted median** (valid if ≤50 %
          of weight is invalid), **weighted mode**, **MR-PRESSO** (outlier detection/correction).
        - **Heterogeneity**: Cochran's Q, I²_GX; **leave-one-out** and **single-SNP** diagnostics.
        - **Concordance across methods is the robustness claim** — not the IVW point estimate alone.
        
        ```r
        library(TwoSampleMR)   # or the MendelianRandomization package
        dat <- harmonise_data(exposure_dat, outcome_dat)          # align effect alleles
        res <- mr(dat, method_list = c("mr_ivw", "mr_egger_regression",
                                       "mr_weighted_median", "mr_weighted_mode"))
        mr_pleiotropy_test(dat)    # MR-Egger intercept (directional pleiotropy)
        mr_heterogeneity(dat)      # Cochran's Q
        # MRPRESSO::mr_presso(...) for outlier detection/correction
        # F-stat: with R2 and N -> F = (R2*(N-k-1))/((1-R2)*k)
        ```
        
        ## Direction, overlap, ancestry
        
        - **Reverse causation**: Steiger filtering (the instrument should explain more variance in the
          exposure than the outcome) and/or **bidirectional MR**.
        - **Sample overlap**: two-sample MR assumes independent samples. Report the overlap fraction; overlap
          biases toward the confounded observational estimate and, with weak instruments, inflates type-1
          error → use non-overlapping samples or an overlap-aware correction, or state the bias direction.
        - **Ancestry**: instruments and LD reference must match the GWAS ancestry; control population
          stratification.
        
        ## MVMR, drug-target, non-linear
        
        - **MVMR** for measured pleiotropy / mediation — estimate conditional (direct) effects; report the
          **conditional F** for instrument strength.
        - **Drug-target / cis-MR**: report **colocalization** (`coloc`) to separate a shared causal variant
          from LD confounding, a **positive control** (a known on-target effect), and a **phenome-wide
          adverse-effect scan** for any safety claim.
        - **Non-linear MR**: the residual and doubly-ranked methods can produce **artefactual shapes** — a
          quoted inflection/threshold is not validated. Require **negative + positive control outcomes**,
          **sensitivity excluding extreme strata**, biological plausibility, and triangulation before
          trusting an NLMR shape (the genetic analogue of data-driven threshold mining).
        
        ## Reporting (STROBE-MR)
        
        Report against **STROBE-MR**: data sources + sample sizes, instrument-selection criteria and
        strength, the three assumptions and how each was addressed, harmonization, the full sensitivity
        suite, sample overlap, and ancestry. Interpret the estimate as a **lifelong genetic-proxy effect
        direction**, not a clinical-intervention magnitude. Review-side probes: MR1–MR8 in
        `mendelian_randomization.md`.
        
      • missing_data.md 3.7 KB
        # Missing Data Guide
        
        Strategies for handling missing data in medical research analyses.
        This is a preprocessing step, not an independent analysis type.
        
        ---
        
        ## Missing Data Mechanisms
        
        | Mechanism | Definition | Example | Implication |
        |-----------|-----------|---------|-------------|
        | **MCAR** | Missing completely at random | Specimen lost in transit | Complete case analysis OK |
        | **MAR** | Missingness depends on observed data | Older patients more likely to drop out | **Multiple imputation needed** |
        | **MNAR** | Missingness depends on unobserved value | Sicker patients miss follow-up | Sensitivity analysis required |
        
        MNAR cannot be verified statistically — always consider as a possibility.
        
        ---
        
        ## Decision by Missing Rate
        
        | Missing % | Action |
        |-----------|--------|
        | < 5% | Complete case analysis generally acceptable |
        | 5-20% | **Multiple imputation recommended** |
        | 20-40% | MI + sensitivity analysis mandatory |
        | > 40% | Consider excluding the variable from analysis |
        
        ---
        
        ## Multiple Imputation via Chained Equations (MICE)
        
        ### Procedure
        1. Specify imputation model for each variable with missing data
        2. Generate m imputed datasets (m = 5 minimum; m = 20 if missing > 20%)
        3. Analyze each dataset independently (same analysis model)
        4. Pool results using **Rubin's rules**: estimate = mean of m estimates; variance = within + between variance
        
        ### Imputation Model Rules
        - Include ALL variables from the analysis model in the imputation model
        - Include the outcome variable (in imputation model only)
        - Match variable type to method:
          - Continuous → predictive mean matching (PMM) or regression
          - Binary → logistic regression
          - Multinomial → polytomous regression
          - Ordinal → proportional odds
        
        ### Methods NOT Recommended
        - **Mean imputation**: underestimates variance — never use
        - **LOCF (Last Observation Carried Forward)**: biased in most settings — avoid
        - **Single imputation**: does not account for imputation uncertainty
        
        ---
        
        ## Reporting Template
        
        "Missing data ranged from X% (variable A) to Y% (variable B). Little's MCAR test suggested data were not MCAR (P = 0.003). Multiple imputation using chained equations (MICE) was performed with m = 20 imputed datasets. Results were pooled using Rubin's rules. Sensitivity analyses using complete case analysis yielded consistent results (Supplementary Table X)."
        
        ---
        
        ## When to Trigger in analyze-stats
        
        During Phase 1 (Data Assessment), if any analysis variable has > 5% missing:
        1. Report missing counts and percentages
        2. Suggest MICE before proceeding
        3. Generate imputation code as a preprocessing step
        4. Run the primary analysis on imputed data
        5. Run complete case analysis as sensitivity analysis
        
        ---
        
        ## Python Implementation
        
        ```python
        from sklearn.experimental import enable_iterative_imputer
        from sklearn.impute import IterativeImputer
        import numpy as np
        import pandas as pd
        
        m = 20  # number of imputations
        results = []
        for i in range(m):
            imputer = IterativeImputer(max_iter=10, random_state=i, sample_posterior=True)
            df_imputed = pd.DataFrame(imputer.fit_transform(df), columns=df.columns)
            # Run analysis on each imputed dataset
            # ... append results
        # Pool using Rubin's rules
        pooled_estimate = np.mean(results, axis=0)
        ```
        
        ## R Implementation
        
        ```r
        library(mice)
        imp <- mice(df, m = 20, method = 'pmm', seed = 42)
        fit <- with(imp, lm(y ~ x1 + x2))
        pooled <- pool(fit)
        summary(pooled)
        ```
        
        ---
        
        ## Common Reviewer Flags
        
        1. Not reporting missing data counts per variable
        2. Using listwise deletion without justification
        3. Mean imputation or LOCF without acknowledging limitations
        4. Not performing sensitivity analysis (complete case vs MI comparison)
        5. Not stating the assumed missing mechanism (MCAR/MAR/MNAR)
        
      • multiplicity.md 5.8 KB
        # Multiple Testing & High-Dimensional Screening Guide
        
        Testing **many exposures/predictors against one (or a few) outcomes at once** — an
        exposome-/environment-/metabolome-/proteome-/nutrient-wide association scan (ExWAS / EWAS /
        MWAS), or any "we screened N candidate predictors and report the significant ones" pass. The
        test is easy; what fails review is the **multiplicity bookkeeping, the replication, and the
        selective reporting**. This is the analysis-side companion to review probe **O17** in
        `observational_confounding.md`.
        
        ---
        
        ## When to Use
        
        - A scan that tests many exposures/biomarkers/features against an outcome simultaneously.
        - Any procedure that **selects** "significant" predictors out of a large candidate set.
        - NOT for: a single pre-specified exposure (use `regression.md`); a search for a cut-point or
          non-linear shape **within one** exposure (a different multiplicity — review probe O12); a
          confirmatory model with a handful of pre-registered covariates.
        
        ---
        
        ## Match the correction to the claim (FWER vs FDR)
        
        The two control different things — pick by what the manuscript will claim, and **report the
        method together with `m`, the number of tests** (the denominator).
        
        - **FWER — family-wise error rate** (Bonferroni, Holm, or a permutation-based study-wide
          threshold). Controls the probability of **any** false positive. Use for a **confirmatory
          single-hit** claim. Bonferroni threshold = α / m (e.g. a proteome scan of 1463 proteins at a
          GWAS-style 5 × 10⁻⁸ gives ≈ 3.4 × 10⁻¹¹). A **permutation-based** "exposome-wide / metabolome-
          wide significance level" estimates the threshold that controls FWER **accounting for the
          correlation** among exposures, so it is less conservative than raw Bonferroni.
        - **FDR — false discovery rate** (Benjamini–Hochberg q-value; Benjamini–Yekutieli under
          arbitrary dependence). Controls the **expected proportion of false positives among the
          declared hits**. Use for **discovery/screening**, and then **frame the result as hypothesis-
          generating, not confirmatory**.
        - **Honest denominator.** Apply the correction to the **whole tested set** — count every
          exposure tested a priori, including ones too sparse to model well. Shrinking `m` to the
          favoured hits, or quoting raw p < 0.05 across hundreds of tests, is the core error.
        
        ## Replication is the real safeguard (not the correction alone)
        
        Agnostic scans carry a **high false-discovery proportion even after FDR**. The load-bearing
        control is an **independent replication**:
        
        - a **split-half** discovery/replication within the cohort (keep the case proportion matched
          across splits), a **second cohort**, or a **second survey cycle** (e.g. NHANES cross-cycle);
        - require **directional concordance** and **report the replication rate** (e.g. "110/164
          exposures replicated", "59% survived correction and were directionally concordant");
        - a held-out **validation** set is used **once**, for the final model — never reused as a second
          discovery pass.
        
        A single-cohort FDR-significant scan with no replication is **exploratory**; its top hits are
        candidates, not findings.
        
        ## Correlated exposures (two consequences)
        
        Exposome/omics exposures are heavily intercorrelated, so:
        
        1. **Raw Bonferroni is over-conservative** — the effective number of independent tests is
           smaller than `m`. Use a permutation threshold, or an **effective-number-of-tests** adjustment
           (eigenvalue decomposition of the exposure correlation matrix; Li & Ji / `poolr::meff()`).
        2. **A univariate hit can be a marker for a correlated true cause** — the single-exposure model
           ignores co-exposure confounding / mixtures. Before any causal reading, address the structure
           with **clustering / dimension reduction** or a **multi-exposure / mixture model**
           (elastic-net, WQS regression, quantile g-computation, or BKMR).
        
        ## Code patterns
        
        ```r
        # FDR / FWER adjustment over a vector of p-values from the scan
        padj_fdr <- p.adjust(pvals, method = "BH")     # discovery / screening
        padj_fwer <- p.adjust(pvals, method = "holm")  # or "bonferroni" — confirmatory
        q <- qvalue::qvalue(pvals)$qvalues             # Storey q-values
        
        # effective number of independent tests under correlated exposures
        m_eff <- poolr::meff(R = cor(exposure_matrix), method = "liji")  # then alpha / m_eff
        
        # permutation-based study-wide significance threshold (correlation-aware)
        # shuffle the outcome B times, refit the scan, keep the min p per permutation;
        # the (alpha)-quantile of that min-p null is the study-wide threshold.
        ```
        
        For mixture / multi-exposure modelling use `glmnet` (elastic-net), `gWQS` (WQS), `qgcomp`
        (quantile g-computation), or `bkmr` (Bayesian kernel machine regression).
        
        ## Reporting checklist (what reviewers check)
        
        - **Number of exposures tested (`m`)** stated; correction applied to the whole set.
        - **Correction method named and matched to the claim** — FWER for confirmatory, FDR for
          discovery (and then framed as hypothesis-generating).
        - **Replication design + rate** reported (split-half / second cohort / cross-cycle, directional
          concordance).
        - **Full results in a supplement** — every tested exposure with its effect size and p/q, not
          only the winners; pre-registration of the exposure list is ideal (guards against
          selective-reporting / HARKing). Nominal (uncorrected) hits may be shown only if **labelled as
          not surviving correction**.
        - **Effect sizes + the resolution floor** — with the large N these scans run on, trivial effects
          clear any threshold; report magnitudes and note that a permutation with `k` permutations
          cannot resolve p below ≈ 1/k. A bare "number of significant exposures" overstates the finding.
        - **Complex-survey data** (NHANES / KNHANES / CHNS): combine **design-based standard errors**
          (weights + strata + PSU — see `survey_weighted.md`) **with** the multiplicity correction, not
          one or the other.
        
      • network_meta_analysis.md 4 KB
        # Network Meta-Analysis (NMA) Analysis Guide
        
        Comparing three or more interventions at once by combining **direct** (head-to-head) and
        **indirect** evidence across a network, usually with a treatment **ranking**. The pooling is
        standard meta-analysis; what fails review is the **transitivity assumption, consistency between
        direct and indirect evidence, network geometry, ranking interpretation, and network-level
        certainty**. This is the analysis-side companion to review probes **NM1–NM8** in
        `network_meta_analysis.md`; for the pairwise machinery (search, screening, the random-effects
        model, study-count thresholds) use the SR/MA workflow.
        
        ---
        
        ## When to Use
        
        - Synthesis of ≥3 interventions with a connected evidence network (direct + indirect), or a
          **component NMA** decomposing multicomponent interventions.
        - NOT for: a two-treatment pairwise meta-analysis (standard random-effects MA); a DTA meta-analysis
          (bivariate/HSROC).
        
        ## Transitivity (assess before pooling)
        
        - An NMA is valid only if **effect modifiers are distributed similarly across the comparisons**
          (transitivity). **Assess and report it**: compare the distribution of plausible effect modifiers
          (baseline severity, age, dose, follow-up, year) across comparisons (box plots / a table), and/or
          network meta-regression. Transitivity is a clinical/epidemiological judgment — no test replaces it.
        
        ## Consistency / incoherence (the statistical footprint)
        
        - Test **globally** (design-by-treatment interaction model) AND **locally** (node-splitting /
          loop-specific / back-calculation). A **star network (no closed loops)** cannot be checked — say so;
          the result then rests entirely on transitivity.
        - Investigate the **source** of any inconsistency (often a single trial/loop), don't just report a
          global p value.
        
        ```r
        library(netmeta)              # frequentist
        nm <- netmeta(TE, seTE, treat1, treat2, studlab, data = d, sm = "OR", common = FALSE)
        netsplit(nm)                  # local incoherence (direct vs indirect per comparison)
        decomp.design(nm)             # global design-by-treatment / Q decomposition
        netheat(nm)                   # net heat plot for inconsistency hotspots
        netrank(nm, small.values = "bad")   # P-scores (frequentist ranking)
        funnel(nm, order = ...)       # comparison-adjusted funnel plot
        # Bayesian alternative: BUGSnet / gemtc / multinma (node-split, SUCRA, model fit via DIC)
        ```
        
        ## Network geometry, heterogeneity, ranking
        
        - Present a **network plot** (node size ∝ sample size / #studies; edge thickness ∝ #trials) so
          thin (single-trial) edges and dominant nodes are visible; sparse/poorly-connected networks give
          fragile estimates.
        - Report **between-study heterogeneity** (global τ²) and examine the usual **common-heterogeneity**
          assumption; high heterogeneity undermines pooling and transitivity.
        - **Ranking (SUCRA / P-score / rankograms) is not a test of superiority** and is unstable when
          estimates are imprecise. Report ranking **with** the effect estimates (a **league table**), their
          intervals, and certainty — never "X is best" from SUCRA alone.
        
        ## Certainty (CINeMA / GRADE-NMA)
        
        - Rate certainty **per network estimate** (within-study bias, reporting bias, indirectness,
          imprecision, heterogeneity, incoherence) via **CINeMA** or GRADE-NMA; **downgrade indirect-only**
          comparisons. Scale conclusions to certainty — a low-certainty estimate cannot support "superior."
        
        ## Component NMA
        
        - A **component NMA** assumes **additivity** of component effects (often no component interaction) —
          state and, where possible, check it (an interaction/full-interaction model). Define the reference
          and the estimand (relative effects via a league table) explicitly.
        
        ## Reporting (PRISMA-NMA)
        
        Report against **PRISMA-NMA**: the network plot, the transitivity assessment, the consistency
        evaluation (global + local), the ranking with its caveats, comparison-adjusted publication-bias
        assessment, and estimate-level certainty. Risk of bias uses **RoB-NMA**; pairwise items via the SR/MA
        workflow. Review-side probes: NM1–NM8 in `network_meta_analysis.md`.
        
      • nhis_icd10_mapping.md 9 KB
        # NHIS Claims-Based Disease Definition Guide
        
        Methodological patterns for defining exposures, outcomes, and comorbidities
        using ICD-10 codes in Korean NHIS claims data. This guide covers the validated
        algorithm patterns -- NOT specific code lists for individual diseases.
        
        ---
        
        ## When to Use
        
        - Defining study populations, exposures, or outcomes in NHIS cohort studies
        - Building claims-based algorithms for disease ascertainment
        - Validating disease definitions using multiple data sources within NHIS
        - NOT for: survey data (KNHANES/NHANES), clinical registry data
        
        ---
        
        ## Core Principle: Claims-Based Algorithms
        
        A single ICD-10 claim is insufficient for disease ascertainment due to rule-out
        diagnoses, coding errors, and provisional codes. Validated algorithms combine
        multiple data elements to improve positive predictive value.
        
        ### Algorithm Components
        
        | Component | Source in NHIS | Purpose |
        |-----------|---------------|---------|
        | **ICD-10 diagnostic codes** | Inpatient + outpatient claims | Primary disease identification |
        | **Medication prescriptions** | Pharmacy dispensing records | Confirms active treatment |
        | **Procedure codes** | Claims procedure fields | Confirms diagnostic workup or treatment |
        | **Health examination results** | National health exam data | Objective measurement (lab values, vitals) |
        | **Visit frequency** | Claim count within time window | Distinguishes incident from rule-out |
        
        ---
        
        ## Validated Algorithm Patterns
        
        ### Pattern 1: N-Claim Rule (Most Common)
        
        Require N or more claims with the target ICD-10 code within a defined time
        window to establish a diagnosis.
        
        ```
        Disease = (ICD-10 code X appears >= N times within T months)
        ```
        
        | Stringency | Claims | Window | PPV | Use case |
        |------------|--------|--------|-----|----------|
        | Lenient | >= 1 claim | Any | Lower | Screening, sensitivity-focused |
        | Standard | >= 2 claims | 12 months | Moderate | Most cohort studies |
        | Strict | >= 3 claims | 12 months | Higher | Sensitivity analysis |
        
        **Reporting template**:
        "[DISEASE] was identified by at least [N] claims with ICD-10 codes [CODE
        RANGE] within [T] months during the observation period."
        
        ### Pattern 2: Claim + Medication (Recommended for Chronic Diseases)
        
        Combine diagnostic codes with disease-specific medication prescriptions.
        
        ```
        Disease = (ICD-10 code X >= 1 time) AND (related medication dispensed >= 1 time)
        ```
        
        **Reporting template**:
        "[DISEASE] was defined as at least one claim with ICD-10 codes [CODE RANGE]
        combined with a prescription for [MEDICATION CLASS] (ATC codes [RANGE])."
        
        **Common medication validation pairs**:
        
        | Disease category | ICD-10 pattern | Medication confirmation |
        |-----------------|----------------|----------------------|
        | Hypertension | I10-I15 | Antihypertensive agents (ATC C02-C09) |
        | Diabetes mellitus | E10-E14 | Antidiabetic agents (ATC A10) |
        | Hyperlipidemia | E78 | Lipid-modifying agents (ATC C10) |
        | Coronary artery disease | I20-I25 | Antiplatelet + statin combination |
        | Depressive disorders | F32-F33 | Antidepressants (ATC N06A) |
        | Asthma | J45 | Inhaled corticosteroids / bronchodilators |
        
        ### Pattern 3: Look-Back Period for Incident Cases
        
        To identify **incident** (new-onset) cases, require a disease-free washout
        period before the index date.
        
        ```
        Incident case = (ICD-10 code X after index date)
                      AND (NO claims with code X during look-back period)
        ```
        
        | Look-back | Strength | Trade-off |
        |-----------|----------|-----------|
        | 1 year | Minimum acceptable | May misclassify prevalent cases as incident |
        | 3 years | Recommended | Better incident case identification |
        | 5+ years | Strongest | Reduces sample size due to data availability |
        
        **Reporting template**:
        "To ensure the identification of incident cases, we applied a minimum [N]-year
        look-back period and excluded individuals with any diagnostic claims for
        [DISEASE] during this period."
        
        ### Pattern 4: Outcome Validation with Time Windows
        
        For complications or sequelae, require temporal proximity to the index event.
        
        ```
        Complication = (ICD-10 code Y within T1-T2 days after event X)
        ```
        
        **Examples of time-window patterns**:
        
        | Complication type | Window | Rationale |
        |------------------|--------|-----------|
        | Post-procedural complication | 0-30 days | Immediate perioperative period |
        | Delayed complication | 30-90 days | Sub-acute period |
        | Chronic sequela | 90 days - 1 year | Excludes acute phase, captures chronicity |
        | Hospital admission for condition | Within 30 days of onset | Links admission to the triggering event |
        
        ### Pattern 5: Hierarchical Disease Classification
        
        When exposure or outcome categories overlap, use a hierarchical scheme.
        
        ```
        Level 1 (broadest): Any code in ICD-10 block (e.g., E10-E14 for all diabetes)
        Level 2: Specific subcategory (e.g., E11 for type 2 diabetes only)
        Level 3 (strictest): Subcategory + medication + lab confirmation
        ```
        
        Sensitivity analyses should test multiple levels to assess robustness.
        
        ---
        
        ## ICD-10-KM vs ICD-10 Differences
        
        Korean NHIS uses ICD-10-KM (Korean Modification), which is based on ICD-10
        but includes Korea-specific extensions:
        
        | Feature | ICD-10 (WHO) | ICD-10-KM |
        |---------|-------------|-----------|
        | Base structure | Same alphanumeric codes | Same base + Korean extensions |
        | Additional codes | -- | U-codes for Korean-specific conditions |
        | Version alignment | WHO updates | HIRA publishes annual Korean versions |
        | Medication mapping | ATC codes | ATC codes (same international standard) |
        
        For most major disease categories (cardiovascular, metabolic, respiratory,
        neurological), the ICD-10-KM codes are identical to international ICD-10.
        Country-specific extensions primarily affect rare or culture-bound conditions.
        
        ---
        
        ## Charlson Comorbidity Index in NHIS
        
        The CCI is computed from claims using the Quan adaptation for ICD-10:
        
        | CCI Category | Standard grouping |
        |-------------|-------------------|
        | 0 | No comorbidities |
        | 1 | Single comorbidity point |
        | >= 2 | Multiple comorbidities |
        
        Always specify which CCI adaptation was used (Quan 2005 is standard for
        ICD-10 claims data).
        
        ---
        
        ## Covariate Definitions from NHIS Data Sources
        
        NHIS integrates three data sources, each providing different covariate types:
        
        ### Source 1: Claims Database
        - Comorbidities (via ICD-10 codes)
        - Medication history (via dispensing records)
        - Healthcare utilization (visit counts, hospitalization days)
        
        ### Source 2: National Health Examination
        - BMI, blood pressure, fasting glucose, GFR
        - Liver enzymes (AST, ALT, gamma-GTP)
        - Hemoglobin, total cholesterol
        - Urinalysis
        
        ### Source 3: Health Interview / Questionnaire
        - Smoking status (never / former / current)
        - Alcohol consumption (frequency categories)
        - Physical activity (MET-based sufficiency)
        - Region of residence, household income level
        
        **Baseline alignment**: Covariates must be anchored to the most recent data
        available **prior to** the index date. Do not use post-index measurements.
        
        ---
        
        ## Sensitivity Analysis Patterns
        
        ### Stricter Disease Definition
        Run the primary analysis with the standard algorithm, then repeat with a
        more stringent definition (e.g., 2-claim → 3-claim, or adding medication
        confirmation).
        
        ### Negative Control Outcomes
        Select outcomes with no plausible biological link to the exposure (e.g.,
        neuromuscular junction disorders, self-harm) to detect residual confounding
        or coding artifacts.
        
        ### Negative Control Exposures
        Use an exposure with no expected effect on the outcome as a falsification test.
        
        ### Multiple Look-Back Periods
        Test 1-year, 3-year, and 5-year look-back periods and compare incidence rates
        to assess the impact of prevalent case misclassification.
        
        ---
        
        ## Reporting Checklist
        
        When reporting NHIS claims-based definitions in a manuscript:
        
        - [ ] ICD-10 code ranges specified (main text or supplementary table)
        - [ ] Number of claims required (1 vs 2+ vs with medication)
        - [ ] Time window for claims (within 12 months, any time, etc.)
        - [ ] Look-back period for incident case definition
        - [ ] Medication confirmation specified with ATC codes (if used)
        - [ ] Data source specified (claims vs exam vs questionnaire)
        - [ ] CCI adaptation cited (Quan 2005 or other)
        - [ ] Sensitivity analysis with alternative definition included
        
        ---
        
        ## Common Reviewer Flags
        
        1. Single-claim definition without justification (low PPV concern)
        2. No look-back period for "incident" cases
        3. Mixing ICD-10 and ICD-9 codes without noting transition dates
        4. Not specifying whether inpatient, outpatient, or both claims were used
        5. CCI version not cited
        6. Covariate timing not anchored to index date
        7. Missing sensitivity analysis with stricter/looser definitions
        8. Rule-out diagnoses not addressed (diagnostic codes without treatment)
        
        ---
        
        ## References (Methodological)
        
        - Quan H et al. Coding algorithms for defining comorbidities in ICD-9-CM
          and ICD-10 administrative data. Med Care. 2005;43(11):1130-9.
        - Choi JY et al. Validation of administrative data for the identification
          of chronic diseases in South Korea. J Korean Med Sci. (methodology reference)
        - NHIS data user guide: nhiss.nhis.or.kr (Korean National Health Insurance
          Sharing Service)
        
      • polygenic_risk_score.md 4.3 KB
        # Polygenic Risk Score (PRS / PGS) Analysis Guide
        
        Building and validating a genome-wide weighted allele sum as a predictor or risk-stratifier. The
        score is easy to compute; what fails review is **ancestry transferability, base/target leakage,
        incremental value over established clinical risk, calibration, and the discrimination-vs-utility
        gap**. This is the analysis-side companion to review probes **PG1–PG8** in
        `polygenic_risk_score.md`.
        
        ---
        
        ## When to Use
        
        - Developing, validating, or applying a PRS/PGS as a predictor or risk-stratifier.
        - NOT for: the instrumental-variable use of genetics (causal inference) — see
          `mendelian_randomization.md`; a generic non-genetic prediction model — see `regression.md` /
          the clinical-prediction-model probes.
        
        ## Construction & data hygiene
        
        - **Base (discovery GWAS)** and **target (validation)** samples must be **independent** — overlap
          inflates performance. Reuse a **PGS Catalog** score (cite the PGS ID) or document the weights,
          variant set, genome build, allele/strand alignment, and imputation/missing-genotype handling.
        - **Tuning** (P+T threshold, LDpred/PRS-CS shrinkage or proportion-of-causal-variants, the chosen
          quantile cut) is selected on a **separate tuning set** and the final score evaluated **out-of-
          sample** — never tune and report performance in the same data (overfitting / winner's curse).
        - Tools: `PRSice-2`, `LDpred2` (`bigsnpr`), `PRS-CS`/`PRS-CSx`, `SBayesR`; for cross-ancestry,
          `PRS-CSx` / `BridgePRS`.
        
        ## Ancestry portability (the central issue)
        
        - PRS transfers poorly across ancestries (LD, allele-frequency, and GxE differences). **Report
          performance separately per target ancestry**; differences *within* a broad ancestry group can be
          as large as across continental groups, so a single per-group number can mislead.
        - Prefer **ancestry-matched or multi-ancestry discovery** GWAS; control population stratification
          with principal components. Do not extend a European-derived score to other ancestries without
          per-ancestry validation (equity harm).
        
        ## Effect-size, discrimination, calibration
        
        - Standardize the PRS; report **OR/HR per SD** (with CI) and **quantile stratification** (decile/
          percentile, reference group stated) with **absolute risk** by stratum for a clinical claim.
        - **Discrimination**: C-statistic / AUC with CI. **Calibration is separate** — calibration plot
          (observed vs expected by stratum), slope/intercept, in the **target population** (and re-checked
          across ancestries/cohorts where baseline incidence differs). A well-discriminating score can be
          badly miscalibrated for absolute risk.
        
        ## Incremental value over established clinical risk (the clinical crux)
        
        - A clinical claim needs the PRS **on top of the guideline clinical model** (SCORE2 / QRISK3 /
          Pooled Cohort Equations / Tyrer-Cuzick / FRAX), not PRS-alone AUC: report **ΔC-statistic (with
          CI)**, **NRI / IDI**, and ideally **net benefit (decision curve)** vs the clinical model alone.
        
        ```r
        # nested models: clinical vs clinical + PRS
        m_clin <- coxph(Surv(time, event) ~ score2, data = d)
        m_both <- coxph(Surv(time, event) ~ score2 + scale(prs), data = d)
        # Delta C-index (Uno's C) with CI; NRI/IDI via survIDINRI / nricens; decision curve via dcurves
        ```
        
        ## Screening / stratification utility ≠ discrimination
        
        - A **population-screening** claim needs the **detection rate at a fixed false-positive rate** (or
          the likelihood ratio for a PRS quantile) and the **number needed to screen / absolute risk
          difference** — AUC and HR-per-SD can look favourable while the detection-rate-at-acceptable-FPR
          is poor.
        
        ## Design caveat
        
        - Prefer **prospective/incident** validation; a cross-sectional **case–control prevalent-disease**
          association overstates predictive utility (PRS predicts incident disease less well than it
          associates with prevalent disease).
        
        ## Reporting (PGS-RS / TRIPOD+AI)
        
        Report against the **PGS Reporting Standards** and TRIPOD+AI: development and validation samples
        with **ancestry composition**, score-construction provenance (PGS Catalog ID), and the full
        performance set (discrimination, **calibration**, **incremental value**, and screening operating
        characteristics where a screening claim is made). Scale the conclusion to the evidence — a
        quantile relative-risk gradient is not, by itself, demonstrated clinical actionability. Review-side
        probes: PG1–PG8 in `polygenic_risk_score.md`.
        
      • propensity_score.md 5.4 KB
        # Propensity Score Analysis Guide
        
        Methods for estimating causal treatment effects in observational studies by balancing
        confounders between treatment groups.
        
        ---
        
        ## When to Use
        
        - Observational study comparing treatment vs control (or two interventions)
        - Multiple confounders to adjust for
        - Goal: estimate causal effect analogous to an RCT
        - NOT for: randomized trials (already balanced), single-arm studies
        
        ---
        
        ## Estimands — Choose Before Analysis
        
        | Estimand | Definition | Target population | Method |
        |----------|-----------|-------------------|--------|
        | **ATE** | Average Treatment Effect | Entire study population | IPTW |
        | **ATT** | Effect on Treated | Treatment group only | PSM, ATT weighting |
        | **ATO** | Effect on Overlap population | PS 0.2-0.8 region | Overlap weighting |
        
        Comparing PSM and IPTW results directly is inappropriate — they estimate different estimands.
        
        ---
        
        ## Step-by-Step Workflow
        
        ### Step 1: PS Estimation
        - Model: logistic regression (standard)
        - Dependent variable: treatment assignment (binary)
        - Covariates: all variables that affect the outcome (confounders)
        - Do NOT include: instrumental variables (affect treatment but not outcome)
        - Individual PS model coefficients have no clinical meaning — only PS distribution matters
        
        ### Step 2: Apply PS Method
        
        **Option A — PS Matching (PSM)**
        - Nearest-neighbor matching with caliper = 0.2 x SD(logit PS)
        - 1:1 matching is standard; 1:N or full matching available
        - Estimand: ATT (typically)
        - Drawback: unmatched subjects are excluded → sample size reduction
        
        **Option B — IPTW (Inverse Probability of Treatment Weighting)**
        - Weights: treated = 1/PS, control = 1/(1-PS) for ATE
        - **Always use stabilized weights** to prevent extreme values
        - Stabilized: treated = P(T=1)/PS, control = P(T=0)/(1-PS)
        - All subjects included (no exclusion)
        - Flag extreme weights > 10
        
        **Option C — SIPTW (Stabilized Inverse Probability of Treatment Weighting)**
        - Weights: treated = P(T=1)/PS, control = P(T=0)/(1-PS)
        - Mathematically equivalent to stabilized IPTW (Option B with stabilized=True)
        - Named explicitly as SIPTW in emulated target trial literature
        - Maintains entire cohort sample size (no exclusion)
        - Allows appropriate variance estimation of main effect
        - Estimand: ATE
        - Increasingly used in large-scale NHIS cohort studies
        - Report effective sample size (ESS) alongside raw N
        
        **Option D — Overlap Weighting (Recommended for most cases)**
        - Weights: treated = (1-PS), control = PS
        - Naturally down-weights subjects at PS extremes
        - No extreme weight problem (advantage over IPTW)
        - Estimand: ATO
        - Increasingly recommended in recent guidelines (JAMA 2020, AJE 2024)
        
        ### Step 3: Assess Balance
        - **SMD < 0.10** for all covariates (Austin, 2009)
        - SMD < 0.25 is acceptable but suboptimal
        - Use SMD, NOT p-values (SMD is sample-size independent)
        - **Love plot**: pre/post-matching SMD comparison (mandatory figure)
        - Variance ratios: should be within 0.5-2.0
        - PS distribution overlap: histogram comparing treated vs control
        
        ### Step 4: Outcome Analysis
        - **After PSM**: paired analysis (paired t-test, conditional logistic, stratified Cox)
        - **After IPTW/OW**: weighted regression using survey methods
        - Always use robust/sandwich standard errors for weighted analyses
        
        ### Step 5: Sensitivity Analysis
        - **E-value**: quantifies how strong unmeasured confounding would need to be to explain away the result
        - Report E-value for the point estimate and lower CI bound
        
        ---
        
        ## Balance Table (Required Output)
        
        | Variable | Before matching | | After matching | |
        |----------|------|------|------|------|
        | | Treated | Control | SMD | Treated | Control | SMD |
        | Age, mean (SD) | 65.2 (12.1) | 58.7 (14.3) | 0.49 | 62.1 (11.8) | 61.8 (12.0) | 0.03 |
        
        ---
        
        ## Reporting Templates
        
        **PSM**: "Propensity scores were estimated using logistic regression with the following covariates: [list]. PS matching was performed using 1:1 nearest-neighbor matching with a caliper of 0.2 SD of the logit PS. After matching, all SMDs were below 0.10 (Figure X). In the matched cohort (n = X pairs), ..."
        
        **IPTW/OW**: "Inverse probability of treatment weighting (or overlap weighting) was applied using stabilized weights. Covariate balance was assessed using SMDs (all < 0.10; Figure X). The weighted analysis showed ..."
        
        **SIPTW**: "Stabilized inverse probability of treatment weighting was used to balance covariate distributions between the [exposed] and [unexposed] groups. This approach maintains the sample size of the entire cohort and allows for appropriate estimation of the variance of the main effect. Covariate balance was assessed using SMDs (all < 0.10; Figure X)."
        
        ---
        
        ## Common Reviewer Flags
        
        1. SMD not reported (using p-values instead)
        2. Caliper width not specified
        3. Estimand (ATE/ATT/ATO) not stated
        4. No sensitivity analysis for unmeasured confounding
        5. Number of unmatched subjects not reported (for PSM)
        6. Extreme weights not assessed (for IPTW)
        7. Individual PS model coefficients interpreted clinically
        
        ---
        
        ## Python Packages
        - `sklearn.linear_model.LogisticRegression` — PS estimation
        - `causalinference` — matching (limited)
        - Manual implementation for IPTW/OW (see template)
        - `statsmodels` — weighted regression
        
        ## R Packages
        - `MatchIt` — PS matching
        - `WeightIt` — IPTW, overlap weighting
        - `cobalt` — balance assessment, Love plot
        - `survey` — weighted outcome analysis
        - `tableone` — baseline table with SMD
        - `EValue` — sensitivity analysis
        
      • regression.md 4.8 KB
        # Regression Analysis Guide
        
        Covers both logistic regression (binary outcome) and multiple linear regression (continuous outcome).
        
        ---
        
        ## Logistic Regression
        
        ### When to Use
        Binary outcome variable (0/1, event/no event) with one or more predictors.
        Used for risk factor identification and prediction model building.
        
        ### Assumptions
        1. Binary outcome
        2. Linear relationship between continuous predictors and log-odds (Box-Tidwell test)
        3. Independent observations (if repeated → GEE or mixed logistic)
        4. No multicollinearity: VIF < 5
        5. Sufficient sample: EPV >= 10 (minimum), >= 20 (recommended)
        
        ### Variable Selection
        - **Clinical rationale first** — avoid purely data-driven stepwise selection
        - Include: variables significant at P < 0.10 in univariable analysis + known confounders
        - STROBE/TRIPOD guideline compliance required
        
        ### Model Assessment
        
        **Calibration:**
        - Hosmer-Lemeshow test: P > 0.05 = adequate fit (overpowered with large N)
        - Calibration plot: predicted probability vs observed frequency
        
        **Discrimination:**
        - C-statistic (= AUC): 0.7-0.8 acceptable, 0.8-0.9 excellent, > 0.9 outstanding
        
        **Multicollinearity:**
        - VIF > 5 → remove or combine variables
        
        ### Required Outputs
        1. OR table: univariable AND multivariable OR (95% CI), P-value per variable
        2. C-statistic with 95% CI
        3. Hosmer-Lemeshow test result
        4. VIF table (supplementary)
        5. Box-Tidwell results for continuous predictors (supplementary)
        
        ### OR Table Format
        | Variable | Univariable OR (95% CI) | P | Multivariable OR (95% CI) | P |
        |----------|------------------------|---|--------------------------|---|
        | Age (per 10 yr) | 1.45 (1.12-1.88) | 0.005 | 1.32 (1.01-1.73) | 0.042 |
        
        ### Reporting Template
        "Multivariable logistic regression was performed to identify independent predictors of [outcome]. Variables with P < 0.10 in univariable analysis and clinically relevant confounders were included. The model showed good discrimination (C-statistic = 0.82, 95% CI 0.78-0.86) and calibration (Hosmer-Lemeshow P = 0.45). [Variable] was independently associated with [outcome] (adjusted OR = 2.15, 95% CI 1.43-3.24; P < 0.001)."
        
        ### Pitfalls
        - OR != RR: when event rate > 10%, OR overestimates RR
        - EPV < 10 → overfitting risk. Consider penalized regression (LASSO/Ridge)
        - Specify reference category for categorical variables
        - Specify units for continuous variables (per 1 year vs per 10 years)
        - Complete separation → use Firth's penalized likelihood
        
        ---
        
        ## Multiple Linear Regression
        
        ### When to Use
        Continuous outcome variable with one or more predictors.
        Used for identifying determinants and estimating adjusted effects.
        
        ### Assumptions (LINE + No Multicollinearity)
        1. **L**inearity: residuals vs fitted plot
        2. **I**ndependence: no repeated measures (if repeated → LMM/GEE)
        3. **N**ormality of residuals: Q-Q plot, Shapiro-Wilk on residuals
        4. **E**qual variance (homoscedasticity): residuals vs fitted, Scale-Location plot
        5. No multicollinearity: VIF < 5
        6. No influential outliers: Cook's distance < 4/n
        
        ### Assumption Violations → Alternatives
        | Violation | Fix |
        |-----------|-----|
        | Non-linearity | Log transform, polynomial terms, GAM |
        | Heteroscedasticity | Robust SE, WLS |
        | Non-normal residuals | Transform outcome, bootstrap CI |
        | Multicollinearity | Remove variable, combine, Ridge/LASSO |
        
        ### Model Evaluation
        - R² (coefficient of determination): proportion of variance explained
        - Adjusted R²: penalized for number of predictors — use for model comparison
        - In medical research, R² = 0.2-0.4 can be meaningful (high biological variability)
        
        ### Diagnostic Plots (4-panel, always generate)
        1. Residuals vs Fitted → linearity + homoscedasticity
        2. Q-Q plot → residual normality
        3. Scale-Location → homoscedasticity
        4. Residuals vs Leverage → influential outliers (Cook's distance)
        
        ### Required Outputs
        1. Coefficient table: β (95% CI), P-value per variable
        2. R² and adjusted R²
        3. VIF table
        4. 4-panel diagnostic plot
        5. Standardized coefficients (optional, for effect size comparison)
        
        ### Coefficient Table Format
        | Variable | β (95% CI) | P |
        |----------|-----------|---|
        | Age (per year) | 0.45 (0.32 to 0.58) | < 0.001 |
        | Model R² | 0.35 | |
        | Adjusted R² | 0.33 | |
        
        ### Reporting Template
        "Multiple linear regression was performed with [outcome] as the dependent variable. The model explained X% of the variance (adjusted R² = 0.XX). After adjusting for [covariates], [variable] was significantly associated with [outcome] (β = X.XX, 95% CI X.XX to X.XX; P = exact). Model assumptions were verified using diagnostic plots."
        
        ### Pitfalls
        - Always report β units (per 1 year, per 10 kg/m², etc.)
        - Standardized β useful for comparing effect sizes but unstandardized is standard in papers
        - EPV for continuous outcome: N >= 10-20 per predictor
        - Always present diagnostic plots (at minimum in supplementary)
        
      • repeated_measures.md 5.4 KB
        # Repeated Measures / Mixed Models Guide
        
        Analysis methods for longitudinal data where the same subjects are measured at multiple time points.
        
        ---
        
        ## When to Use
        
        - Same subjects measured at 2+ time points
        - Key research question: does change over time differ between groups? (Time x Group interaction)
        - Examples: treatment response over weeks, serial imaging measurements, before/after/follow-up
        
        ---
        
        ## Method Selection
        
        | Condition | Recommended method |
        |-----------|-------------------|
        | No missing data + few time points + sphericity met | RM ANOVA |
        | Missing data (MAR) + continuous outcome | **LMM (preferred)** |
        | Missing data + binary/count outcome | GEE (or GLMM) |
        | Individual trajectory estimation needed | LMM (random slope) |
        | Population-averaged effect only | GEE |
        | Non-normal outcome | GLMM or GEE |
        
        **Default recommendation: LMM** — handles missing data, does not require sphericity, allows unequal time spacing.
        
        ---
        
        ## Data Format
        
        All methods (LMM, GEE) require **long format**:
        
        | id | time | group | outcome |
        |----|------|-------|---------|
        | 1 | 0 | A | 45.2 |
        | 1 | 1 | A | 42.8 |
        | 1 | 2 | A | 38.1 |
        
        Convert from wide format before analysis.
        
        ---
        
        ## 1. RM ANOVA
        
        ### Sphericity Check (Mandatory)
        - **Mauchly's test**: H0 = sphericity holds
          - P >= 0.05 → use standard F-test
          - P < 0.05 → apply correction
        
        ### Corrections for Sphericity Violation
        1. **Greenhouse-Geisser (G-G)**: conservative, recommended
        2. **Huynh-Feldt**: slightly liberal
        3. **Multivariate test** (Pillai's trace): no sphericity assumption needed
        
        ### Limitations
        - Complete cases only — any missing time point drops the entire subject
        - Difficult to add covariates
        - Equal time spacing required
        
        ### Key Results to Report
        - Mauchly's test result (W statistic, P-value, epsilon)
        - Correction method used (if sphericity violated)
        - Within-subject effect: Time (F, df, P)
        - Between-subject effect: Group (F, df, P)
        - **Interaction: Time x Group** (most important)
        
        ---
        
        ## 2. Linear Mixed Model (LMM)
        
        ### Structure
        ```
        Y = Xβ (fixed effects) + Zb (random effects) + ε
        ```
        
        ### Random Effects Selection
        - **Random intercept only**: subjects differ in baseline (default, start here)
        - **Random intercept + slope**: subjects differ in both baseline and rate of change
        - Decision: visualize individual trajectories (spaghetti plot). If slopes vary → add random slope.
        - If model does not converge with random slope → simplify to random intercept only
        
        ### Covariance Structure Selection
        | Structure | Property | When to use |
        |-----------|----------|-------------|
        | **CS** (Compound Symmetry) | Equal correlation between all time pairs | Equivalent to RM ANOVA |
        | **AR(1)** | Correlation decays with time lag | Equally spaced measurements |
        | **UN** (Unstructured) | Free correlation for each pair | Few time points only (many parameters) |
        
        Compare structures using **AIC/BIC** (lower = better).
        
        ### Advantages over RM ANOVA
        - Handles missing data under MAR assumption
        - No sphericity requirement
        - Allows unequal time intervals
        - Easy covariate adjustment
        - Estimates individual trajectories
        
        ---
        
        ## 3. GEE (Generalized Estimating Equations)
        
        ### Key Properties
        - Estimates **population-averaged (marginal) effects** — not individual-level
        - Specify working correlation: exchangeable, AR(1), unstructured
        - Works for non-normal outcomes (binary, count)
        - Requires **MCAR** assumption for missing data (or use weighted GEE for MAR)
        - Needs sufficient clusters: subjects >= 30-40
        
        ### LMM vs GEE Decision
        | Question | LMM | GEE |
        |----------|-----|-----|
        | "How does each patient change?" | Yes | No |
        | "How does the group average change?" | Yes | Yes |
        | Binary/count outcome without GLMM | No | Yes |
        | MAR missing data tolerance | Yes | No (MCAR only) |
        
        ---
        
        ## Required Outputs
        
        1. **Spaghetti plot**: individual trajectories by group
        2. **Model summary table**: fixed effects (β, 95% CI, P), random effects variance
        3. **Time x Group interaction** result prominently reported
        4. Model fit: AIC/BIC for covariance structure comparison
        5. Missing data description (n per time point, mechanism assumed)
        
        ---
        
        ## Reporting Templates
        
        **RM ANOVA**: "Repeated-measures ANOVA was performed with Greenhouse-Geisser correction for violation of sphericity (Mauchly's test P < 0.001, ε = 0.42). There was a significant time × group interaction (F(X, Y) = Z, P = exact)."
        
        **LMM**: "A linear mixed-effects model with random intercepts for subjects and [CS/AR(1)] correlation structure was fitted. The time × group interaction was significant (β = -2.34, 95% CI -3.87 to -0.81; P = 0.003), indicating that the rate of change in [outcome] differed between groups."
        
        **GEE**: "GEE with exchangeable correlation structure was used to estimate population-averaged effects. The time × group interaction was ..."
        
        ---
        
        ## Common Reviewer Flags
        
        1. Sphericity test not reported (for RM ANOVA)
        2. Covariance structure selection rationale not stated (for LMM)
        3. Time × Group interaction not interpreted
        4. Missing data handling not described
        5. Using RM ANOVA with substantial missing data (should use LMM)
        6. Not reporting individual trajectories (spaghetti plot)
        
        ---
        
        ## Python Packages
        - `pingouin` — RM ANOVA with G-G correction
        - `statsmodels.formula.api.mixedlm` — LMM
        - `statsmodels.genmod.generalized_estimating_equations.GEE` — GEE
        
        ## R Packages
        - `lme4` + `lmerTest` — LMM (standard)
        - `nlme` — LMM with correlation structures (CS, AR1, UN)
        - `geepack` — GEE
        - Base R `aov()` with `Error()` — RM ANOVA
        
      • survey_weighted.md 12.5 KB
        # Survey-Weighted Analysis Guide
        
        Methods for analyzing complex survey data (KNHANES, NHANES, KCHS, and similar
        nationally representative health surveys) that use stratified, multistage
        probability sampling designs.
        
        ---
        
        ## When to Use
        
        - Data from a national health survey with sampling weights (e.g., KNHANES, NHANES, KCHS)
        - Goal: produce nationally representative prevalence, means, or associations
        - Cross-national comparisons using parallel survey datasets
        - NOT for: simple random samples, census data, or claims-based cohorts (NHIS, JMDC)
        
        Claims-based databases (NHIS, JMDC) are NOT surveys -- they do not have sampling
        weights or complex sampling design. Use standard regression for these.
        
        ---
        
        ## Key Concepts
        
        ### Complex Survey Design Elements
        
        | Element | Description | Survey variable |
        |---------|-------------|-----------------|
        | **Stratification** | Groups the population into non-overlapping strata before sampling | `strata` |
        | **Clustering (PSU)** | Primary sampling units within strata (e.g., districts, census blocks) | `cluster` / `PSU` |
        | **Sampling weights** | Inverse probability of selection, adjusted for non-response and post-stratification | `weight` |
        
        Ignoring these elements produces biased standard errors, incorrect p-values, and
        non-representative point estimates.
        
        ### Dataset-Specific Design Variables
        
        | Dataset | Strata variable | Cluster/PSU variable | Weight variable | Notes |
        |---------|----------------|---------------------|-----------------|-------|
        | **KNHANES** | `kstrata` | `psu` | `wt_itvex` (interview+exam) or `wt_ntr` (nutrition) | Years may be non-consecutive (PHQ-9 only in certain cycles) |
        | **NHANES** | `SDMVSTRA` | `SDMVPSU` | `WTMECXYR` (exam) or `WTINTXYR` (interview) | 2-year cycles; combine cycles with adjusted weights |
        | **KCHS** | varies by year | varies by year | `wt` | Annual community survey; single-stage cluster design |
        
        ### Weight Selection Rules
        
        - **Interview-only variables**: use interview weight
        - **Exam/lab variables**: use exam weight (smaller denominator)
        - **Nutrition variables (KNHANES)**: use nutrition weight
        - **Multi-cycle NHANES**: divide weight by number of cycles combined (e.g., 4 cycles: weight/4)
        - **Single-cycle analysis**: use the cycle-specific weight as-is
        
        ### Subpopulation (domain) analysis — never row-delete
        
        A restricted analysis (adults only, one sex, a disease subgroup) must keep the **full design** and select the domain, **not** filter the data frame and refit. Row-deletion discards the strata/PSU structure of the dropped units and gives wrong standard errors and design degrees of freedom.
        
        - R `survey`: `subset(design, age >= 18)` on the **design object** (or `svyby`), not `svydesign(data = df[df$age>=18, ])`.
        - Stata: `svy, subpop(if age>=18):` — never `keep if age>=18` before `svy:`.
        - Python `samplics` / R is preferred; statsmodels has no native domain estimator.
        
        ### Reporting & common errors (these invalidate the inference, flag at review)
        
        - **Model-based SEs on weighted points.** Applying the weight but computing SEs without strata + PSU (or replicate weights) understates uncertainty. Always declare `strata` + `id`/`cluster`.
        - **Weighted total ≠ sample size.** Report the **unweighted n** as the analytic sample; the weighted figure is a *population* estimate, not "n".
        - **Design effect / effective n.** Report DEFF or the effective sample size where precision is load-bearing; a large DEFF means far fewer independent observations than rows.
        - **Unweighted vs weighted divergence.** If they differ materially, that signals weight-dependent selection — discuss it, do not hide it.
        - **Data-driven thresholds.** A restricted-cubic-spline "non-linear/saturation" claim needs a pre-specified non-linearity test (LRT/Wald vs the linear model), and any "inflection point / threshold" from a recursive breakpoint search must carry a **confidence interval** and a stability check — a searched cutoff is exploratory, not a validated target (review-side probe O12 in `observational_confounding.md`).
        
        ---
        
        ## Step-by-Step Workflow
        
        ### Step 1: Declare Survey Design
        
        Always declare the design before any analysis. This ensures correct variance estimation.
        
        **Python (statsmodels)**:
        ```python
        # statsmodels does not have a native survey design object.
        # Use linearmodels or manual weight application.
        # For publication-quality survey analysis, R is strongly recommended.
        ```
        
        **R (survey package)**:
        ```r
        library(survey)
        
        # KNHANES
        design_kr <- svydesign(
          id = ~psu,
          strata = ~kstrata,
          weights = ~wt_itvex,
          data = df_kr,
          nest = TRUE
        )
        
        # NHANES (2-year cycle)
        design_us <- svydesign(
          id = ~SDMVPSU,
          strata = ~SDMVSTRA,
          weights = ~WTMECXYR,
          data = df_us,
          nest = TRUE
        )
        ```
        
        **SAS**:
        ```sas
        /* KNHANES */
        PROC SURVEYLOGISTIC DATA=kr;
          STRATA kstrata;
          CLUSTER psu;
          WEIGHT wt_itvex;
          MODEL outcome(event='1') = exposure covariates;
        RUN;
        
        /* NHANES */
        PROC SURVEYLOGISTIC DATA=us;
          STRATA SDMVSTRA;
          CLUSTER SDMVPSU;
          WEIGHT WTMECXYR;
          MODEL outcome(event='1') = exposure covariates;
        RUN;
        ```
        
        ### Step 2: Weighted Descriptive Statistics
        
        **R**:
        ```r
        # Weighted means
        svymean(~continuous_var, design, na.rm = TRUE)
        
        # Weighted proportions
        svymean(~factor(categorical_var), design, na.rm = TRUE)
        
        # Weighted Table 1 by group
        library(tableone)
        svyCreateTableOne(
          vars = c("age", "sex", "bmi", "income"),
          strata = "exposure_group",
          data = design,
          test = TRUE
        )
        ```
        
        **SAS**:
        ```sas
        PROC SURVEYMEANS DATA=dataset;
          STRATA strata_var;
          CLUSTER cluster_var;
          WEIGHT weight_var;
          VAR continuous_var1 continuous_var2;
        RUN;
        
        PROC SURVEYFREQ DATA=dataset;
          STRATA strata_var;
          CLUSTER cluster_var;
          WEIGHT weight_var;
          TABLES group * categorical_var / CHISQ;
        RUN;
        ```
        
        ### Step 3: Weighted Regression — Sequential Model Building
        
        The standard cross-national analysis pattern uses sequential model building:
        
        | Model | Covariates | Purpose |
        |-------|-----------|---------|
        | Model 1 | Age, sex | Minimal adjustment |
        | Model 2 | Model 1 + income, education, smoking, alcohol, BMI, comorbidities | Full adjustment |
        
        **R (survey-weighted logistic regression)**:
        ```r
        # Model 1: age + sex
        model1 <- svyglm(
          outcome ~ exposure + age + sex,
          design = design,
          family = quasibinomial()
        )
        
        # Model 2: full adjustment
        model2 <- svyglm(
          outcome ~ exposure + age + sex + income + education +
                    smoking + alcohol + bmi + cvd_history,
          design = design,
          family = quasibinomial()
        )
        
        # Extract weighted OR (wOR) with 95% CI
        extract_wor <- function(model, var) {
          coef_val <- coef(model)[var]
          se_val <- summary(model)$coefficients[var, "Std. Error"]
          or <- exp(coef_val)
          ci_lo <- exp(coef_val - 1.96 * se_val)
          ci_hi <- exp(coef_val + 1.96 * se_val)
          p_val <- summary(model)$coefficients[var, "Pr(>|t|)"]
          data.frame(wOR = or, CI_lower = ci_lo, CI_upper = ci_hi, P = p_val)
        }
        ```
        
        **SAS (PROC SURVEYLOGISTIC)**:
        ```sas
        /* Model 2: full adjustment */
        PROC SURVEYLOGISTIC DATA=dataset;
          STRATA strata_var;
          CLUSTER cluster_var;
          WEIGHT weight_var;
          CLASS sex(ref='Male') income(ref='High') smoking(ref='Never') / PARAM=REF;
          MODEL outcome(event='1') = exposure age sex income education smoking alcohol bmi;
          ODDSRATIO exposure / CL=WALD;
        RUN;
        ```
        
        ### Step 4: Subgroup / Stratified Analyses
        
        ```r
        # Stratified by sex
        svyglm(
          outcome ~ exposure + age + income + education + smoking + alcohol + bmi,
          design = subset(design, sex == "Male"),
          family = quasibinomial()
        )
        # Repeat for Female
        # Note: exclude the stratification variable from covariates
        ```
        
        Reporting pattern: "Weighted odds ratios are adjusted for all covariates except
        for the stratification variable."
        
        ### Step 5: Advanced Analyses (Optional)
        
        **Restricted cubic spline (dose-response)**:
        ```r
        library(rms)
        design_rms <- svydesign(id = ~psu, strata = ~kstrata,
                                 weights = ~wt_itvex, data = df)
        model_rcs <- svyglm(
          outcome ~ rcs(continuous_exposure, 3) + age + sex + covariates,
          design = design_rms,
          family = quasibinomial()
        )
        ```
        
        **Weighted quantile sum (WQS) regression**:
        ```r
        library(gWQS)
        # WQS for composite exposure (e.g., LE8 components)
        result_wqs <- gwqs(
          outcome ~ wqs + age + sex + covariates,
          mix_name = c("comp1", "comp2", "comp3", "comp4"),
          data = df,
          q = 4,           # quartiles
          b = 500,         # bootstrap iterations
          seed = 42,
          family = "binomial",
          weights = df$weight_var
        )
        ```
        
        ---
        
        ## Weighted SMD (Standardized Mean Difference)
        
        For balance assessment in weighted analyses:
        
        ```r
        library(survey)
        library(tableone)
        
        # SMD in survey design
        tab <- svyCreateTableOne(
          vars = covariates,
          strata = "treatment",
          data = design,
          smd = TRUE
        )
        print(tab, smd = TRUE)
        ```
        
        **Manual calculation (Python)**:
        ```python
        def weighted_smd(x, treatment, weights, is_binary=False):
            """Calculate weighted standardized mean difference."""
            t_mask = treatment == 1
            c_mask = treatment == 0
            
            w1, w0 = weights[t_mask], weights[c_mask]
            x1, x0 = x[t_mask], x[c_mask]
            
            wm1 = np.average(x1, weights=w1)
            wm0 = np.average(x0, weights=w0)
            
            if is_binary:
                denom = np.sqrt((wm1 * (1 - wm1) + wm0 * (1 - wm0)) / 2)
            else:
                wv1 = np.average((x1 - wm1) ** 2, weights=w1)
                wv0 = np.average((x0 - wm0) ** 2, weights=w0)
                denom = np.sqrt((wv1 + wv0) / 2)
            
            return (wm1 - wm0) / denom if denom > 0 else 0.0
        ```
        
        ---
        
        ## Cross-National Analysis Pattern
        
        When comparing two countries using parallel surveys:
        
        1. **Never pool** raw data across countries into a single regression
        2. Analyze each country **independently** with country-specific survey design
        3. Present results **side-by-side** in the same table
        4. Compare effect magnitudes narratively (not via interaction terms)
        
        ### Standard Output Table Format
        
        | Variable | Korea (KNHANES) | | US (NHANES) | |
        |----------|------|------|------|------|
        | | Model 1 wOR (95% CI) | Model 2 wOR (95% CI) | Model 1 wOR (95% CI) | Model 2 wOR (95% CI) |
        | Exposure | 1.42 (1.21-1.67) | 1.35 (1.14-1.59) | 1.28 (1.10-1.49) | 1.22 (1.04-1.43) |
        
        ---
        
        ## Reporting Templates
        
        **Methods**:
        "To account for the complex survey designs and ensure nationally representative
        estimates, appropriate stratification, clustering, and sampling weights were
        applied. Weighted logistic regression models were used to estimate weighted odds
        ratios (wORs) and 95% confidence intervals (CIs). Model 1 adjusted for age and
        sex, while Model 2 further included [covariates]. All analyses were performed
        using SAS version 9.4 (SAS Institute Inc.) [and R version X.X.X (R Foundation
        for Statistical Computing)]. A two-sided P value of less than 0.05 was
        considered statistically significant."
        
        **Results**:
        "In the weighted analysis of [N] participants from [DATASET], [EXPOSURE] was
        significantly associated with [OUTCOME] (wOR [X.XX]; 95% CI [X.XX-X.XX]) after
        adjusting for [covariates] (Model 2)."
        
        ---
        
        ## Common Reviewer Flags
        
        1. Survey weights not applied (unweighted analysis of survey data)
        2. Strata/cluster variables not specified (incorrect SE estimation)
        3. Wrong weight variable used (interview weight for lab variables)
        4. Multi-cycle NHANES weights not adjusted (divided by number of cycles)
        5. Data pooled across countries instead of analyzed separately
        6. Weighted proportions not reported (using raw counts instead)
        7. Subgroup analysis includes the stratification variable as covariate
        8. Missing data handling not stated (complete case vs imputation)
        
        ---
        
        ## Python vs R Recommendation
        
        | Task | Recommended | Reason |
        |------|-------------|--------|
        | Survey-weighted regression | **R (survey)** | Native support, correct variance estimation |
        | Survey-weighted Table 1 | **R (tableone)** | `svyCreateTableOne()` handles design |
        | WQS regression | **R (gWQS)** | Only available in R |
        | Dose-response (RCS) | **R (rms + survey)** | Integrated with survey design |
        | Quick descriptives | Python (statsmodels) | Adequate for simple weighted means |
        
        For publication-quality survey analysis, **R is strongly recommended** over Python.
        Python's statsmodels supports basic weighted regression but lacks full survey
        design support (no strata/cluster specification for variance estimation).
        
        ---
        
        ## R Packages
        
        - `survey` -- core survey design and analysis
        - `tableone` -- survey-weighted baseline tables with SMD
        - `rms` -- restricted cubic splines with survey design
        - `gWQS` -- weighted quantile sum regression
        - `srvyr` -- tidyverse-compatible survey analysis wrapper
        
        ## SAS Procedures
        
        - `PROC SURVEYMEANS` -- weighted means and proportions
        - `PROC SURVEYFREQ` -- weighted frequency tables and chi-square
        - `PROC SURVEYLOGISTIC` -- weighted logistic regression (wOR)
        - `PROC SURVEYREG` -- weighted linear regression
        - `PROC SURVEYPHREG` -- weighted Cox proportional hazards
        
      • survival.md 7 KB
        # Survival / Time-to-Event Guide
        
        Estimating time-to-event outcomes and prognostic effects. The estimator is easy to
        call; the ways these analyses fail review are (1) ignoring **competing risks** so a naive
        1−KM **overestimates** the cumulative incidence, (2) reporting a **single time-averaged
        hazard ratio** when the proportional-hazards assumption is violated, and (3) **estimand
        drift** — quoting a subdistribution hazard for an etiologic claim or a cause-specific hazard
        for an absolute-risk claim. This guide produces the right estimand; the operational caveats
        (EPV gate, cluster-robust CIs, interval-censoring) live in the SKILL.md `### Survival
        Analysis` section.
        
        ---
        
        ## When to Use
        
        - **Single event type, right-censored** → Kaplan–Meier (unadjusted) + Cox proportional-hazards
          (adjusted HR with 95% CI); the log-rank test for a KM group comparison.
        - **≥2 competing event types** (recurrence + competing death, or cause-specific mortality) →
          cumulative incidence functions + **cause-specific Cox** or **Fine–Gray** — see below.
        - **Prognostic model discrimination** → a C-index variant matched to the censoring + a
          time-dependent AUC at a clinical horizon (S6).
        - NOT for: events detected only at scheduled visits → interval-censored methods (SKILL.md);
          recurrent events per subject → a cluster-robust or frailty model (SKILL.md cluster rule);
          rater agreement → `analysis_guides/agreement_reliability.md`.
        
        ---
        
        ## Competing risks come first (this, not the HR, is the issue)
        
        If a subject can experience an event that **precludes** the event of interest (death before
        recurrence), treating the competing event as ordinary censoring is *informative* censoring:
        the naive **1−KM overestimates** the cumulative incidence of the event of interest. Produce the
        **cumulative incidence function (CIF)** with an Aalen–Johansen / Fine–Gray estimator instead.
        This is the produce-side of probe **S3**.
        
        ```python
        # lifelines: cumulative incidence for a competing-risks event of interest
        from lifelines import AalenJohansenFitter, KaplanMeierFitter
        import pandas as pd
        df = pd.read_csv("survival.csv")   # time, event_type (0=censored, 1=interest, 2=competing)
        
        ajf = AalenJohansenFitter()
        ajf.fit(df["time"], df["event_type"], event_of_interest=1)
        print(ajf.cumulative_density_.tail(1))          # correct CIF for cause 1
        
        kmf = KaplanMeierFitter()                        # NAIVE (cause 2 censored) — overestimates
        kmf.fit(df["time"], (df["event_type"] == 1).astype(int))
        print("naive 1-KM:", float(1 - kmf.survival_function_.iloc[-1, 0]))
        # the naive value will exceed the Aalen-Johansen CIF whenever the competing event is common.
        ```
        
        **Which model for which question (S8 estimand):**
        
        - **Cause-specific hazard** (a Cox model that censors the competing event) answers an
          **etiologic** question — "does X change the rate of recurrence among those still at risk?"
        - **Fine–Gray subdistribution hazard** (`cmprsk::crr` / `survival::finegray` in R) answers a
          **prognostic / absolute-risk** question — "does X change the cumulative *incidence*?" Quote an
          sHR for an incidence claim and a cause-specific HR for an etiologic claim — not the reverse.
        
        ```r
        library(survival)                       # Fine-Gray via a weighted Cox on the finegray split
        fg  <- finegray(Surv(time, factor(event_type)) ~ ., data = d, etype = 1)
        crr <- coxph(Surv(fgstart, fgstop, fgstatus) ~ x, weight = fgwt, data = fg)   # subdistribution HR
        ```
        
        ---
        
        ## Proportional hazards, and RMST when it fails
        
        A single Cox HR is a time-average; if PH is violated it averages a changing effect. Test PH,
        and when it fails report a **restricted mean survival time (RMST) difference** at a fixed
        horizon (an estimand that stays interpretable under non-PH) rather than one HR.
        
        ```python
        from lifelines import CoxPHFitter
        cph = CoxPHFitter().fit(df, "time", "event")
        cph.check_assumptions(df, p_value_threshold=0.05)   # Schoenfeld; significant -> PH violated
        ```
        
        ```r
        library(survRM2)
        rmst2(time, status, arm, tau = 3)   # RMST difference at tau=3 years, valid under non-PH
        ```
        
        Do not report a single time-averaged HR alongside a significant Schoenfeld test without also
        giving a piecewise/time-stratified HR or an RMST difference (SKILL.md PH-violation rule).
        
        ---
        
        ## Follow-up and discrimination (S6)
        
        - **Reverse Kaplan–Meier median follow-up** — the honest "how long were people followed"
          (median event time answers a different question). Compute it by **swapping the event
          indicator** (censored observations become the "events"); report it per cohort and per
          outcome, with the censoring date.
        
        ```python
        kmf.fit(df["time"], 1 - df["event"])            # swap: censored -> event
        print("reverse-KM median follow-up:", kmf.median_survival_time_)
        ```
        
        - **C-index variant** — Harrell's C is biased under heavy or non-random censoring; report
          **Uno's IPCW C** (`survC1::Est.Cval` / `timeROC`) and a **time-dependent AUC at a clinical
          horizon** (2-/3-year) beside it, and state which variant and horizon (S6).
        
        ---
        
        ## Estimand provenance (S8)
        
        State the survival estimand explicitly and hold it consistent across Abstract / Methods /
        Results — event-free survival vs cause-specific cumulative incidence vs all-cause mortality,
        and subject vs population level — with the evaluation horizon fixed in advance. Do not
        re-designate the primary endpoint, model, or horizon after seeing results, and make every
        derived statistic (an E-value, an sHR-vs-cause-specific contrast) trace to the *declared
        primary* estimand. The self-review skill automates the registration ↔ manuscript and E-value
        arithmetic checks (Phase 2.5f); see also `estimand-provenance-lock`.
        
        ---
        
        ## Reporting
        
        - KM curves **with a number-at-risk table**; median survival with 95% CI (or the reason it is
          not reached).
        - Cox HR (95% CI) with the **PH check stated**; for competing risks, the CIF and whether the
          HR is cause-specific or subdistribution.
        - Events and person-time per group; the **reverse-KM median follow-up**; EPV for the model.
        - The estimand and horizon, stated once and consistent everywhere.
        
        ---
        
        ## Common failures (flag at review)
        
        - **Competing risks ignored** — naive 1−KM (or a cause-specific model presented as absolute
          risk) overestimates incidence; report a CIF and name cause-specific vs subdistribution (S3/S8).
        - **A single time-averaged HR under a violated PH assumption** — needs a piecewise HR or RMST.
        - **Harrell's C under heavy censoring** with no Uno/IPCW variant and no horizon (S6).
        - **Median *survival* reported as "follow-up"** instead of the reverse-KM follow-up.
        - **Estimand drift** — a primary endpoint/model/horizon re-designated post-hoc, or a derived
          statistic quoted off a non-primary estimate (S8).
        
        ---
        
        ## Anti-Hallucination
        
        - Never hand-type an HR, median, CIF, or CI — compute it from the time-to-event CSV with a
          seeded script, and carry each estimate together with its CI.
        - Do not report a Cox HR without the proportional-hazards check the code actually ran.
        - Under competing risks, do not quote a 1−KM cumulative incidence — report the Aalen–Johansen /
          Fine–Gray CIF the code produced.
        
      • test_selection.md 3.4 KB
        # Statistical Test Selection Guide
        
        Decision tree for selecting the appropriate statistical test based on data structure.
        Reference: Petrie & Sabin flowchart, Kirkwood Summary Guide.
        
        ---
        
        ## Step 1: Outcome Variable Type
        
        | Outcome type | Next step |
        |---|---|
        | Continuous (measurement, score) | Step 2A |
        | Binary / Categorical | Step 2B |
        | Time-to-event (survival) | → Survival analysis type |
        | Agreement / Reliability | → Inter-rater Agreement type |
        | Diagnostic accuracy (Se/Sp/AUC) | → Diagnostic Accuracy type |
        
        ---
        
        ## Step 2A: Continuous Outcome
        
        | Groups | Pairing | Normal? | Test | analyze-stats type |
        |--------|---------|---------|------|--------------------|
        | 1 | - | Yes | One-sample t-test | Group Comparison |
        | 1 | - | No | Wilcoxon signed-rank (one-sample) | Group Comparison |
        | 2 | Independent | Yes | Independent t-test | Group Comparison |
        | 2 | Independent | No | Mann-Whitney U | Group Comparison |
        | 2 | Paired | Yes | Paired t-test | Group Comparison |
        | 2 | Paired | No | Wilcoxon signed-rank | Group Comparison |
        | 3+ | Independent | Yes | One-way ANOVA + post-hoc | Group Comparison |
        | 3+ | Independent | No | Kruskal-Wallis + post-hoc | Group Comparison |
        | 3+ | Repeated | Yes | RM ANOVA | **Repeated Measures** |
        | 3+ | Repeated | No | Friedman test | **Repeated Measures** |
        | - | Correlation | Yes | Pearson r | Correlation |
        | - | Correlation | No | Spearman rho | Correlation |
        | - | Regression | - | Multiple linear regression | **Linear Regression** |
        
        ---
        
        ## Step 2B: Categorical Outcome
        
        | Groups | Pairing | Condition | Test | analyze-stats type |
        |--------|---------|-----------|------|--------------------|
        | 1 | - | - | Binomial / z-test for proportion | Group Comparison |
        | 2 | Independent | Expected >= 5 | Chi-squared | Group Comparison |
        | 2 | Independent | Expected < 5 | Fisher's exact | Group Comparison |
        | 2 | Paired | - | McNemar's test | Group Comparison |
        | 2+ | Independent | Ordered | Chi-squared trend | Group Comparison |
        | 3+ | Independent | - | Chi-squared | Group Comparison |
        | 3+ | Paired | - | Cochran's Q | Group Comparison |
        | - | Regression | Binary outcome | Logistic regression | **Logistic Regression** |
        
        ---
        
        ## Step 3: Confounder Control
        
        | Situation | Method | analyze-stats type |
        |-----------|--------|--------------------|
        | Continuous outcome + multivariable | Multiple linear regression | **Linear Regression** |
        | Binary outcome + multivariable | Logistic regression | **Logistic Regression** |
        | Survival outcome + multivariable | Cox proportional hazards | Survival |
        | Observational study + treatment comparison | Propensity score | **Propensity Score** |
        | Repeated measures + missing data | LMM / GEE | **Repeated Measures** |
        
        ---
        
        ## Normality Assessment
        
        | Method | When | Criterion |
        |--------|------|-----------|
        | Shapiro-Wilk | n < 50 | p >= 0.05 → normal |
        | Kolmogorov-Smirnov | n >= 50 | p >= 0.05 → normal |
        | Q-Q plot | Always (visual) | Points on diagonal |
        | Skewness/Kurtosis | Supplementary | |skew| < 2, |kurt| < 7 |
        
        Practical rule: n > 30 → t-test is generally robust (CLT), unless extreme skew or outliers.
        
        ---
        
        ## Common Reviewer Flags
        
        - Using independent test on paired data
        - Using chi-squared when expected cell count < 5 (should use Fisher's exact)
        - Not reporting assumption check results
        - Missing multiple comparison correction for 3+ groups
        - Not specifying the test selection rationale in Methods
        
    • style
      • figure_style.mplstyle 1.4 KB · in bundle
      • theme_publication.R 5.9 KB · in bundle
    • table-standards
      • journal-profiles
        • ajr.yaml 1.2 KB
          # AJR — Table Style Profile
          journal: American Journal of Roentgenology
          publisher: ARRS
          style_base: AMA (with AJR-specific overrides)
          
          format:
            allowed: Word table (editable)
            rejected: [Excel, image, links]
            placement: "end of MS Word file, after References"
          
          limits:
            max_tables: 5
            word_limit: 4500
            min_size: "4 rows x 2 columns"
            max_pages_per_table: 2
          
          lines:
            vertical: false
            horizontal: AMA_style
          
          font:
            size: 12pt
            spacing: double
          
          title:
            format: "Table N — Title text"
            all_columns_must_have_headings: true
          
          footnotes:
            marker_system: superscript_lowercase_letters  # AMA style
            abbreviation_list: "ajronline.org/abbreviationlist"
            note: "Abbreviations on AJR list need no definition"
          
          p_value:
            case: lowercase  # AJR override: lowercase italic p
            italic: true
            symbol: "*p*"
            leading_zero: false
            examples: ["*p* = .03", "*p* < .001"]
          
          numbering:
            sub_parts: false  # Table 1A, 1B forbidden
            rule: "each table gets independent number"
          
          gtsummary_theme: null  # no built-in theme, use "jama" as base
          
          notes:
            - "AJR has its own approved abbreviation list"
            - "Lowercase italic p (differs from Radiology's uppercase P)"
            - "No 1A/1B sub-numbering — separate tables"
            - "Minimum 4 rows x 2 columns"
          
        • european_radiology.yaml 1.3 KB
          # European Radiology — Table Style Profile
          journal: European Radiology
          publisher: Springer Nature
          style_base: Springer house style
          
          format:
            allowed: Word table (Table object function), separate file
            rejected: [spreadsheet, image]
          
          limits:
            max_tables: 5
            max_figures: 6
            word_limit: 3000
          
          lines:
            vertical: false
            color_shading: false  # no colors/shading in tables
            emphasis: [superscript, numbers, symbols, bold]
          
          font:
            size: null
          
          title:
            format: "Table N Title text"
            numbering: arabic
            placement: above
            legends_placement: "after References, grouped"
          
          footnotes:
            marker_system: superscript_lowercase_letters
            significance_markers: asterisks  # * for significance (Springer convention)
            placement: below_table_body
          
          p_value:
            case: lowercase
            italic: false
            symbol: "p"
            leading_zero: varies
            significance_asterisks: true  # * allowed for p<.05
          
          ci_format:
            separator: varies
            example: null
          
          data_conventions:
            no_comma_as_decimal: true  # avoid European decimal comma confusion
            oversized_tables: "place at end of document if > 1 A4 page"
          
          abbreviations:
            define_in: [title, first_use, footnote]  # any of these locations
          
          notes:
            - "No color or shading in tables"
            - "Abbreviations can be defined in title, first use, OR footnote"
            - "Comma must not be used as numeric value (decimal confusion)"
          
        • jama.yaml 1.6 KB
          # JAMA — Table Style Profile
          journal: JAMA
          publisher: AMA (American Medical Association)
          style_base: AMA 11th Edition
          
          format:
            allowed: Word table (table function)
            rejected: [Excel, PDF, image]
          
          limits:
            max_display_items: 5  # tables + figures combined
            word_limit: null
          
          lines:
            vertical: false
            horizontal: minimal  # cell gridlines serve as outlines
            note: "Do not draw extra lines/rules"
          
          font:
            size: "10pt or 12pt"
            shrink: false  # never reduce font to fit
          
          spacing: single
          
          title:
            format: "**Table N.** Title text"
            placement: above
            bold_prefix: true
            separator: period
          
          footnotes:
            marker_system: superscript_lowercase_letters
            assignment_order: top_to_bottom_left_to_right
            each_on_separate_line: true
            abbreviation_order: alphabetical  # JAMA prefers alphabetical
            abbreviation_format: "BMI, body mass index; CI, confidence interval"
            abbreviation_separator: "; "
          
          p_value:
            case: uppercase
            italic: true
            symbol: "*P*"
            leading_zero: false
            examples: ["*P* = .04", "*P* = .003", "*P* < .001"]
            decimals_above_01: 2
            decimals_01_to_001: 3
            floor: "*P* < .001"
            asterisk_significance: false  # never use *, **, ***
          
          ci_format:
            separator: "to"
            brackets: null
            example: "1.23 (95% CI, 0.89 to 1.70)"
          
          data_conventions:
            si_units: "Provide conversion factor in footnote"
            vertical_cell_merge: false  # forbidden
            percentages: "n (%) with denominator clear"
            summary_stats: "mean [SD] or median [IQR]"
          
          gtsummary_theme: "jama"
          
          notes:
            - "Original Investigation: max 5 display items"
            - "Empty cells must be explained in footnotes"
            - "Abbreviation: semicolon-separated, alphabetical"
          
        • lancet.yaml 1.3 KB
          # The Lancet — Table Style Profile
          journal: The Lancet
          publisher: Elsevier
          style_base: Lancet house style (Vancouver-influenced)
          
          format:
            allowed: Word table (table function), separate Word file
            rejected: [Excel, PDF, image]
          
          limits:
            max_display_items: 5  # tables + figures combined (approximate)
          
          lines:
            vertical: false
            horizontal: [top, below_header, bottom]  # exactly 3
          
          font:
            size: null
          
          title:
            format: "Table N: Title text"
            placement: above
            separator: colon  # different from AMA period
            keep_minimal: true
          
          footnotes:
            marker_system: symbols
            symbol_order: ["*", "†", "‡", "§", "¶", "‖", "**", "††"]
            note: "¶ and ‖ swapped compared to NEJM order"
          
          p_value:
            case: lowercase
            italic: false
            symbol: "p"
            leading_zero: true  # KEY DIFFERENCE from AMA
            examples: ["p=0.03", "p=0.0023", "p<0.0001"]
            significant_figures: 2
            max_decimals: 4
            floor: "p<0.0001"
          
          ci_format:
            separator: "to"
            example: "1.23 (0.89 to 1.70)"
          
          data_conventions:
            format_patterns:
              continuous: "mean (SD) or median (IQR)"
              categorical: "n (%) — percent sign required"
              proportion: "n/N (%)"
            references_in_tables: "numbered per main text citation order"
          
          gtsummary_theme: "lancet"
          
          notes:
            - "Caption must NOT repeat Methods details"
            - "Leading zero on p-values (0.05, not .05)"
            - "Lowercase p, not italic"
          
        • nejm.yaml 1.1 KB
          # NEJM — Table Style Profile
          journal: New England Journal of Medicine
          publisher: NEJM Group
          style_base: Custom (AMA-influenced)
          
          format:
            allowed: Word table
            rejected: [Excel, PDF, image]
          
          limits:
            max_display_items: 5  # tables + figures combined (4-5)
            word_limit: 2700
          
          lines:
            vertical: false
            horizontal_internal: false  # "Do not use internal horizontal and vertical rules"
            horizontal: [top, below_header, bottom]
          
          font:
            size: null
          
          title:
            format: "Table N. Title text"
            placement: above
            separator: period
          
          footnotes:
            marker_system: symbols
            symbol_order: ["*", "†", "‡", "§", "‖", "¶", "**", "††"]
            assignment_order: top_to_bottom_left_to_right
            explanatory_content: footnotes_not_headings
          
          p_value:
            case: uppercase
            italic: true
            symbol: "*P*"
            leading_zero: false
            decimals_above_01: 2
            decimals_01_to_001: 3
            floor: "*P* < .001"
          
          ci_format:
            separator: "to"
            example: "1.23 (95% CI, 0.89 to 1.70)"
          
          gtsummary_theme: "nejm"
          
          notes:
            - "Original Article: ~4-5 display items"
            - "Non-standard abbreviations defined per table"
            - "Symbol footnotes (not letters) — different from AMA 11th"
          
        • radiology.yaml 1.5 KB
          # Radiology (RSNA) — Table Style Profile
          journal: Radiology
          publisher: RSNA
          style_base: AMA 11th Edition
          
          format:
            allowed: Word table (editable)
            rejected: [Excel, PDF, image]
          
          limits:
            max_tables: null  # no explicit limit for Original Research
            max_display_items: null
            max_rows: 40
            max_columns: 8
            max_pages_per_table: 1
            word_limit: 3000  # Introduction through Discussion
          
          lines:
            vertical: false
            horizontal: [top, below_header, bottom]
            additional_horizontal: sparingly
          
          font:
            body: null  # not specified
            size: null
          
          spacing: null
          
          title:
            format: "**Table N.** Title text"
            placement: above
            bold_prefix: true
            separator: period
          
          footnotes:
            marker_system: superscript_lowercase_letters  # a, b, c...
            assignment_order: top_to_bottom_left_to_right
            abbreviation_order: appearance_in_table  # not alphabetical
            abbreviation_format: "BMI = body mass index, OR = odds ratio"
            abbreviation_separator: ", "
            max_abbreviations_per_manuscript: 10
          
          p_value:
            case: uppercase
            italic: true
            symbol: "*P*"
            leading_zero: false
            examples: ["*P* = .03", "*P* < .001"]
            decimals_above_01: 2
            decimals_01_to_001: 3
            floor: "*P* < .001"
            never_report_p_equals_1: true  # use > .99
          
          ci_format:
            separator: comma
            brackets: parentheses
            example: "(0.82, 0.95)"
          
          data_conventions:
            parentheses_default: percentage  # unspecified parenthetical data = %
            decking_max_levels: 3
          
          notes:
            - "RSNA Scientific Style Guide is authoritative"
            - "Review articles: max 4 tables"
            - "Radiology:AI follows same RSNA style"
          
      • table-types
        • agreement.md 3.6 KB
          # Table: Reliability / Agreement Results
          
          ## Reporting Guidelines
          - **GRRAS**: Reliability and agreement studies — report the coefficient, its model/definition,
            the 95% CI, and the number of raters/subjects/replicates.
          - Distinguish **relative reliability** (how well raters/methods separate subjects relative to
            total variance — a consistency ICC) from **absolute agreement** (how close the actual values
            are — an absolute-agreement ICC, or Bland–Altman LoA for two continuous methods). κ measures
            categorical agreement *corrected for chance*. Report the metric that matches the question, and
            both when relevant.
          
          ## Standard Structure
          
          ```
          Table 3. Inter-rater Reliability and Agreement for [measurement]
          (n = [N] subjects, [R] raters, [k] replicates)
          
          Measure / metric          Estimate (95% CI)        Notes
          
          ICC (continuous)          0.88 (0.82-0.92)         two-way random, absolute agreement,
                                                             single measures [ICC(2,1)]
          Weighted κ (ordinal)      0.74 (0.65-0.82)         quadratic weights
          Bland-Altman bias          1.2 (0.6 to 1.8)        mean difference (rater A - rater B)
            Limits of agreement     -4.8 to 7.2              ±1.96 SD; CI on each limit in footnote
          Percent agreement         86%                      within ±[clinically acceptable Δ]
          
          ICC = intraclass correlation coefficient. Heteroscedasticity assessed (LoA constant across
          the measurement range). Interpretation: ICC >0.90 excellent, 0.75-0.90 good, 0.50-0.75
          moderate, <0.50 poor.
          ```
          
          ## Rules
          - **ICC is uninterpretable without its model and definition** — always state: one-way vs
            **two-way**; **consistency vs absolute agreement**; **single vs average** measures (the
            Shrout–Fleiss form, e.g., ICC(2,1)). Report the point estimate **with its 95% CI**.
          - **Ordinal categories → weighted κ** (state linear or quadratic weights); unweighted κ ignores
            the magnitude of disagreement. Nominal → unweighted κ (or Fleiss' κ for >2 raters). Report
            the CI; note that κ is prevalence-sensitive (report observed agreement alongside it).
          - **Agreement of two continuous methods → Bland–Altman**: mean difference (bias) with its CI,
            and the **limits of agreement (bias ± 1.96 SD)** with a CI on each limit; assess
            **heteroscedasticity** (do the LoA widen with magnitude? — if so, log-transform or model the
            SD). State whether the LoA fall within a pre-defined clinically acceptable difference.
          - **Never report Pearson/Spearman correlation as agreement** — high correlation is compatible
            with large systematic bias; correlation measures association, not agreement.
          - **Sample**: report n subjects, n raters/methods, replicates per subject, and a sample-size /
            precision justification for the CI width.
          - Match the metric to the data and the question; do not report an ICC for nominal categories or
            a κ for continuous measurements.
          
          ## Python / R Code
          ```r
          library(irr); library(psych); library(blandr)
          
          # ICC — specify model & type explicitly (psych::ICC reports all six forms)
          psych::ICC(ratings_wide)                 # pick the row matching your design, e.g. ICC2 (2,1)
          
          # Weighted kappa for ordinal (two raters)
          irr::kappa2(cbind(rater_a, rater_b), weight = "squared")   # quadratic weights
          
          # Bland-Altman (two continuous methods)
          blandr::blandr.statistics(method_a, method_b)   # bias, LoA, and CIs; plot via blandr.draw()
          ```
          ```python
          import pingouin as pg
          pg.intraclass_corr(data=df, targets="subject", raters="rater", ratings="value")  # all ICC forms + CI
          # pg.cohen_kappa(...) for categorical; Bland-Altman bias = mean(diff), LoA = mean ± 1.96*sd
          ```
          
        • diagnostic_accuracy.md 1.5 KB
          # Table: Diagnostic Accuracy Results
          
          ## Reporting Guidelines
          - **STARD**: Sensitivity, Specificity, PPV, NPV with 95% CIs
          - **TRIPOD-AI / CLAIM**: For AI model performance
          
          ## Standard Structure
          
          ```
          Table 2. Diagnostic Performance of [Model/Test] for [Condition]
          
                               Sensitivity       Specificity       PPV              NPV              AUC
                               (95% CI)          (95% CI)          (95% CI)         (95% CI)         (95% CI)
          
          Model A              0.92 (0.87-0.96)  0.85 (0.79-0.90)  0.78 (0.71-0.84) 0.95 (0.91-0.97) 0.94 (0.91-0.97)
          Model B              0.88 (0.82-0.93)  0.90 (0.85-0.94)  0.83 (0.76-0.88) 0.93 (0.89-0.96) 0.93 (0.89-0.96)
          P value              .12               .08               .21              .34              .45
          
          95% CIs were calculated using the Wilson score method. AUC comparison
          by DeLong test.
          ```
          
          ## Rules
          - **Always include 95% CIs** for all metrics (STARD requirement)
          - **CI method**: Specify in footnote (Wilson score, Clopper-Pearson, DeLong)
          - **Threshold**: State the decision threshold used (e.g., "at Youden optimal threshold")
          - **Per-class results**: For multi-class, show per-class + macro/micro average
          - **Comparison P values**: DeLong test for AUC, McNemar for sensitivity/specificity
          - **Decimal places**: 2-3 for proportions (0.92), 2-3 for AUC (0.94)
          - **Reader studies**: Include per-reader AND pooled results
          
          ## Key Footnote Content
          - CI calculation method
          - Comparison test used (DeLong, McNemar, bootstrap)
          - Threshold selection method
          - Whether results are per-patient or per-lesion
          
        • incremental_value.md 4.4 KB
          # Table: Incremental (Added) Value Beyond a Baseline Model
          
          The table standard for any "the new marker/model adds value **beyond** an established baseline"
          claim — a new biomarker on top of a clinical score, an AI model on top of a radiologist or a
          guideline nomogram. Discrimination alone (a higher AUROC) is not sufficient evidence of added
          value; report the paired change in discrimination **with** a reclassification and a clinical-utility
          metric, all on the **same patients** and the same held-out data.
          
          ## Reporting Guidelines
          - **TRIPOD / TRIPOD+AI**: prediction-model development/validation, model comparison and performance
          - **TRIPOD-LLM**: when the augmenting model is an LLM
          - **CLAIM**: AI in medical imaging
          - **STARD / STARD-AI**: when the added value is framed as diagnostic accuracy
          
          ## Standard Structure
          
          ```
          Table 4. Incremental Value of [New Predictor/Model] Added to [Baseline] (external test set, n = ___, events = ___)
          
          Model                         C-statistic (95% CI)   ΔC (95% CI)         P (ΔC)   Continuous NRI (95% CI)   IDI (95% CI)      ΔNet benefit @ threshold
          Baseline ([predictors])       0.74 (0.70-0.78)       —  (reference)       —        —                          —                 — (reference)
          Baseline + [new predictor]    0.81 (0.77-0.85)       0.07 (0.04-0.10)     <.001    0.38 (0.21-0.55)           0.045 (0.02-0.07) +0.021 @ 10%
          
          ΔC by DeLong test on paired, same-patient predictions. NRI/IDI from the paired risk
          estimates (event/non-event components reported separately in the footnote). Net benefit
          from decision-curve analysis at the prespecified threshold; full curve in Figure X.
          ```
          
          ## Rules
          - **Nested / same-patient comparison**: the augmented model must be the baseline **plus** the new
            term, evaluated on the **identical** held-out patients — not two models on different cohorts.
          - **Baseline named and justified**: state exactly what the baseline contains (clinical score,
            guideline nomogram, prior model) and that it was applied as published / recalibrated / refit.
          - **ΔAUC with a paired CI and test**: report ΔC-statistic with a 95% CI and the **DeLong** paired
            test, not two independent AUROCs eyeballed side by side. A tiny, non-significant ΔC with wide CI
            is not "added value."
          - **Reclassification reported correctly**: prefer **continuous NRI** or category-free NRI; if a
            categorical NRI is used, prespecify and justify the risk categories (post-hoc categories inflate
            NRI). Always report **event** and **non-event** NRI components separately — a positive overall
            NRI driven only by the non-event component is weak. Report **IDI** alongside.
          - **Clinical utility, not just statistics**: include **net benefit** (decision-curve analysis) at a
            prespecified, clinically justified threshold (and reference the full curve —
            `make-figures` `exemplar_plots/decision_curve.md`). Reclassification metrics without utility can
            mislead.
          - **Calibration first**: NRI/IDI/net benefit all depend on predicted probabilities, so both models
            must be calibrated on the test data before these are computed (pair with the calibration plot).
          - **One decimal discipline / CIs everywhere**: every estimate carries a 95% CI; bootstrap CIs state
            the number of replicates; the same bootstrap resamples are reused across paired metrics.
          
          ## Common pitfalls (flag in review)
          - Reporting only ΔAUROC and calling it "added value" (no reclassification, no utility).
          - NRI with post-hoc categories, or only the overall NRI without event/non-event split.
          - New vs baseline AUROCs from different cohorts or different n (not a paired comparison).
          - Net benefit asserted in prose with no decision curve and no stated threshold.
          
          ## Code (illustrative)
          
          ```r
          # Paired ΔAUC with DeLong CI (pROC); NRI/IDI (Hmisc / nricens / PredictABEL-style).
          library(pROC)
          roc_base <- roc(y, p_baseline); roc_full <- roc(y, p_full)
          roc.test(roc_base, roc_full, method = "delong", paired = TRUE)   # ΔC + p, paired
          # NRI/IDI from paired risk vectors p_baseline, p_full (report event + non-event components);
          # net benefit via the decision-curve template (analyze-stats references/templates/dca_plot.R).
          ```
          
          ```python
          # Paired DeLong CI for ΔAUC (e.g., delong_roc_test); reclassification via statsmodels/lifelines-
          # adjacent helpers. Compute NRI/IDI on the SAME held-out patients used for the AUCs; reuse one set
          # of bootstrap resamples across ΔAUC, NRI, IDI, and net benefit so the CIs are mutually consistent.
          ```
          
        • meta_analysis.md 2.3 KB
          # Tables: Meta-Analysis
          
          ## Reporting Guidelines
          - **PRISMA 2020**: Study characteristics + pooled results
          - **PRISMA-DTA**: For diagnostic test accuracy meta-analyses
          
          ---
          
          ## Table A: Characteristics of Included Studies
          
          ```
          Table 1. Characteristics of Included Studies
          
          Author, Year   Country   Design   N      Population       Index Test        Reference Standard   Quality
          Kim 2023       Korea     Retro    450    Suspected PE     CTPA AI (v2.1)    Expert consensus     Low risk
          Smith 2024     USA       Prosp    1200   ED patients      CTPA AI (v3.0)    Pulmonary DSA        Some concerns
          ...
          
          Retro = retrospective, Prosp = prospective, PE = pulmonary embolism,
          CTPA = CT pulmonary angiography, ED = emergency department,
          DSA = digital subtraction angiography.
          ```
          
          ### Rules
          - **Column order**: Author/Year, Country, Design, N, Population, Index test, Reference standard, Quality/RoB
          - **Author format**: First author surname + year
          - **Study design**: Use standard abbreviations (Retro, Prosp, RCT)
          - **Quality assessment tool**: QUADAS-2 (DTA), RoB 2 (RCT), NOS (observational)
          - **Quality rating**: "Low risk" / "Some concerns" / "High risk" (QUADAS-2 terms)
          
          ---
          
          ## Table B: Pooled Results / Summary Estimates
          
          ```
          Table 2. Pooled Diagnostic Accuracy Estimates
          
                              k    N       Pooled Estimate (95% CI)    I²     P_het
          
          Sensitivity         12   3400    0.91 (0.87-0.94)           78%    <.001
          Specificity         12   3400    0.88 (0.83-0.92)           65%    .002
          Positive LR         12   3400    7.58 (5.12-11.2)           72%    <.001
          Negative LR         12   3400    0.10 (0.07-0.15)           69%    .001
          DOR                 12   3400    75.8 (42.1-136.5)          58%    .008
          
          k = number of studies, N = total participants, CI = confidence interval,
          LR = likelihood ratio, DOR = diagnostic odds ratio.
          Pooled estimates from bivariate random-effects model.
          I² = Higgins inconsistency statistic; P_het from Cochran Q test.
          ```
          
          ### Rules
          - **k and N**: Always report number of studies and total participants
          - **Heterogeneity**: I² + P from Cochran Q test (mandatory)
          - **Model**: State random-effects vs fixed-effects in footnote
          - **Subgroup analyses**: Separate rows or separate table
          - **Prediction interval**: Include for random-effects if k >= 3
          - **Forest plot complement**: Table complements but does not replace forest plot
          
        • model_comparison.md 1.5 KB
          # Table: Model Performance Comparison
          
          ## Reporting Guidelines
          - **TRIPOD-AI**: AI model development/validation
          - **CLAIM**: AI in medical imaging
          
          ## Standard Structure
          
          ```
          Table 3. Comparison of Model Performance on Test Set
          
          Model          AUROC (95% CI)       Sensitivity (95% CI)  Specificity (95% CI)  P vs Baseline
          
          Baseline CNN   0.87 (0.83-0.91)    0.82 (0.75-0.88)     0.85 (0.79-0.90)     —
          Proposed Model 0.93 (0.90-0.96)    0.90 (0.84-0.94)     0.89 (0.84-0.93)     .003
          Reader A       0.88 (0.84-0.92)    0.85 (0.78-0.90)     0.86 (0.80-0.91)     .52
          Reader B       0.86 (0.81-0.90)    0.83 (0.76-0.89)     0.84 (0.78-0.89)     .71
          
          AUROC compared by DeLong test. Sensitivity/specificity at Youden optimal
          threshold. 95% CIs by Wilson score interval.
          ```
          
          ## Rules
          - **Baseline/reference model**: Always include (first row, P = — or "Ref")
          - **Reader comparison**: Include human readers when available
          - **Same test set**: All models evaluated on identical held-out data
          - **Threshold**: Specify how threshold was chosen (Youden, fixed sensitivity, etc.)
          - **Bootstrap CIs**: State number of iterations if used (e.g., "1000 bootstrap replicates")
          - **Multiple test sets**: Separate sections or sub-tables for internal vs external validation
          - **Calibration**: Include Brier score or calibration slope if relevant
          
          ## AI-Specific Additions (CLAIM)
          - Software version / model architecture in footnote or methods
          - Training set size in footnote
          - Hardware used (if inference time reported)
          - Whether results are per-image or per-patient
          
        • reader_study.md 3.8 KB
          # Table: Multi-Reader Multi-Case (MRMC) Reader-Study Results
          
          The table standard for a **reader study** — multiple readers interpreting the same cases under one
          or more conditions (e.g., unaided vs AI-aided, or modality A vs B). The headline is a
          **reader-averaged** performance with a confidence interval that accounts for **both reader and case
          variability**, with **per-reader** results shown so a single average cannot hide a weak reader.
          
          ## Reporting Guidelines
          - **STARD / STARD-AI**: diagnostic accuracy reporting (index test, reference standard, flow)
          - **CLAIM**: AI in medical imaging (reader study is a common CLAIM design)
          - MRMC analysis convention: Obuchowski–Rockette (OR) / Dorfman–Berbaum–Metz (DBM) for reader+case variance
          
          ## Standard Structure
          
          ```
          Table 3. Reader Performance, Unaided vs AI-aided (MRMC, fully crossed; N readers, M cases; per-patient)
          
          Reader            Unaided AUC (95% CI)     AI-aided AUC (95% CI)     ΔAUC (95% CI)
          Reader 1          0.82 (0.76-0.88)         0.88 (0.83-0.93)          +0.06 (0.01-0.11)
          Reader 2          0.79 (0.72-0.86)         0.86 (0.80-0.92)          +0.07 (0.02-0.12)
          ...               ...                      ...                       ...
          Reader N          0.85 (0.79-0.90)         0.89 (0.84-0.94)          +0.04 (-0.01-0.09)
          Reader-averaged   0.82 (0.77-0.87)*        0.88 (0.84-0.92)*         +0.06 (0.02-0.10)*
          
          * Reader-averaged AUC and ΔAUC with MRMC 95% CIs (Obuchowski-Rockette), accounting for
            reader and case variance. N readers (random), M cases. Fully crossed. Unit: per-patient.
            Non-inferiority margin (if applicable): ΔAUC > -0.05, pre-specified.
          ```
          
          ## Rules
          - **Reader-averaged headline + per-reader rows**: report the reader-averaged estimate (the inferential
            target) *and* each reader, so reader spread is visible. A single averaged AUC alone is insufficient.
          - **MRMC CI, not fixed-reader CI**: the reader-averaged CI and any ΔAUC CI must come from an MRMC method
            (OR/DBM) that accounts for **reader + case** variance. Do **not** report a DeLong CI (case-only) for a
            generalising reader claim — it understates uncertainty.
          - **State the design**: fully crossed vs split-plot; number of readers (and that they are a random
            sample of the reader population for generalisation); number of cases / prevalence.
          - **Unit of analysis**: per-patient vs per-lesion (clustered) — state it; do not analyse clustered
            lesion data as independent.
          - **Reading order / washout**: for a within-reader condition comparison, state order randomisation and
            the washout interval (footnote) — it is part of the design's validity.
          - **Estimand**: superiority vs non-inferiority; for non-inferiority, pre-specify and report the margin.
            Report ΔAUC (or Δsensitivity/Δspecificity at a fixed operating point) with its MRMC CI.
          - **Operating-point metrics**: if sensitivity/specificity are reported, fix and state the operating
            point (reader-declared threshold), and apply the same MRMC variance treatment.
          
          ## Common pitfalls (flag in review)
          - Reader-averaged AUC only, no per-reader rows (hides a weak reader).
          - DeLong / fixed-reader CI used for a claim that generalises to readers (ignores reader variance).
          - Clustered per-lesion data analysed as independent.
          - Non-inferiority asserted with no pre-specified margin.
          
          ## Code (illustrative)
          
          ```r
          # MRMC reader-study analysis (Obuchowski-Rockette / DBM) — e.g., the RJafroc or MRMCaov package.
          # Input: long data frame (readerID, caseID, modality/condition, score, truth).
          # Output: reader-averaged AUC per condition + ΔAUC with MRMC (reader+case) CI and p; per-reader AUCs
          # from the same fit. Pair the figure with make-figures exemplar_plots/mrmc_roc.md (per-reader +
          # reader-averaged curves). Report the unit of analysis (per-patient vs per-lesion) and the design
          # (fully crossed, N readers random, M cases).
          ```
          
        • regression_results.md 1.8 KB
          # Table: Regression Results
          
          ## Reporting Guidelines
          - **STROBE**: Observational studies — OR, HR, RR with 95% CI
          - **TRIPOD**: Prediction models — coefficients or OR with CI
          
          ## Standard Structure
          
          ```
          Table 3. Multivariable Logistic Regression for [Outcome]
          
          Variable              Univariable              Multivariable
                                OR (95% CI)    P Value   OR (95% CI)      P Value
          
          Age, per 10 y         1.22 (1.08-1.38) .002   1.18 (1.03-1.35)  .02
          Sex, male vs female   1.45 (0.98-2.14) .06    1.38 (0.91-2.09)  .13
          BMI, per 5 kg/m²      1.31 (1.12-1.53) <.001  1.25 (1.06-1.47)  .008
          Stage III vs I-II     2.87 (1.92-4.29) <.001  2.54 (1.65-3.91)  <.001
          
          OR = odds ratio, CI = confidence interval, BMI = body mass index.
          ```
          
          ## Rules
          - **Reference category**: Always state (e.g., "male vs female", "Stage III vs I-II")
          - **Clinically meaningful units**: "per 10 years" not "per 1 year" for continuous
          - **Effect measure**: Match study design — OR (logistic), HR (Cox), RR (log-binomial), β (linear)
          - **Always show both univariable AND multivariable** (or justify omission)
          - **Model fit statistics**: AIC, C-statistic, or Hosmer-Lemeshow in footnote or separate row
          - **Variable selection method**: State in footnote (e.g., "Variables with P < .10 in univariable entered multivariable")
          - **Collinearity**: Note if VIF checked, in footnote
          
          ## gtsummary Code
          ```r
          # Univariable
          uv <- tbl_uvregression(
            data, y = outcome, method = glm,
            method.args = list(family = binomial),
            exponentiate = TRUE
          )
          
          # Multivariable
          mv <- glm(outcome ~ age + sex + bmi + stage, data = data, family = binomial)
          mv_tbl <- tbl_regression(mv, exponentiate = TRUE) %>%
            add_global_p() %>%
            bold_p(t = 0.05)
          
          # Merge
          tbl_merge(list(uv, mv_tbl),
                    tab_spanner = c("Univariable", "Multivariable"))
          ```
          
        • survival_results.md 4.2 KB
          # Table: Survival / Time-to-Event (Cox) Results
          
          ## Reporting Guidelines
          - **STROBE**: Observational time-to-event — HR with 95% CI, plus events, person-time, and follow-up.
          - **TRIPOD**: Prognostic models — coefficients/HR with CI and a discrimination measure (C-index).
          
          ## Standard Structure
          
          ```
          Table 3. Cox Proportional Hazards for [Outcome] (events = [E] / N = [N];
          median follow-up [X.X] y [reverse Kaplan-Meier])
          
          Variable            Events/N    IR*      Univariable          Multivariable
                                                   HR (95% CI)  P       HR (95% CI)   P
          
          Age, per 10 y         —         —        1.28 (1.11-1.47) <.001 1.21 (1.04-1.41) .01
          Sex, male vs female  120/410   3.8      1.42 (1.02-1.98) .04   1.33 (0.94-1.88) .10
          Stage III vs I-II    180/300   9.1      2.65 (1.88-3.74) <.001 2.31 (1.59-3.36) <.001
          Biomarker ≥[cut]     ...        ...      ...                    ...
          
          IR = incidence rate per 100 person-years; HR = hazard ratio; CI = confidence interval.
          Reference categories: age <[x], female, Stage I-II, biomarker <[cut].
          Model adjusted for [covariate set]. Proportional-hazards assumption assessed by
          Schoenfeld residuals (global P = [.xx]). C-index [0.xx].
          ```
          
          ## Rules
          - **Events and person-time, not just N**: show events per group and an incidence rate (or
            person-years); a HR without the event count is uninterpretable and hides a sparse-data model.
          - **Median follow-up via reverse Kaplan-Meier** (not the mean of observed times), reported in
            the title or a row; report median survival with 95% CI where estimable.
          - **Reference category** always stated (set factor levels explicitly so the model matches the
            footnote); **clinically meaningful units** for continuous ("per 10 y", not "per 1 y");
            **effect measure = HR** (Cox), and match the design.
          - **Median survival** with 95% CI where estimable; report **"not reached"** when the event rate
            stays below 50% — never substitute the maximum observed follow-up or leave a blank.
          - **Univariable AND multivariable** (or justify omission); state the variable-selection rule
            and any forced confounders in a footnote.
          - **Proportional-hazards assumption**: report that Schoenfeld residuals were checked. If
            **violated**, do not present a single time-averaged HR — report a piecewise/time-stratified
            HR or an **RMST difference at a fixed horizon**, and say so.
          - **EPV / sparse strata**: with events/covariates < 10, flag instability (Firth/penalized Cox,
            profile-likelihood CIs); for any stratum-specific HR with < 10 events, add a sparse-stratum
            caveat with its reference contrast and event count.
          - **Nested units** (multiple lesions/both eyes/repeated episodes): note cluster-robust
            (sandwich) variance was used so CIs are not artificially narrow.
          - **Interval-censored** (event detected at scheduled visits, not observed exactly): note the
            interval-censored model; do not present a right-censored Cox as if times were exact.
          - **Censoring**: state how censoring was defined and the competing-risk handling (cause-specific
            vs subdistribution) when competing events exist.
          
          ## gtsummary / R Code
          ```r
          library(survival); library(gtsummary); library(dplyr)
          
          # Set reference levels explicitly so the model matches the table footnote
          d <- d %>% mutate(
            sex   = relevel(factor(sex), ref = "female"),
            stage = relevel(factor(stage), ref = "I-II")
          )
          
          # Median follow-up (reverse KM): event indicator flipped
          fu <- survfit(Surv(time, 1 - status) ~ 1, data = d)  # report median of `fu`
          
          # Univariable
          uv <- tbl_uvregression(
            d[, c("time","status","age","sex","stage","biomarker")],
            method = coxph, y = Surv(time, status),
            exponentiate = TRUE
          )
          
          # Multivariable + PH check
          fit <- coxph(Surv(time, status) ~ age + sex + stage + biomarker, data = d)
          cox.zph(fit)                       # Schoenfeld global + per-term; if violated, see Rules
          mv <- tbl_regression(fit, exponentiate = TRUE) %>% add_global_p() %>% bold_p(t = 0.05)
          
          tbl_merge(list(uv, mv), tab_spanner = c("Univariable", "Multivariable"))
          # concordance(fit)$concordance   # C-index for the footnote
          
          # Nested units (multiple lesions/both eyes/repeated episodes): cluster-robust variance
          # fit_r <- coxph(Surv(time, status) ~ age + sex + stage + cluster(id), data = d, robust = TRUE)
          ```
          
        • table1_demographics.md 3.8 KB
          # Table 1: Baseline Demographics / Characteristics
          
          ## Reporting Guidelines
          - **RCT**: CONSORT (Table 1 should NOT include P values — randomization makes them irrelevant)
          - **Cohort/Cross-sectional**: STROBE (P values optional, SMD preferred for propensity-matched)
          - **Diagnostic**: STARD (patient demographics + index test characteristics)
          
          ## Standard Structure
          
          ```
          Table 1. Baseline Characteristics of Patients
          
                                    Group A (n=XXX)    Group B (n=XXX)    P Value
          Age, y                    65.3 (12.1)        62.1 (11.8)        .04
          Sex
            Male                    53 (52)            48 (47)            .41
            Female                  49 (48)            54 (53)
          BMI, kg/m²               24.8 (3.2)         25.1 (3.5)         .52
          ...
          
          Data are presented as mean (SD) for continuous variables and n (%)
          for categorical variables.
          ```
          
          ## Rules
          - **Binary variables**: Show only one level (e.g., Male only; Female is implied)
          - **Continuous variables**: Mean (SD) if normal; Median (IQR) if skewed. State which in footnote. **Choose by skewness, not by a mean−median/SD heuristic** (see below)
          - **Categorical variables**: n (%)
          
          ### Mean (SD) vs Median (IQR): choose by skewness, and couple the test
          
          The selection criterion is **`|skewness| > 1`** (equivalently a Shapiro–Wilk rejection or a clear visual departure), **not** a `|mean − median| / SD > 0.5` rule. The mean−median/SD ratio fails exactly where it matters: a strongly right-skewed lab with a large SD (triglycerides, glucose, HbA1c, creatinine, often diastolic BP) can have skewness 2–4 yet `|mean − median|/SD ≈ 0.3`, so the heuristic wrongly keeps it as mean (SD). Use skewness so heavy-tailed labs are reported as median (IQR).
          
          **Couple the statistic to the test** — a variable shown as median (IQR) must be compared with a **rank test (Wilcoxon / Mann–Whitney)**, and a variable shown as mean (SD) with a **t-test**. Reporting median (IQR) but a t-test p-value (or vice versa) is an internal inconsistency a reviewer will flag (and `/self-review` Phase 2.5a checks prose↔table statistic-type match).
          
          ```python
          import numpy as np
          from scipy.stats import skew, ttest_ind, mannwhitneyu
          
          def summarize_continuous(x_by_group, full):
              """Return ('mean_sd'|'median_iqr', display, p) for one continuous variable.
              full = all non-missing values pooled; x_by_group = [groupA_vals, groupB_vals]."""
              if abs(skew(full, nan_policy="omit")) > 1:          # skewed -> median (IQR) + Wilcoxon
                  stat, p = mannwhitneyu(*x_by_group, alternative="two-sided")
                  return "median_iqr", f"{np.median(full):.1f} ({np.percentile(full,25):.1f}-{np.percentile(full,75):.1f})", p
              stat, p = ttest_ind(*x_by_group, equal_var=False)   # symmetric -> mean (SD) + t-test
              return "mean_sd", f"{np.mean(full):.1f} ({np.std(full,ddof=1):.1f})", p
          ```
          
          In `gtsummary`, drive the same split: put skewed variables in `type = list(<var> ~ "continuous")` with `statistic = list(<skewed> ~ "{median} ({p25}-{p75})")` and `add_p(test = list(<skewed> ~ "wilcox.test", <symmetric> ~ "t.test"))`.
          - **Column headers**: Include group size — "Group A (n=XXX)"
          - **Units**: In row label — "Age, y" or "Age, years"
          - **Missing data**: Report as "Missing" row or footnote stating N with complete data
          - **P values in RCTs**: Omit (CONSORT recommendation) or include for observational studies
          - **SMD**: Preferred over P values for propensity-matched studies
          
          ## gtsummary Code
          ```r
          tbl_summary(
            data, by = group,
            type = list(age ~ "continuous2"),
            statistic = list(
              all_continuous() ~ c("{mean} ({sd})"),
              all_categorical() ~ "{n} ({p}%)"
            ),
            digits = list(all_continuous() ~ 1, all_categorical() ~ c(0, 1)),
            missing = "ifany",
            missing_text = "Missing"
          ) %>%
            add_p() %>%         # omit for RCTs
            add_overall() %>%
            bold_labels()
          ```
          
      • table-standards.md 10.1 KB
        # Publication Table Standards — Knowledge Base
        
        > Reference document for medical journal table formatting.
        > Source: YouTube tutorials, journal author guidelines, AMA Manual of Style, tool documentation.
        > Last updated: 2026-04-11
        
        ---
        
        ## 1. Universal Rules (All Medical Journals)
        
        1. **No vertical lines** — horizontal rules only (top, below header, bottom)
        2. **Editable Word tables** — use Word Insert > Table. Never submit images, Excel, or PDF
        3. **Sequential numbering** — Table 1, 2, 3... in order of first citation in text
        4. **Define all abbreviations** — in footnotes, independently for each table
        5. **Explanatory content in footnotes, not headings**
        6. **Display item limit** — most journals: tables + figures combined 4-5
        7. **Exact P values** — never just "significant/not significant"
        8. **Variability measures required** — always state mean (SD) or median (IQR)
        9. **Self-contained titles** — table title alone must convey content without reading text
        10. **No duplication** — tables and figures must not repeat the same data
        
        ---
        
        ## 2. Journal-Specific Differences
        
        ### Footnote Markers
        
        | Journal Family | Marker System | Order |
        |---|---|---|
        | AMA (Radiology, JAMA, AJR, Rad:AI) | Superscript lowercase letters | a, b, c, d... |
        | NEJM | Symbols | *, †, ‡, §, ‖, ¶, **, ††... |
        | Lancet | Symbols | *, †, ‡, §, ¶, ‖, **, ††... |
        | European Radiology (Springer) | Superscript lowercase letters + asterisks for significance | a, b, c... and *, **, *** |
        
        ### P Value Formatting
        
        | Journal | Case | Leading Zero | Examples |
        |---|---|---|---|
        | Radiology / JAMA / AJR / Rad:AI | Uppercase italic *P* | No | *P* = .03, *P* < .001 |
        | NEJM | Uppercase *P* | No | *P* = .04, *P* < .001 |
        | Lancet | Lowercase p | Yes | p = 0.03, p < 0.0001 |
        | European Radiology | Lowercase p (Springer) | Varies | p = 0.03 |
        
        ### P Value Decimal Places
        
        | Value Range | JAMA/Radiology | NEJM | Lancet |
        |---|---|---|---|
        | > .01 | 2 decimals (.04) | 2 decimals (.04) | 2 sig figs (0.04) |
        | .01-.001 | 3 decimals (.003) | 3 decimals (.003) | Up to 4 decimals (0.0023) |
        | < .001 | *P* < .001 | *P* < .001 | p < 0.0001 |
        
        ### Horizontal Lines
        
        | Journal | Rule |
        |---|---|
        | AMA journals | Top, below header, bottom. Additional sparingly |
        | NEJM | Minimize even internal horizontal lines |
        | Lancet | Exactly 3: top, below header, bottom |
        
        ### 95% CI Format
        
        | Journal | Format |
        |---|---|
        | Radiology | (XX, XX) — comma separator |
        | JAMA | XX to XX — "to" separator |
        | Lancet | XX to XX or XX-XX |
        | NEJM | XX to XX |
        
        ### Table Title Format
        
        | Journal | Format |
        |---|---|
        | AMA journals | **Table N.** Title in regular weight (bold "Table N." only) |
        | NEJM | Table N. Title |
        | Lancet | Table N: Title |
        
        ---
        
        ## 3. AMA Manual of Style (11th Ed) — Table Rules
        
        ### Structure
        - **Column headers**: Bold
        - **Row headers (stub)**: Regular weight, sentence case
        - **Decking** (nested column headers): Maximum 3 levels
        - **Alignment**: Text = left-aligned, Numbers = center or decimal-aligned
        - **Units**: In column header parentheses, not repeated in cells
        - **Maximum size**: ~40 rows x 6-8 columns, fit within 1 page
        
        ### Footnote System (AMA 11th)
        - **Table footnotes**: Superscript lowercase letters (a, b, c...)
        - **NOT symbols** — symbols (*, †, ‡) are for bottom-of-page text footnotes only
        - **Assignment order**: Top-to-bottom, left-to-right through the table
        - **Whole-table footnote**: Place superscript "a" at end of table title
        - **Reference numbers + footnotes**: Reference first, comma, then footnote letter (e.g., 5,b)
        
        ### Footnote Placement Order
        ```
        Below the table, in this sequence:
        
        1. General note (no marker, applies to entire table)
           "Data are presented as median (IQR) unless otherwise noted."
        
        2. Abbreviation note
           "ASA = American Society of Anesthesiologists, BMI = body mass index,
            CI = confidence interval."
           → Listed in order of appearance in table (left→right, top→bottom)
           → Some journals (JAMA) require alphabetical order — check guide
        
        3. Specific notes (superscript letter markers)
           "a Excludes patients with missing follow-up data."
           "b Adjusted for age and sex."
        
        4. Probability notes (asterisk markers, if used)
           "* P < .05; ** P < .01."
        ```
        
        ### P Value Rules
        - Uppercase italic: *P*
        - No leading zero: .05, not 0.05
        - Thin space around operators: *P* = .03
        - Only *P*, *α*, *β* omit leading zero; all other statistics keep it
        
        ---
        
        ## 4. Footnote System Specification
        
        ### When to Use Symbols vs Letters vs Numbers
        
        | System | Markers | Use Case | Journals |
        |---|---|---|---|
        | Lowercase letters | a, b, c... | **Table footnotes (standard)** | AMA journals, EUR, most |
        | Symbols | *, †, ‡, §, ‖, ¶ | Table footnotes (traditional) | NEJM, Lancet |
        | Asterisks only | *, **, *** | **Probability notes only** | Springer journals |
        | Uppercase letters | A, B, C... | Table footnotes | JCI |
        | Numbers | 1, 2, 3... | Non-numeric tables only | Rarely used (confusion with references) |
        
        ### Caption vs Footnote — What Goes Where
        
        | Content | Caption (above table) | Footnote (below table) |
        |---|---|---|
        | Table title | Yes | No |
        | Study period/setting | Yes (brief) | No |
        | Abbreviation definitions | No | **Yes** (first footnote) |
        | Statistical test descriptions | No | **Yes** |
        | P value thresholds | No | **Yes** (probability note) |
        | Data source | No | **Yes** (source note) |
        | Missing data explanation | No | **Yes** (specific note) |
        | Sample size (N) | Column header or caption | Footnote (supplementary) |
        
        ### Statistical Test Footnote Patterns
        
        **Pattern A — Individual markers (preferred when tests vary by row):**
        ```
        Age, y                    65.3 (12.1)    62.1 (11.8)    .04ᵃ
        Sex, male                 53 (52)        48 (47)        .41ᵇ
        
        ᵃ Wilcoxon rank-sum test.
        ᵇ Fisher exact test.
        ```
        
        **Pattern B — General note (preferred when same test for all rows of a type):**
        ```
        P values were calculated using the Wilcoxon rank-sum test for continuous
        variables and the Fisher exact test for categorical variables.
        ```
        
        ---
        
        ## 5. Common Mistakes Checklist
        
        ### Structure
        - [ ] Binary variables: show only one level (Male 53%, not Male 53% / Female 47%)
        - [ ] Remove derivable columns (Total column when groups are shown)
        - [ ] Keep tables under 50 rows (excess → supplementary)
        - [ ] No sub-part numbering (Table 3A/3B → Table 3, Table 4)
        - [ ] Do not repeat all table data in Results text
        
        ### Formatting
        - [ ] Consistent decimal places within each column
        - [ ] Units in column headers, not in cells
        - [ ] No vertical lines, minimal horizontal lines
        - [ ] Numbers right-aligned or center-aligned (not left)
        - [ ] Specify variability: "Mean (SD)" or "Median (IQR)" in header or footnote
        
        ### Statistics
        - [ ] RCTs: P values in Table 1 are usually unnecessary (randomization)
        - [ ] Always name the statistical test (in footnote or general note)
        - [ ] Report effect sizes per clinically meaningful unit (OR per 10-year, not per 1-year)
        - [ ] CI notation consistent throughout (parentheses vs "to" — match journal style)
        - [ ] Never use "NS" — report exact P values
        
        ### Submission
        - [ ] Editable Word table (not image/screenshot)
        - [ ] No color as sole information carrier (use bold/italic/symbols instead)
        - [ ] All abbreviations defined in footnotes
        - [ ] Each table's footnotes are self-contained (no "see Table 1 footnote")
        
        ---
        
        ## 6. Tool Recommendations
        
        ### Primary Pipeline: R {gtsummary}
        
        **Why gtsummary:**
        - Built-in journal themes: JAMA, Lancet, NEJM
        - Auto-selects statistical tests (Wilcoxon, Fisher, Chi-square)
        - Auto-generates footnotes (test names, summary statistics)
        - Outputs to Word (via flextable), LaTeX (via huxtable), HTML (via gt)
        
        **Core API:**
        ```r
        library(gtsummary)
        
        # Set journal theme FIRST
        theme_gtsummary_journal("jama")  # or "lancet", "nejm"
        theme_gtsummary_compact()
        
        # Table 1
        tbl <- df %>%
          tbl_summary(
            by = group,
            type = list(age ~ "continuous2"),
            statistic = list(
              all_continuous() ~ c("{mean} ({sd})", "{median} ({p25}, {p75})"),
              all_categorical() ~ "{n} ({p}%)"
            ),
            digits = list(all_continuous() ~ 1),
            missing = "ifany"
          ) %>%
          add_p() %>%
          add_overall() %>%
          add_stat_label() %>%
          bold_labels() %>%
          modify_footnote(all_stat_cols() ~ "Mean (SD); Median (Q1, Q3); n (%)")
        
        # Export
        tbl %>% as_flex_table() %>% flextable::save_as_docx(path = "table1.docx")
        tbl %>% as_hux_table() %>% huxtable::to_latex() %>% writeLines("table1.tex")
        ```
        
        **Regression table:**
        ```r
        model <- glm(outcome ~ age + sex + bmi, data = df, family = binomial)
        
        tbl_regression(model, exponentiate = TRUE) %>%
          add_global_p() %>%
          bold_p(t = 0.05) %>%
          bold_labels()
        ```
        
        ### Supporting Tools
        
        | Tool | Role | When to Use |
        |---|---|---|
        | gt | Fine-grained styling | Cell-level colors, custom footnote marks, heatmaps |
        | flextable | Word output engine | Final DOCX formatting, autofit, borders |
        | huxtable | LaTeX output engine | LaTeX code extraction |
        | tableone (R/Python) | Quick exploratory Table 1 | Early drafts, data exploration |
        | python-docx | Python Word tables | When pipeline is Python-only |
        | great_tables | Python gt port | Growing but immature for medical use |
        
        ### Python Limitation
        Python has no equivalent to gtsummary's journal themes or automatic statistical test selection. For publication tables, R is strongly recommended. Python-only pipelines should use tableone for generation + python-docx for Word formatting, accepting manual style adjustments.
        
        ---
        
        ## 7. Format-Specific Notes
        
        ### Word (DOCX) Submission
        - Use Word's Insert > Table function (never tab-separated)
        - Font: Times New Roman 10-12pt (table body can be 9-10pt)
        - Single spacing within cells
        - Remove all vertical borders; keep 3 horizontal lines
        - Bold headers, regular body text
        - Each table on a separate page, after References
        
        ### LaTeX Submission
        - Use `booktabs` package (`\toprule`, `\midrule`, `\bottomrule`)
        - Never use `\hline` or `|` vertical separators
        - `\siunitx` for decimal alignment
        - `\multirow` / `\multicolumn` for merged cells
        - Generate via gtsummary → huxtable → to_latex() for consistency
        
        ### HTML (Review/Proofing)
        - gt produces the highest quality HTML tables
        - Useful for co-author review before final Word/LaTeX export
        - Can include interactive features (sorting, filtering) for supplementary materials
        
      • tool-comparison.md 2.7 KB
        # Publication Table Tools — Comparison
        
        ## R Packages
        
        | Feature | gtsummary | gt | flextable | tableone | kableExtra | huxtable |
        |---|---|---|---|---|---|---|
        | **Primary Use** | Table 1 + regression | Fine styling | Word output | Quick Table 1 | LaTeX/PDF | Multi-format |
        | **Auto Statistics** | Yes (test selection) | No | No | Yes (p, SMD) | No | huxreg() only |
        | **Journal Themes** | JAMA, Lancet, NEJM | No | Custom | No | No | No |
        | **Footnotes** | Via gt engine | Native (excellent) | Native | Manual | Native | Native |
        | **Word Output** | Via flextable | Limited | **Best** | Via knitr | Limited | Yes |
        | **LaTeX Output** | Via huxtable | Native | Limited | Via knitr | **Best** | Native |
        | **HTML Output** | Via gt | **Best** | Yes | Via knitr | Yes | Yes |
        | **Maintenance** | Active (MSKCC) | Active (Posit) | Active | Maintenance | Maintenance | Maintenance |
        
        ## Python Libraries
        
        | Feature | great_tables | tableone (py) | python-docx | pandas Styler |
        |---|---|---|---|---|
        | **Primary Use** | gt port | Quick Table 1 | Word tables | HTML/LaTeX |
        | **Auto Statistics** | No | Yes (p, SMD) | No | No |
        | **Journal Themes** | No | No | No | No |
        | **Footnotes** | tab_footnote() | No | Manual | No |
        | **Word Output** | No | No | **Native** | No |
        | **LaTeX Output** | No | Via export | No | to_latex() |
        | **Maturity** | Growing | Stable | Stable | Built-in |
        
        ## Recommended Pipelines
        
        ### Best: R gtsummary → Word
        ```r
        theme_gtsummary_journal("jama")
        theme_gtsummary_compact()
        tbl %>% as_flex_table() %>% flextable::save_as_docx(path = "table.docx")
        ```
        
        ### Best: R gtsummary → LaTeX
        ```r
        tbl %>% as_hux_table() %>% huxtable::to_latex()
        # OR
        tbl %>% as_gt() %>% gt::gtsave("table.tex")
        ```
        
        ### Python-only (acceptable, not ideal)
        ```python
        # Generation
        from tableone import TableOne
        table1 = TableOne(df, columns=cols, categorical=cats, groupby='group', pval=True)
        # Export to CSV, then format with python-docx
        ```
        
        ## Key Insight
        > **Python lacks gtsummary-equivalent journal themes and auto-statistics.**
        > For publication tables in medical journals, R is strongly preferred.
        > Python pipelines require manual style adjustments that R handles automatically.
        
        ## Tool Selection Decision Tree
        
        ```
        Need Table 1 or regression table?
          → gtsummary (+ journal theme)
        
        Need pixel-perfect custom styling?
          → gt (HTML) or flextable (Word)
        
        Submitting to Word-only journal?
          → gtsummary → as_flex_table() → save_as_docx()
        
        Submitting LaTeX?
          → gtsummary → as_hux_table() → to_latex()
          → OR kableExtra for direct LaTeX control
        
        Python-only constraint?
          → tableone (generation) + python-docx (formatting)
          → Accept manual style work
        
        Quick exploratory table?
          → tableone (R or Python) — fastest setup
        ```
        
    • templates
      • agreement_analysis.py 14.3 KB
        """
        Template: Inter-rater Agreement Analysis
        Calculates Cohen's/Fleiss' kappa, ICC, and Krippendorff's alpha with bootstrap CIs.
        Generates Bland-Altman plots for continuous measurements.
        
        Usage:
            Modify the CONFIGURATION section below, then run:
                python agreement_analysis.py
        
        Input:  CSV with rows=items, columns=raters (or long format)
        Output: agreement_table.csv, bland_altman.pdf/.png, summary text
        """
        
        # === REPRODUCIBILITY HEADER ===
        import sys
        import os
        import datetime
        import numpy as np
        import pandas as pd
        from scipy import stats
        
        np.random.seed(42)
        print(f"Date: {datetime.date.today()}")
        print(f"Python: {sys.version}")
        print(f"numpy: {np.__version__}, pandas: {pd.__version__}, scipy: {stats.scipy.__version__}")
        
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        
        STYLE_PATH = os.path.expanduser(
            "~/.claude/skills/analyze-stats/references/style/figure_style.mplstyle"
        )
        if os.path.exists(STYLE_PATH):
            plt.style.use(STYLE_PATH)
        
        print()
        
        # === CONFIGURATION (modify for your study) ===
        INPUT_FILE = "data.csv"           # Path to input data
        OUTPUT_DIR = "."                  # Output directory
        RATER_COLS = []                   # Column names for each rater (wide format)
        DATA_TYPE = "auto"                # "categorical", "ordinal", "continuous", or "auto"
        BOOTSTRAP_N = 1000                # Number of bootstrap iterations for CIs
        ALPHA = 0.05                      # Significance level
        # ==============================================
        
        
        def load_data(filepath: str) -> pd.DataFrame:
            """Load CSV or Excel file."""
            if filepath.endswith((".xlsx", ".xls")):
                return pd.read_excel(filepath)
            return pd.read_csv(filepath)
        
        
        def detect_data_type(df: pd.DataFrame, cols: list) -> str:
            """Auto-detect whether ratings are categorical, ordinal, or continuous."""
            combined = pd.concat([df[c] for c in cols], ignore_index=True).dropna()
            n_unique = combined.nunique()
        
            if combined.dtype == object or combined.dtype.name == "category":
                return "categorical"
            if n_unique <= 10 and all(combined == combined.astype(int)):
                return "ordinal"
            return "continuous"
        
        
        def interpret_kappa(kappa: float) -> str:
            """Interpret kappa using Landis & Koch (1977) guidelines."""
            if kappa < 0:
                return "poor"
            if kappa < 0.20:
                return "slight"
            if kappa < 0.40:
                return "fair"
            if kappa < 0.60:
                return "moderate"
            if kappa < 0.80:
                return "substantial"
            return "almost perfect"
        
        
        def interpret_icc(icc: float) -> str:
            """Interpret ICC using Cicchetti (1994) guidelines."""
            if icc < 0.40:
                return "poor"
            if icc < 0.60:
                return "fair"
            if icc < 0.75:
                return "good"
            return "excellent"
        
        
        def cohens_kappa(r1: np.ndarray, r2: np.ndarray) -> float:
            """Cohen's kappa for 2 raters, categorical data."""
            from sklearn.metrics import cohen_kappa_score
            return cohen_kappa_score(r1, r2)
        
        
        def cohens_weighted_kappa(r1: np.ndarray, r2: np.ndarray,
                                  weights: str = "linear") -> float:
            """Weighted Cohen's kappa for ordinal data."""
            from sklearn.metrics import cohen_kappa_score
            return cohen_kappa_score(r1, r2, weights=weights)
        
        
        def fleiss_kappa(rating_matrix: np.ndarray) -> float:
            """Fleiss' kappa for multiple raters, categorical data.
        
            Args:
                rating_matrix: n_items x n_categories matrix of category counts per item
            """
            n_items, n_categories = rating_matrix.shape
            n_raters = int(rating_matrix.sum(axis=1)[0])
        
            p_j = rating_matrix.sum(axis=0) / (n_items * n_raters)
            P_i = (rating_matrix**2).sum(axis=1) - n_raters
            P_i = P_i / (n_raters * (n_raters - 1))
        
            P_bar = P_i.mean()
            P_e = (p_j**2).sum()
        
            if P_e == 1:
                return 1.0
            return (P_bar - P_e) / (1 - P_e)
        
        
        def compute_icc(df: pd.DataFrame, cols: list,
                        model: str = "two-way", type_: str = "agreement") -> float:
            """Compute Intraclass Correlation Coefficient.
        
            Args:
                model: "one-way", "two-way" (random), or "two-way-mixed"
                type_: "consistency" or "agreement" (only for two-way)
            Returns: ICC value
            """
            ratings = df[cols].values
            n_subjects, n_raters = ratings.shape
        
            grand_mean = ratings.mean()
        
            ss_total = np.sum((ratings - grand_mean) ** 2)
            row_means = ratings.mean(axis=1)
            col_means = ratings.mean(axis=0)
        
            ss_rows = n_raters * np.sum((row_means - grand_mean) ** 2)
            ss_cols = n_subjects * np.sum((col_means - grand_mean) ** 2)
            ss_error = ss_total - ss_rows - ss_cols
        
            ms_rows = ss_rows / (n_subjects - 1)
            ms_cols = ss_cols / (n_raters - 1) if n_raters > 1 else 0
            ms_error = ss_error / ((n_subjects - 1) * (n_raters - 1)) if (n_subjects > 1 and n_raters > 1) else 0
        
            if model == "one-way":
                ms_within = (ss_total - ss_rows) / (n_subjects * (n_raters - 1))
                icc = (ms_rows - ms_within) / (ms_rows + (n_raters - 1) * ms_within)
            elif model in ("two-way", "two-way-mixed"):
                if type_ == "agreement":
                    icc = (ms_rows - ms_error) / (
                        ms_rows + (n_raters - 1) * ms_error +
                        n_raters * (ms_cols - ms_error) / n_subjects
                    )
                else:  # consistency
                    icc = (ms_rows - ms_error) / (ms_rows + (n_raters - 1) * ms_error)
            else:
                raise ValueError(f"Unknown model: {model}")
        
            return icc
        
        
        def bootstrap_ci(data: pd.DataFrame, cols: list, metric_func,
                         n_bootstrap: int = 1000, alpha: float = 0.05,
                         **kwargs) -> tuple:
            """Bootstrap confidence interval for any agreement metric."""
            n = len(data)
            boot_values = []
        
            for _ in range(n_bootstrap):
                idx = np.random.choice(n, size=n, replace=True)
                boot_data = data.iloc[idx]
                try:
                    val = metric_func(boot_data, cols, **kwargs)
                    if np.isfinite(val):
                        boot_values.append(val)
                except Exception:
                    continue
        
            if len(boot_values) < 10:
                return (np.nan, np.nan)
        
            boot_values = np.array(boot_values)
            ci_lo = np.percentile(boot_values, 100 * alpha / 2)
            ci_hi = np.percentile(boot_values, 100 * (1 - alpha / 2))
            return (ci_lo, ci_hi)
        
        
        def bland_altman_plot(r1: np.ndarray, r2: np.ndarray,
                              name1: str, name2: str, output_dir: str) -> dict:
            """Generate Bland-Altman plot for two continuous raters."""
            mean_vals = (r1 + r2) / 2
            diff_vals = r1 - r2
            mean_diff = diff_vals.mean()
            std_diff = diff_vals.std(ddof=1)
        
            loa_upper = mean_diff + 1.96 * std_diff
            loa_lower = mean_diff - 1.96 * std_diff
        
            fig, ax = plt.subplots(figsize=(3.5, 3.5))
            ax.scatter(mean_vals, diff_vals, s=15, alpha=0.6, color="#0072B2", edgecolors="none")
            ax.axhline(mean_diff, color="#D55E00", linewidth=1, label=f"Mean: {mean_diff:.2f}")
            ax.axhline(loa_upper, color="#D55E00", linewidth=0.8, linestyle="--",
                       label=f"+1.96 SD: {loa_upper:.2f}")
            ax.axhline(loa_lower, color="#D55E00", linewidth=0.8, linestyle="--",
                       label=f"-1.96 SD: {loa_lower:.2f}")
            ax.set_xlabel(f"Mean of {name1} and {name2}")
            ax.set_ylabel(f"Difference ({name1} - {name2})")
            ax.legend(fontsize=7, loc="upper right")
        
            fig.tight_layout()
            pdf_path = os.path.join(output_dir, "bland_altman.pdf")
            png_path = os.path.join(output_dir, "bland_altman.png")
            fig.savefig(pdf_path, format="pdf", bbox_inches="tight")
            fig.savefig(png_path, format="png", dpi=300, bbox_inches="tight")
            plt.close(fig)
            print(f"Saved: {pdf_path}")
            print(f"Saved: {png_path}")
        
            return {
                "mean_diff": mean_diff,
                "sd_diff": std_diff,
                "loa_lower": loa_lower,
                "loa_upper": loa_upper,
            }
        
        
        def _icc_for_bootstrap(data, cols, model="two-way", type_="agreement"):
            """Wrapper for ICC to use with bootstrap_ci."""
            return compute_icc(data, cols, model=model, type_=type_)
        
        
        def _kappa_for_bootstrap_2raters(data, cols):
            """Wrapper for Cohen's kappa to use with bootstrap_ci."""
            r1 = data[cols[0]].values
            r2 = data[cols[1]].values
            mask = ~(pd.isna(r1) | pd.isna(r2))
            if mask.sum() < 2:
                return np.nan
            return cohens_kappa(r1[mask], r2[mask])
        
        
        def analyze_categorical(df: pd.DataFrame, cols: list, output_dir: str) -> list:
            """Run agreement analysis for categorical data."""
            results = []
            n_raters = len(cols)
            n_items = len(df)
        
            if n_raters == 2:
                r1 = df[cols[0]].values
                r2 = df[cols[1]].values
                mask = ~(pd.isna(r1) | pd.isna(r2))
                r1_clean, r2_clean = r1[mask], r2[mask]
        
                kappa = cohens_kappa(r1_clean, r2_clean)
                ci_lo, ci_hi = bootstrap_ci(df, cols, _kappa_for_bootstrap_2raters,
                                             n_bootstrap=BOOTSTRAP_N, alpha=ALPHA)
        
                results.append({
                    "Metric": "Cohen's kappa",
                    "Value": f"{kappa:.3f}",
                    "95% CI": f"({ci_lo:.3f}-{ci_hi:.3f})",
                    "Interpretation": interpret_kappa(kappa),
                    "n_items": int(mask.sum()),
                    "n_raters": 2,
                })
        
                pct_agree = np.mean(r1_clean == r2_clean)
                results.append({
                    "Metric": "Percent agreement",
                    "Value": f"{pct_agree:.3f}",
                    "95% CI": "",
                    "Interpretation": "",
                    "n_items": int(mask.sum()),
                    "n_raters": 2,
                })
            else:
                categories = sorted(set(pd.concat([df[c] for c in cols]).dropna().unique()))
                rating_counts = np.zeros((n_items, len(categories)))
                for i in range(n_items):
                    for c in cols:
                        val = df[c].iloc[i]
                        if pd.notna(val) and val in categories:
                            rating_counts[i, categories.index(val)] += 1
        
                fk = fleiss_kappa(rating_counts)
                results.append({
                    "Metric": "Fleiss' kappa",
                    "Value": f"{fk:.3f}",
                    "95% CI": "",
                    "Interpretation": interpret_kappa(fk),
                    "n_items": n_items,
                    "n_raters": n_raters,
                })
        
            return results
        
        
        def analyze_continuous(df: pd.DataFrame, cols: list, output_dir: str) -> list:
            """Run agreement analysis for continuous data."""
            results = []
            n_raters = len(cols)
        
            for type_ in ["agreement", "consistency"]:
                icc = compute_icc(df, cols, model="two-way", type_=type_)
                ci_lo, ci_hi = bootstrap_ci(df, cols, _icc_for_bootstrap,
                                             n_bootstrap=BOOTSTRAP_N, alpha=ALPHA,
                                             model="two-way", type_=type_)
                label = f"ICC (two-way, {type_})"
                results.append({
                    "Metric": label,
                    "Value": f"{icc:.3f}",
                    "95% CI": f"({ci_lo:.3f}-{ci_hi:.3f})",
                    "Interpretation": interpret_icc(icc),
                    "n_items": len(df),
                    "n_raters": n_raters,
                })
        
            if n_raters == 2:
                r1 = df[cols[0]].values.astype(float)
                r2 = df[cols[1]].values.astype(float)
                mask = ~(np.isnan(r1) | np.isnan(r2))
                ba = bland_altman_plot(r1[mask], r2[mask], cols[0], cols[1], output_dir)
                results.append({
                    "Metric": "Bland-Altman mean diff",
                    "Value": f"{ba['mean_diff']:.3f}",
                    "95% CI": f"LoA: ({ba['loa_lower']:.3f}-{ba['loa_upper']:.3f})",
                    "Interpretation": "",
                    "n_items": int(mask.sum()),
                    "n_raters": 2,
                })
        
            return results
        
        
        def analyze_ordinal(df: pd.DataFrame, cols: list, output_dir: str) -> list:
            """Run agreement analysis for ordinal data (weighted kappa + ICC)."""
            results = []
        
            if len(cols) == 2:
                r1 = df[cols[0]].values
                r2 = df[cols[1]].values
                mask = ~(pd.isna(r1) | pd.isna(r2))
                r1_clean, r2_clean = r1[mask], r2[mask]
        
                for weight in ["linear", "quadratic"]:
                    wk = cohens_weighted_kappa(r1_clean, r2_clean, weights=weight)
                    results.append({
                        "Metric": f"Weighted kappa ({weight})",
                        "Value": f"{wk:.3f}",
                        "95% CI": "",
                        "Interpretation": interpret_kappa(wk),
                        "n_items": int(mask.sum()),
                        "n_raters": 2,
                    })
        
            results.extend(analyze_continuous(df, cols, output_dir))
            return results
        
        
        def save_results(results: list, output_dir: str) -> None:
            """Save agreement metrics as CSV and print formatted output."""
            df = pd.DataFrame(results)
            csv_path = os.path.join(output_dir, "agreement_table.csv")
            df.to_csv(csv_path, index=False)
            print(f"\nSaved: {csv_path}")
            print("\n--- Agreement Analysis Results ---\n")
            print(df.to_markdown(index=False))
        
        
        def print_results_text(results: list, data_type: str) -> None:
            """Print manuscript-ready text for Results section."""
            print("\n--- Results Text (copy-paste ready) ---\n")
        
            for r in results:
                metric = r["Metric"]
                value = r["Value"]
                ci = r.get("95% CI", "")
                interp = r.get("Interpretation", "")
                n_items = r.get("n_items", "")
                n_raters = r.get("n_raters", "")
        
                text = f"{metric} was {value}"
                if ci:
                    text += f" (95% CI: {ci})"
                if interp:
                    text += f", indicating {interp} agreement"
                text += f" (n = {n_items} items, {n_raters} raters)."
                print(text)
            print()
        
        
        # === MAIN ===
        if __name__ == "__main__":
            print("=" * 60)
            print("Inter-rater Agreement Analysis")
            print("=" * 60)
        
            df = load_data(INPUT_FILE)
            print(f"\nLoaded: {INPUT_FILE} ({df.shape[0]} rows, {df.shape[1]} columns)")
        
            if not RATER_COLS:
                RATER_COLS = [c for c in df.columns if c.lower().startswith("rater")]
                if not RATER_COLS:
                    RATER_COLS = list(df.columns)
                print(f"Using rater columns: {RATER_COLS}")
        
            if DATA_TYPE == "auto":
                DATA_TYPE = detect_data_type(df, RATER_COLS)
            print(f"Data type: {DATA_TYPE}")
            print(f"Items: {len(df)}, Raters: {len(RATER_COLS)}")
        
            for col in RATER_COLS:
                n_miss = df[col].isna().sum()
                if n_miss > 0:
                    print(f"  Missing in {col}: {n_miss} ({100*n_miss/len(df):.1f}%)")
        
            if DATA_TYPE == "categorical":
                results = analyze_categorical(df, RATER_COLS, OUTPUT_DIR)
            elif DATA_TYPE == "ordinal":
                results = analyze_ordinal(df, RATER_COLS, OUTPUT_DIR)
            elif DATA_TYPE == "continuous":
                results = analyze_continuous(df, RATER_COLS, OUTPUT_DIR)
            else:
                raise ValueError(f"Unknown data type: {DATA_TYPE}")
        
            save_results(results, OUTPUT_DIR)
            print_results_text(results, DATA_TYPE)
        
      • dca_plot.R 10.5 KB · in bundle
      • diagnostic_accuracy.py 14.6 KB
        """
        Template: Diagnostic Accuracy Analysis
        Calculates sensitivity, specificity, PPV, NPV, accuracy, AUC with 95% CIs.
        Generates ROC curve and optional model comparison.
        
        Usage:
            Modify the CONFIGURATION section below, then run:
                python diagnostic_accuracy.py
        
        Input:  CSV with ground truth and predicted scores/labels
        Output: diagnostic_accuracy_table.csv, roc_curve.pdf/.png, summary text
        """
        
        # === REPRODUCIBILITY HEADER ===
        import sys
        import os
        import datetime
        import numpy as np
        import pandas as pd
        import scipy
        from scipy import stats
        
        import sklearn
        
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        
        STYLE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "style", "figure_style.mplstyle")
        if os.path.exists(STYLE_PATH):
            plt.style.use(STYLE_PATH)
        
        # === CONFIGURATION (modify for your study) ===
        INPUT_FILE = "data.csv"           # Path to input data
        OUTPUT_DIR = "."                  # Output directory
        TRUTH_COL = "ground_truth"       # Column: binary ground truth (0/1)
        SCORE_COLS = ["model_score"]     # Column(s): predicted probability/score (for ROC)
        PRED_COLS = ["model_pred"]       # Column(s): binary predictions (0/1) at chosen threshold
        MODEL_NAMES = ["Model"]          # Display names for each model
        THRESHOLD = None                  # Fixed threshold (None = use Youden's optimal)
        COMPARE_MODELS = False            # True to run DeLong test between models
        POSITIVE_LABEL = 1                # Value representing positive class
        # ==============================================
        
        
        def wilson_ci(p: float, n: int, alpha: float = 0.05) -> tuple:
            """Wilson score confidence interval for a proportion."""
            if n == 0:
                return (np.nan, np.nan)  # No denominator: undefined, not zero performance.
            z = stats.norm.ppf(1 - alpha / 2)
            denominator = 1 + z**2 / n
            center = (p + z**2 / (2 * n)) / denominator
            spread = z * np.sqrt((p * (1 - p) + z**2 / (4 * n)) / n) / denominator
            return (max(0.0, center - spread), min(1.0, center + spread))
        
        
        def delong_auc_variance(y_true: np.ndarray, y_score: np.ndarray) -> float:
            """Estimate AUC variance using the DeLong method."""
            pos = y_score[y_true == 1]
            neg = y_score[y_true == 0]
            m = len(pos)
            n = len(neg)
        
            v_pos = np.array([np.mean(neg < p) + 0.5 * np.mean(neg == p) for p in pos])
            v_neg = np.array([np.mean(pos > nv) + 0.5 * np.mean(pos == nv) for nv in neg])
        
            var_auc = (np.var(v_pos, ddof=1) / m) + (np.var(v_neg, ddof=1) / n)
            return var_auc
        
        
        def delong_ci(y_true: np.ndarray, y_score: np.ndarray,
                      alpha: float = 0.05) -> tuple:
            """AUC with DeLong 95% CI."""
            from sklearn.metrics import roc_auc_score
        
            auc = roc_auc_score(y_true, y_score)
            var = delong_auc_variance(y_true, y_score)
            se = np.sqrt(var)
            z = stats.norm.ppf(1 - alpha / 2)
            ci_low = max(0.0, auc - z * se)
            ci_high = min(1.0, auc + z * se)
            return auc, ci_low, ci_high
        
        
        def delong_test(y_true: np.ndarray, y_score1: np.ndarray,
                        y_score2: np.ndarray) -> tuple:
            """DeLong test for comparing two AUCs on the same dataset."""
            from sklearn.metrics import roc_auc_score
        
            auc1 = roc_auc_score(y_true, y_score1)
            auc2 = roc_auc_score(y_true, y_score2)
        
            var1 = delong_auc_variance(y_true, y_score1)
            var2 = delong_auc_variance(y_true, y_score2)
        
            pos_mask = y_true == 1
            neg_mask = y_true == 0
            m = pos_mask.sum()
            n = neg_mask.sum()
        
            v1_pos = np.array([np.mean(y_score1[neg_mask] < p) +
                                0.5 * np.mean(y_score1[neg_mask] == p) for p in y_score1[pos_mask]])
            v2_pos = np.array([np.mean(y_score2[neg_mask] < p) +
                                0.5 * np.mean(y_score2[neg_mask] == p) for p in y_score2[pos_mask]])
            v1_neg = np.array([np.mean(y_score1[pos_mask] > nv) +
                                0.5 * np.mean(y_score1[pos_mask] == nv) for nv in y_score1[neg_mask]])
            v2_neg = np.array([np.mean(y_score2[pos_mask] > nv) +
                                0.5 * np.mean(y_score2[pos_mask] == nv) for nv in y_score2[neg_mask]])
        
            cov = np.cov(v1_pos, v2_pos)[0, 1] / m + np.cov(v1_neg, v2_neg)[0, 1] / n
        
            z = (auc1 - auc2) / np.sqrt(var1 + var2 - 2 * cov)
            p = 2 * stats.norm.sf(abs(z))
            return z, p
        
        
        def youdens_threshold(y_true: np.ndarray, y_score: np.ndarray) -> float:
            """Find optimal threshold using Youden's J statistic."""
            from sklearn.metrics import roc_curve
        
            fpr, tpr, thresholds = roc_curve(y_true, y_score)
            j = tpr - fpr
            optimal_idx = np.argmax(j)
            return thresholds[optimal_idx]
        
        
        def compute_metrics(y_true: np.ndarray, y_pred: np.ndarray,
                            y_score: np.ndarray = None) -> dict:
            """Compute diagnostic accuracy metrics with Wilson CIs."""
            tp = np.sum((y_pred == 1) & (y_true == 1))
            fp = np.sum((y_pred == 1) & (y_true == 0))
            tn = np.sum((y_pred == 0) & (y_true == 0))
            fn = np.sum((y_pred == 0) & (y_true == 1))
            n = len(y_true)
        
            sens = tp / (tp + fn) if (tp + fn) > 0 else np.nan
            spec = tn / (tn + fp) if (tn + fp) > 0 else np.nan
            ppv = tp / (tp + fp) if (tp + fp) > 0 else np.nan
            npv = tn / (tn + fn) if (tn + fn) > 0 else np.nan
            acc = (tp + tn) / n if n > 0 else np.nan
        
            metrics = {
                "Sensitivity": (sens, *wilson_ci(sens, tp + fn)),
                "Specificity": (spec, *wilson_ci(spec, tn + fp)),
                "PPV": (ppv, *wilson_ci(ppv, tp + fp)),
                "NPV": (npv, *wilson_ci(npv, tn + fn)),
                "Accuracy": (acc, *wilson_ci(acc, n)),
            }
        
            if y_score is not None:
                auc, auc_lo, auc_hi = delong_ci(y_true, y_score)
                metrics["AUC"] = (auc, auc_lo, auc_hi)
        
            metrics["_counts"] = {"TP": int(tp), "FP": int(fp), "TN": int(tn), "FN": int(fn)}
            return metrics
        
        
        def plot_roc(y_true: np.ndarray, score_dict: dict, output_dir: str) -> None:
            """Generate ROC curve figure with AUC in legend."""
            from sklearn.metrics import roc_curve
        
            fig, ax = plt.subplots(figsize=(3.5, 3.5))
            colors = ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#F0E442"]
        
            for i, (name, y_score) in enumerate(score_dict.items()):
                fpr, tpr, _ = roc_curve(y_true, y_score)
                auc, ci_lo, ci_hi = delong_ci(y_true, y_score)
                label = f"{name}: AUC = {auc:.3f} ({ci_lo:.3f}-{ci_hi:.3f})"
                ax.plot(fpr, tpr, color=colors[i % len(colors)], linewidth=1.5, label=label)
        
            ax.plot([0, 1], [0, 1], color="gray", linestyle="--", linewidth=0.8)
            ax.set_xlabel("1 - Specificity (FPR)")
            ax.set_ylabel("Sensitivity (TPR)")
            ax.set_xlim([-0.02, 1.02])
            ax.set_ylim([-0.02, 1.02])
            ax.set_aspect("equal")
            ax.legend(loc="lower right", fontsize=7)
        
            fig.tight_layout()
            pdf_path = os.path.join(output_dir, "roc_curve.pdf")
            png_path = os.path.join(output_dir, "roc_curve.png")
            fig.savefig(pdf_path, format="pdf", bbox_inches="tight")
            fig.savefig(png_path, format="png", dpi=300, bbox_inches="tight")
            plt.close(fig)
            print(f"Saved: {pdf_path}")
            print(f"Saved: {png_path}")
        
        
        def plot_confusion_matrix(y_true: np.ndarray, pred_dict: dict,
                                  model_names: list, output_dir: str) -> None:
            """Generate side-by-side confusion matrices using matplotlib."""
            n_models = len(pred_dict)
            fig, axes = plt.subplots(1, n_models, figsize=(3.5 * n_models, 3.5))
            if n_models == 1:
                axes = [axes]
        
            for ax, (name, y_pred) in zip(axes, pred_dict.items()):
                from sklearn.metrics import confusion_matrix as cm_func
                cm = cm_func(y_true, y_pred, labels=[0, 1])
                cm_pct = cm.astype(float) / cm.sum() * 100
        
                im = ax.imshow(cm, interpolation="nearest", cmap=plt.cm.Blues)
                ax.set_title(name, fontsize=10)
                ax.set_xlabel("Predicted")
                ax.set_ylabel("Actual")
                ax.set_xticks([0, 1])
                ax.set_yticks([0, 1])
                ax.set_xticklabels(["Neg", "Pos"])
                ax.set_yticklabels(["Neg", "Pos"])
        
                # Annotate cells with count and percentage
                thresh = cm.max() / 2.0
                for i in range(2):
                    for j in range(2):
                        ax.text(j, i, f"{cm[i, j]}\n({cm_pct[i, j]:.1f}%)",
                                ha="center", va="center", fontsize=9,
                                color="white" if cm[i, j] > thresh else "black")
        
            fig.tight_layout()
            pdf_path = os.path.join(output_dir, "confusion_matrix.pdf")
            png_path = os.path.join(output_dir, "confusion_matrix.png")
            fig.savefig(pdf_path, format="pdf", bbox_inches="tight",
                        metadata={"CreationDate": None, "ModDate": None})
            fig.savefig(png_path, format="png", dpi=300, bbox_inches="tight")
            plt.close(fig)
            print(f"Saved: {pdf_path}")
            print(f"Saved: {png_path}")
        
        
        def plot_calibration(y_true: np.ndarray, score_dict: dict,
                             output_dir: str) -> None:
            """Generate calibration curves with Brier scores."""
            from sklearn.calibration import calibration_curve
            from sklearn.metrics import brier_score_loss
        
            fig, ax = plt.subplots(figsize=(3.5, 3.5))
            colors = ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#F0E442"]
        
            ax.plot([0, 1], [0, 1], color="gray", linestyle="--", linewidth=0.8,
                    label="Perfect calibration")
        
            for i, (name, y_score) in enumerate(score_dict.items()):
                brier = brier_score_loss(y_true, y_score)
                fraction_pos, mean_predicted = calibration_curve(
                    y_true, y_score, n_bins=10, strategy="uniform"
                )
                ax.plot(mean_predicted, fraction_pos, marker="o", markersize=4,
                        color=colors[i % len(colors)], linewidth=1.5,
                        label=f"{name} (Brier = {brier:.3f})")
        
            ax.set_xlabel("Mean predicted probability")
            ax.set_ylabel("Fraction of positives")
            ax.set_xlim([-0.02, 1.02])
            ax.set_ylim([-0.02, 1.02])
            ax.legend(loc="lower right", fontsize=7)
        
            fig.tight_layout()
            pdf_path = os.path.join(output_dir, "calibration_plot.pdf")
            png_path = os.path.join(output_dir, "calibration_plot.png")
            fig.savefig(pdf_path, format="pdf", bbox_inches="tight")
            fig.savefig(png_path, format="png", dpi=300, bbox_inches="tight")
            plt.close(fig)
            print(f"Saved: {pdf_path}")
            print(f"Saved: {png_path}")
        
        
        def save_performance_table(results: dict, output_dir: str) -> None:
            """Save performance metrics as CSV and print markdown."""
            rows = []
            for model_name, metrics in results.items():
                row = {"Model": model_name}
                for metric_name, vals in metrics.items():
                    if metric_name.startswith("_"):
                        continue
                    val, ci_lo, ci_hi = vals
                    row[metric_name] = f"{val:.3f} ({ci_lo:.3f}-{ci_hi:.3f})"
                counts = metrics.get("_counts", {})
                for k, v in counts.items():
                    row[k] = v
                rows.append(row)
        
            df = pd.DataFrame(rows)
            csv_path = os.path.join(output_dir, "diagnostic_accuracy_table.csv")
            df.to_csv(csv_path, index=False)
            print(f"\nSaved: {csv_path}")
            print("\n--- Diagnostic Accuracy ---\n")
            print(df.to_markdown(index=False))
        
        
        def print_results_text(results: dict) -> None:
            """Print manuscript-ready results text."""
            print("\n--- Results Text (copy-paste ready) ---\n")
            for model_name, metrics in results.items():
                parts = []
                for metric_name in ["AUC", "Sensitivity", "Specificity", "PPV", "NPV", "Accuracy"]:
                    if metric_name in metrics:
                        val, ci_lo, ci_hi = metrics[metric_name]
                        parts.append(f"{metric_name} of {val:.3f} (95% CI: {ci_lo:.3f}-{ci_hi:.3f})")
        
                counts = metrics.get("_counts", {})
                n = sum(counts.values())
                n_pos = counts.get("TP", 0) + counts.get("FN", 0)
                n_neg = counts.get("TN", 0) + counts.get("FP", 0)
        
                print(f"{model_name} was evaluated on {n} cases "
                      f"({n_pos} positive, {n_neg} negative). "
                      f"The model achieved {', '.join(parts[:-1])}, and {parts[-1]}.")
                print()
        
        
        # === MAIN ===
        if __name__ == "__main__":
            np.random.seed(42)
            print(f"Date: {datetime.date.today()}")
            print(f"Python: {sys.version}")
            print(f"numpy: {np.__version__}, pandas: {pd.__version__}, scipy: {scipy.__version__}")
            print(f"sklearn: {sklearn.__version__}")
            print("=" * 60)
            print("Diagnostic Accuracy Analysis")
            print("=" * 60)
        
            df = pd.read_csv(INPUT_FILE)
            print(f"\nLoaded: {INPUT_FILE} ({df.shape[0]} rows, {df.shape[1]} columns)")
        
            y_true = df[TRUTH_COL].values
        
            # Prevalence
            prev = y_true.mean()
            print(f"Prevalence: {int(y_true.sum())}/{len(y_true)} ({100*prev:.1f}%)")
        
            all_results = {}
            score_dict = {}
            pred_dict = {}
        
            for i, (score_col, pred_col, name) in enumerate(
                zip(SCORE_COLS, PRED_COLS, MODEL_NAMES)
            ):
                print(f"\n--- {name} ---")
                y_score = df[score_col].values if score_col in df.columns else None
                if y_score is not None:
                    score_dict[name] = y_score
        
                # Determine threshold
                if THRESHOLD is not None:
                    thresh = THRESHOLD
                elif y_score is not None:
                    thresh = youdens_threshold(y_true, y_score)
                    print(f"Youden's optimal threshold: {thresh:.4f}")
                    print(f"  WARNING: Youden's threshold optimized on evaluation data.")
                    print(f"  For publication, use cross-validated thresholds or pre-specified cutoffs.")
                else:
                    thresh = 0.5
        
                # Get predictions
                if pred_col in df.columns:
                    y_pred = df[pred_col].values
                elif y_score is not None:
                    y_pred = (y_score >= thresh).astype(int)
                else:
                    raise ValueError(f"Neither prediction column '{pred_col}' nor "
                                     f"score column '{score_col}' found.")
        
                pred_dict[name] = y_pred
                metrics = compute_metrics(y_true, y_pred, y_score)
                all_results[name] = metrics
        
            # ROC curve
            if score_dict:
                plot_roc(y_true, score_dict, OUTPUT_DIR)
        
            # Confusion matrix
            if pred_dict:
                plot_confusion_matrix(y_true, pred_dict, MODEL_NAMES, OUTPUT_DIR)
        
            # Calibration plot
            if score_dict:
                plot_calibration(y_true, score_dict, OUTPUT_DIR)
        
            # Model comparison (DeLong test)
            if COMPARE_MODELS and len(SCORE_COLS) >= 2:
                print("\n--- Model Comparison (DeLong Test) ---\n")
                for i in range(len(SCORE_COLS)):
                    for j in range(i + 1, len(SCORE_COLS)):
                        s1 = df[SCORE_COLS[i]].values
                        s2 = df[SCORE_COLS[j]].values
                        z, p = delong_test(y_true, s1, s2)
                        from sklearn.metrics import roc_auc_score
                        auc1 = roc_auc_score(y_true, s1)
                        auc2 = roc_auc_score(y_true, s2)
                        print(f"{MODEL_NAMES[i]} (AUC={auc1:.3f}) vs "
                              f"{MODEL_NAMES[j]} (AUC={auc2:.3f}): "
                              f"z = {z:.3f}, p = {p:.3f}")
        
            # Save outputs
            save_performance_table(all_results, OUTPUT_DIR)
            print_results_text(all_results)
        
      • dta_meta_analysis.R 19.2 KB · in bundle
      • forest_plot.py 17.8 KB
        """
        forest_plot.py — Publication-ready Forest Plot for Meta-analysis
        ================================================================
        Generates a forest plot from a CSV file of study-level effect sizes.
        
        Input CSV columns (required):
            study_label     : str  — Study author + year (e.g., "Kim 2022")
            effect_size     : float — Effect estimate (OR, RR, HR, MD, SMD, AUC)
            ci_lower        : float — Lower bound of 95% CI
            ci_upper        : float — Upper bound of 95% CI
        
        Input CSV columns (optional):
            n_total         : int   — Total sample size (shown in table)
            weight          : float — Study weight % (determines box size)
            subgroup        : str   — Subgroup label (adds subgroup header rows)
            events_treat    : int   — Events in treatment/index group
            events_control  : int   — Events in control/comparator group
        
        Pooled estimate (required as separate dict or CSV row with label="Pooled"):
            pooled_effect   : float
            pooled_ci_lower : float
            pooled_ci_upper : float
            i_squared       : float — I² heterogeneity (%)
            tau_squared     : float — τ² between-study variance
            q_p_value       : float — Cochran Q test p-value
        
        Usage:
            python forest_plot.py --input studies.csv --pooled 0.82 0.65 1.04 \
                --i2 45.3 --tau2 0.021 --q-p 0.08 \
                --effect-label "OR" --null-value 1.0 \
                --output forest_plot --favor-left "Favors Treatment" --favor-right "Favors Control"
        
        Outputs:
            forest_plot.pdf  — Vector format (submission quality)
            forest_plot.png  — Raster 300 DPI (web/preview)
        """
        
        import argparse
        import sys
        import numpy as np
        import pandas as pd
        import matplotlib.pyplot as plt
        import matplotlib.patches as patches
        from matplotlib.lines import Line2D
        from datetime import datetime
        
        # ── Reproducibility ───────────────────────────────────────────────────────────
        SCRIPT_VERSION = "1.0.0"
        SEED = 42
        np.random.seed(SEED)
        
        # ── Wong (2011) colorblind-safe colors ────────────────────────────────────────
        COLORS = {
            "study_ci": "#333333",      # dark gray for CI lines
            "study_box": "#0072B2",     # blue for individual study boxes
            "pooled_diamond": "#D55E00",# vermillion for pooled diamond
            "subgroup_header": "#E8E8E8",
            "grid": "#DDDDDD",
            "null_line": "#888888",
        }
        
        # ── Figure style ──────────────────────────────────────────────────────────────
        plt.rcParams.update({
            "font.family": "Arial",
            "font.size": 9,
            "axes.labelsize": 9,
            "xtick.labelsize": 8,
            "ytick.labelsize": 8,
            "figure.dpi": 150,
        })
        
        
        def load_data(filepath: str) -> pd.DataFrame:
            """Load study data from CSV."""
            df = pd.read_csv(filepath)
            required = ["study_label", "effect_size", "ci_lower", "ci_upper"]
            missing = [c for c in required if c not in df.columns]
            if missing:
                raise ValueError(f"Missing required columns: {missing}")
            return df
        
        
        def compute_box_size(weights: pd.Series, min_size=0.08, max_size=0.35) -> np.ndarray:
            """Scale box heights proportional to study weight."""
            if weights is None or weights.isna().all():
                return np.full(len(weights), (min_size + max_size) / 2)
            w = weights.fillna(weights.mean())
            w_norm = (w - w.min()) / (w.max() - w.min() + 1e-9)
            return min_size + w_norm * (max_size - min_size)
        
        
        def make_forest_plot(
            df: pd.DataFrame,
            pooled_effect: float,
            pooled_ci_lower: float,
            pooled_ci_upper: float,
            i_squared: float,
            tau_squared: float,
            q_p_value: float,
            effect_label: str = "OR",
            null_value: float = 1.0,
            log_scale: bool = True,
            favor_left: str = "Favors Treatment",
            favor_right: str = "Favors Control",
            output_path: str = "forest_plot",
        ) -> None:
            """Generate and save the forest plot."""
        
            n_studies = len(df)
            has_subgroups = "subgroup" in df.columns and df["subgroup"].notna().any()
            has_weights = "weight" in df.columns
        
            # ── Layout calculations ───────────────────────────────────────────────────
            row_height = 0.45  # inches per row
            header_height = 1.2
            footer_height = 0.9
            n_rows = n_studies + (1 if has_subgroups else 0) + 2  # +subgroup headers + pooled + blank
            fig_height = max(4.5, header_height + n_rows * row_height + footer_height)
            fig_width = 7.0  # double column
        
            fig, ax = plt.subplots(figsize=(fig_width, fig_height))
            ax.set_axis_off()
        
            # ── Column positions (normalized 0–1 in figure space) ────────────────────
            col_label = 0.02
            col_n = 0.38
            col_events = 0.44
            col_weight = 0.51
            col_plot_left = 0.57
            col_plot_right = 0.87
            col_effect = 0.89
        
            # ── All effect values for x-axis range ───────────────────────────────────
            all_effects = list(df["effect_size"]) + [pooled_effect,
                                                      pooled_ci_lower, pooled_ci_upper]
            if log_scale:
                all_log = [np.log(x) for x in all_effects if x > 0]
                x_min_log = min(all_log) - 0.5
                x_max_log = max(all_log) + 0.5
            else:
                x_min_log = min(all_effects) - abs(min(all_effects)) * 0.2
                x_max_log = max(all_effects) + abs(max(all_effects)) * 0.2
        
            def to_plot_x(value):
                """Map effect value to normalized plot x position."""
                if log_scale and value > 0:
                    log_val = np.log(value)
                else:
                    log_val = value
                frac = (log_val - x_min_log) / (x_max_log - x_min_log)
                return col_plot_left + frac * (col_plot_right - col_plot_left)
        
            # ── Headers ───────────────────────────────────────────────────────────────
            fig.text(col_label, 0.96, "Study", fontsize=9, fontweight="bold",
                     transform=fig.transFigure, va="top")
            if "n_total" in df.columns:
                fig.text(col_n, 0.96, "N", fontsize=9, fontweight="bold",
                         transform=fig.transFigure, va="top", ha="center")
            if has_weights:
                fig.text(col_weight, 0.96, "Weight\n(%)", fontsize=8, fontweight="bold",
                         transform=fig.transFigure, va="top", ha="center")
            fig.text((col_plot_left + col_plot_right) / 2, 0.96,
                     f"{effect_label} (95% CI)", fontsize=9, fontweight="bold",
                     transform=fig.transFigure, va="top", ha="center")
        
            # ── Y positions for each row ───────────────────────────────────────────────
            y_start = 0.90
            y_step = row_height / fig_height
            box_sizes = compute_box_size(df.get("weight"))
        
            current_subgroup = None
            y = y_start
        
            rows = []
            if has_subgroups:
                for sg, group in df.groupby("subgroup", sort=False):
                    rows.append(("subgroup_header", sg, None))
                    for _, row in group.iterrows():
                        rows.append(("study", row, None))
            else:
                for _, row in df.iterrows():
                    rows.append(("study", row, None))
        
            rows.append(("blank", None, None))
            rows.append(("pooled", None, None))
        
            box_idx = 0
            for row_type, data, _ in rows:
                y -= y_step
        
                if row_type == "subgroup_header":
                    fig.text(col_label, y, data, fontsize=9, fontweight="bold",
                             transform=fig.transFigure, va="center",
                             color="#333333", style="italic")
        
                elif row_type == "study":
                    row = data
                    # Study label
                    fig.text(col_label, y, row["study_label"], fontsize=8,
                             transform=fig.transFigure, va="center")
                    # N
                    if "n_total" in row.index and pd.notna(row["n_total"]):
                        fig.text(col_n, y, f"{int(row['n_total']):,}", fontsize=8,
                                 transform=fig.transFigure, va="center", ha="center")
                    # Weight
                    if "weight" in row.index and pd.notna(row.get("weight")):
                        fig.text(col_weight, y, f"{row['weight']:.1f}", fontsize=8,
                                 transform=fig.transFigure, va="center", ha="center")
                    # CI line
                    x_lo = to_plot_x(row["ci_lower"])
                    x_hi = to_plot_x(row["ci_upper"])
                    x_mid = to_plot_x(row["effect_size"])
                    bh = box_sizes[box_idx] * y_step
        
                    # Draw CI line
                    fig.add_artist(Line2D([x_lo, x_hi], [y, y],
                                           transform=fig.transFigure,
                                           color=COLORS["study_ci"], linewidth=0.8, zorder=2))
                    # Draw study box
                    rect = patches.FancyBboxPatch(
                        (x_mid - bh * 0.5 * (fig_height / fig_width), y - bh * 0.5),
                        bh * (fig_height / fig_width), bh,
                        boxstyle="square,pad=0",
                        transform=fig.transFigure,
                        facecolor=COLORS["study_box"],
                        edgecolor=COLORS["study_box"],
                        zorder=3,
                    )
                    fig.add_artist(rect)
        
                    # Effect value text
                    val_str = f"{row['effect_size']:.2f} ({row['ci_lower']:.2f}–{row['ci_upper']:.2f})"
                    fig.text(col_effect, y, val_str, fontsize=7.5,
                             transform=fig.transFigure, va="center")
        
                    box_idx += 1
        
                elif row_type == "pooled":
                    # Separator line
                    y_sep = y + y_step * 0.3
                    ax.axhline(y=0, xmin=col_label, xmax=0.98, color="#888888",
                                linewidth=0.5, transform=fig.transFigure, zorder=1)
        
                    # Diamond
                    x_lo = to_plot_x(pooled_ci_lower)
                    x_hi = to_plot_x(pooled_ci_upper)
                    x_mid = to_plot_x(pooled_effect)
                    diamond_h = y_step * 0.4
                    diamond = plt.Polygon(
                        [[x_lo, y], [x_mid, y + diamond_h / 2],
                         [x_hi, y], [x_mid, y - diamond_h / 2]],
                        transform=fig.transFigure,
                        facecolor=COLORS["pooled_diamond"],
                        edgecolor=COLORS["pooled_diamond"],
                        zorder=4,
                    )
                    fig.add_artist(diamond)
        
                    # Pooled estimate text
                    val_str = f"{pooled_effect:.2f} ({pooled_ci_lower:.2f}–{pooled_ci_upper:.2f})"
                    fig.text(col_label, y, "Pooled estimate", fontsize=8, fontweight="bold",
                             transform=fig.transFigure, va="center")
                    fig.text(col_effect, y, val_str, fontsize=7.5, fontweight="bold",
                             transform=fig.transFigure, va="center")
        
            # ── Null line ─────────────────────────────────────────────────────────────
            x_null = to_plot_x(null_value)
            fig.add_artist(Line2D([x_null, x_null], [y - y_step, y_start + y_step],
                                   transform=fig.transFigure,
                                   color=COLORS["null_line"], linewidth=0.8,
                                   linestyle="--", zorder=1))
        
            # ── X-axis ticks ─────────────────────────────────────────────────────────
            if log_scale:
                tick_values_raw = [0.25, 0.5, 1.0, 2.0, 4.0]
            else:
                span = x_max_log - x_min_log
                tick_values_raw = np.linspace(x_min_log, x_max_log, 5)
        
            y_axis = y - y_step * 1.2
            for tv in tick_values_raw:
                try:
                    tx = to_plot_x(tv)
                except Exception:
                    continue
                if col_plot_left <= tx <= col_plot_right:
                    fig.text(tx, y_axis, f"{tv}", fontsize=7.5,
                             transform=fig.transFigure, va="top", ha="center")
                    fig.add_artist(Line2D([tx, tx], [y_axis + 0.01, y - y_step * 0.8],
                                           transform=fig.transFigure,
                                           color="#888888", linewidth=0.5))
        
            # ── Favor labels ─────────────────────────────────────────────────────────
            y_favor = y_axis - 0.025
            fig.text((col_plot_left + x_null) / 2, y_favor, f"← {favor_left}",
                     fontsize=7.5, transform=fig.transFigure, va="top", ha="center",
                     color="#555555")
            fig.text((x_null + col_plot_right) / 2, y_favor, f"{favor_right} →",
                     fontsize=7.5, transform=fig.transFigure, va="top", ha="center",
                     color="#555555")
        
            # ── Heterogeneity footer ──────────────────────────────────────────────────
            y_footer = y_favor - 0.035
            het_str = (
                f"Heterogeneity: I² = {i_squared:.1f}%, τ² = {tau_squared:.3f}, "
                f"Q-test P = {q_p_value:.3f}  |  "
                f"N studies = {n_studies}"
            )
            fig.text(col_label, y_footer, het_str, fontsize=7.5,
                     transform=fig.transFigure, va="top", color="#444444")
        
            # ── Reproducibility footer ────────────────────────────────────────────────
            rep_str = (
                f"Generated: {datetime.now().strftime('%Y-%m-%d')} | "
                f"Script v{SCRIPT_VERSION} | seed={SEED}"
            )
            fig.text(0.99, 0.01, rep_str, fontsize=6, transform=fig.transFigure,
                     va="bottom", ha="right", color="#999999")
        
            # ── Save ──────────────────────────────────────────────────────────────────
            for ext in ["pdf", "png"]:
                outfile = f"{output_path}.{ext}"
                dpi = 300 if ext == "png" else None
                plt.savefig(outfile, dpi=dpi, bbox_inches="tight",
                            facecolor="white", edgecolor="none")
                print(f"Saved: {outfile}")
        
            plt.close()
        
        
        def main():
            parser = argparse.ArgumentParser(description="Generate publication-ready forest plot")
            parser.add_argument("--input", required=True, help="Path to CSV file")
            parser.add_argument("--pooled", nargs=3, type=float, metavar=("EFFECT", "CI_LO", "CI_HI"),
                                required=True, help="Pooled effect size and 95% CI")
            parser.add_argument("--i2", type=float, default=0.0, help="I² (%)")
            parser.add_argument("--tau2", type=float, default=0.0, help="τ²")
            parser.add_argument("--q-p", type=float, default=1.0, dest="q_p",
                                help="Cochran Q p-value")
            parser.add_argument("--effect-label", default="OR", help="Label for effect measure")
            parser.add_argument("--null-value", type=float, default=1.0,
                                help="Null value (1.0 for OR/RR; 0 for MD)")
            parser.add_argument("--log-scale", action="store_true", default=True,
                                help="Use log scale for OR/RR (default: True)")
            parser.add_argument("--no-log-scale", action="store_false", dest="log_scale")
            parser.add_argument("--favor-left", default="Favors Treatment")
            parser.add_argument("--favor-right", default="Favors Control")
            parser.add_argument("--output", default="forest_plot", help="Output file path (no extension)")
        
            args = parser.parse_args()
        
            df = load_data(args.input)
            pooled_effect, pooled_ci_lo, pooled_ci_hi = args.pooled
        
            print(f"\n── Forest Plot Generation ──────────────────")
            print(f"Studies: {len(df)}")
            print(f"Pooled {args.effect_label}: {pooled_effect:.2f} "
                  f"(95% CI: {pooled_ci_lo:.2f}–{pooled_ci_hi:.2f})")
            print(f"I² = {args.i2:.1f}%, τ² = {args.tau2:.3f}, Q P = {args.q_p:.3f}")
        
            make_forest_plot(
                df=df,
                pooled_effect=pooled_effect,
                pooled_ci_lower=pooled_ci_lo,
                pooled_ci_upper=pooled_ci_hi,
                i_squared=args.i2,
                tau_squared=args.tau2,
                q_p_value=args.q_p,
                effect_label=args.effect_label,
                null_value=args.null_value,
                log_scale=args.log_scale,
                favor_left=args.favor_left,
                favor_right=args.favor_right,
                output_path=args.output,
            )
        
        
        # ── Example CSV format ────────────────────────────────────────────────────────
        EXAMPLE_CSV = """study_label,effect_size,ci_lower,ci_upper,n_total,weight,subgroup
        Kim 2019,0.72,0.51,1.02,234,12.4,Single-center
        Park 2020,0.61,0.45,0.83,418,18.2,Single-center
        Lee 2021,0.88,0.62,1.24,156,10.1,Single-center
        Chen 2022,0.65,0.50,0.85,512,19.8,Multi-center
        Wang 2022,0.71,0.53,0.95,345,15.6,Multi-center
        Smith 2023,0.58,0.41,0.81,289,14.7,Multi-center
        Jones 2023,0.77,0.55,1.08,198,9.2,Multi-center
        """
        
        # ── Run example ───────────────────────────────────────────────────────────────
        if __name__ == "__main__":
            if len(sys.argv) == 1:
                # Demo mode: generate example
                import io
                print("Running in demo mode with example data...")
                df = pd.read_csv(io.StringIO(EXAMPLE_CSV))
                make_forest_plot(
                    df=df,
                    pooled_effect=0.70,
                    pooled_ci_lower=0.59,
                    pooled_ci_upper=0.83,
                    i_squared=23.4,
                    tau_squared=0.008,
                    q_p_value=0.24,
                    effect_label="OR",
                    null_value=1.0,
                    log_scale=True,
                    favor_left="Favors Treatment",
                    favor_right="Favors Control",
                    output_path="forest_plot_demo",
                )
            else:
                main()
        
      • likert_summary.py 17.3 KB
        """
        likert_summary.py — Publication-ready Likert Scale Survey Analysis
        ===================================================================
        Analyzes Likert-scale survey data for medical education research papers.
        
        Input:
            CSV with respondent rows and item columns.
            Likert responses should be numeric (1–5 or 1–7).
            Optional group column for subgroup comparisons.
        
        Outputs:
            - Console: formatted descriptive statistics and test results
            - CSV: summary tables (table1_likert.csv)
            - PNG/PDF: diverging stacked bar chart (300 DPI)
        
        Usage:
            python likert_summary.py --input survey_data.csv \
                --items Q1 Q2 Q3 Q4 Q5 \
                --labels "Strongly Disagree" "Disagree" "Neutral" "Agree" "Strongly Agree" \
                --group group_column \
                --scale 5 \
                --output likert_analysis
        
        Dependencies:
            pip install pandas numpy matplotlib scipy pingouin
        """
        
        import argparse
        import sys
        import warnings
        from datetime import datetime
        
        import numpy as np
        import pandas as pd
        import matplotlib.pyplot as plt
        import matplotlib.patches as mpatches
        from scipy import stats
        
        try:
            import pingouin as pg
            PINGOUIN_AVAILABLE = True
        except ImportError:
            PINGOUIN_AVAILABLE = False
            warnings.warn("pingouin not installed. Cronbach's alpha will be skipped. "
                          "Install with: pip install pingouin")
        
        # ── Reproducibility ───────────────────────────────────────────────────────────
        SCRIPT_VERSION = "1.0.0"
        SEED = 42
        np.random.seed(SEED)
        print(f"Script: likert_summary.py v{SCRIPT_VERSION} | Date: {datetime.now().strftime('%Y-%m-%d')}")
        
        # ── Style ─────────────────────────────────────────────────────────────────────
        plt.rcParams.update({
            "font.family": "Arial",
            "font.size": 9,
            "figure.dpi": 150,
        })
        
        # Diverging palette: negative → neutral → positive (colorblind-safe)
        DIVERGE_COLORS_5 = ["#D55E00", "#E69F00", "#CCCCCC", "#56B4E9", "#0072B2"]
        DIVERGE_COLORS_7 = ["#D55E00", "#E69F00", "#F0E442", "#CCCCCC",
                             "#56B4E9", "#009E73", "#0072B2"]
        
        
        def load_data(filepath: str, item_cols: list) -> pd.DataFrame:
            df = pd.read_csv(filepath)
            missing = [c for c in item_cols if c not in df.columns]
            if missing:
                raise ValueError(f"Item columns not found in CSV: {missing}")
            return df
        
        
        def descriptive_stats(df: pd.DataFrame, items: list, labels: list) -> pd.DataFrame:
            """Compute descriptive statistics for each Likert item."""
            rows = []
            for item in items:
                col = df[item].dropna()
                rows.append({
                    "Item": item,
                    "N": len(col),
                    "Mean": round(col.mean(), 2),
                    "Median": col.median(),
                    "SD": round(col.std(), 2),
                    "Q1": col.quantile(0.25),
                    "Q3": col.quantile(0.75),
                    "Min": col.min(),
                    "Max": col.max(),
                })
            return pd.DataFrame(rows)
        
        
        def frequency_table(df: pd.DataFrame, items: list, labels: list, scale: int) -> pd.DataFrame:
            """Compute frequency distribution for each item."""
            scale_values = list(range(1, scale + 1))
            item_labels = labels if labels else [str(v) for v in scale_values]
        
            rows = []
            for item in items:
                col = df[item].dropna()
                row = {"Item": item, "N": len(col)}
                for val, label in zip(scale_values, item_labels):
                    count = (col == val).sum()
                    pct = count / len(col) * 100
                    row[f"{label} (n)"] = count
                    row[f"{label} (%)"] = round(pct, 1)
                rows.append(row)
            return pd.DataFrame(rows)
        
        
        def apply_reverse_coding(df: pd.DataFrame, items: list, reverse_items: list,
                                 scale: int) -> pd.DataFrame:
            """Recode reverse-worded items as (min+max) - x BEFORE reliability/scoring.
            Returns a copy; leaves non-reverse items untouched. min is assumed 1."""
            if not reverse_items:
                return df
            out = df.copy()
            flip_const = scale + 1  # (min=1) + (max=scale)
            for it in reverse_items:
                if it not in items:
                    raise ValueError(f"--reverse-items '{it}' is not in --items")
                out[it] = flip_const - out[it]
                print(f"  Reverse-coded: {it} -> ({flip_const} - {it})")
            return out
        
        
        def item_rest_correlations(df: pd.DataFrame, items: list) -> dict:
            """Corrected item-total (item-rest) correlation per item. A negative value
            means the item moves opposite the rest of the scale — the classic signature
            of a reverse-worded item that was never recoded."""
            sub = df[items].dropna()
            out = {}
            if len(sub) < 2 or len(items) < 2:
                return {it: None for it in items}
            for it in items:
                rest = sub[[c for c in items if c != it]].sum(axis=1)
                if sub[it].std() == 0 or rest.std() == 0:
                    out[it] = None
                else:
                    out[it] = round(float(sub[it].corr(rest)), 3)
            return out
        
        
        def cronbach_alpha(df: pd.DataFrame, items: list) -> float:
            """Compute Cronbach's alpha for a set of items."""
            if not PINGOUIN_AVAILABLE:
                return None
            try:
                result = pg.cronbach_alpha(df[items].dropna())
                return round(result[0], 3)
            except Exception as e:
                print(f"  Warning: Could not compute Cronbach's alpha: {e}")
                return None
        
        
        def compare_groups(df: pd.DataFrame, items: list, group_col: str) -> pd.DataFrame:
            """Mann-Whitney U test for group comparison on each item."""
            groups = df[group_col].dropna().unique()
            if len(groups) != 2:
                print(f"  Warning: Group comparison requires exactly 2 groups; found {len(groups)}")
                return pd.DataFrame()
        
            g1, g2 = groups
            rows = []
            for item in items:
                d1 = df.loc[df[group_col] == g1, item].dropna()
                d2 = df.loc[df[group_col] == g2, item].dropna()
        
                stat, p = stats.mannwhitneyu(d1, d2, alternative="two-sided")
                # Rank-biserial r (effect size)
                n1, n2 = len(d1), len(d2)
                r = 1 - (2 * stat) / (n1 * n2)
        
                rows.append({
                    "Item": item,
                    f"{g1} Median [IQR]": f"{d1.median():.1f} [{d1.quantile(0.25):.1f}–{d1.quantile(0.75):.1f}]",
                    f"{g2} Median [IQR]": f"{d2.median():.1f} [{d2.quantile(0.25):.1f}–{d2.quantile(0.75):.1f}]",
                    "U statistic": round(stat, 1),
                    "P value": round(p, 3),
                    "r (effect size)": round(r, 3),
                    "Interpretation": "small" if abs(r) < 0.3 else ("medium" if abs(r) < 0.5 else "large"),
                })
            return pd.DataFrame(rows)
        
        
        def prepost_comparison(df: pd.DataFrame, pre_items: list, post_items: list) -> pd.DataFrame:
            """Wilcoxon signed-rank test for pre-post comparison."""
            rows = []
            for pre, post in zip(pre_items, post_items):
                paired = df[[pre, post]].dropna()
                d_pre = paired[pre]
                d_post = paired[post]
        
                stat, p = stats.wilcoxon(d_pre, d_post, alternative="two-sided")
                n = len(paired)
                z = stats.norm.ppf(p / 2)  # approximation
                r = abs(z) / np.sqrt(n)
        
                rows.append({
                    "Item": f"{pre} → {post}",
                    "N (paired)": n,
                    "Pre Median [IQR]": f"{d_pre.median():.1f} [{d_pre.quantile(0.25):.1f}–{d_pre.quantile(0.75):.1f}]",
                    "Post Median [IQR]": f"{d_post.median():.1f} [{d_post.quantile(0.25):.1f}–{d_post.quantile(0.75):.1f}]",
                    "W statistic": round(stat, 1),
                    "P value": round(p, 3),
                    "r (effect size)": round(r, 3),
                })
            return pd.DataFrame(rows)
        
        
        def diverging_bar_chart(
            df: pd.DataFrame,
            items: list,
            labels: list,
            scale: int,
            group_col: str = None,
            output_path: str = "likert_chart",
            item_names: dict = None,
        ) -> None:
            """Generate diverging stacked bar chart."""
            colors = DIVERGE_COLORS_5 if scale == 5 else DIVERGE_COLORS_7
            scale_values = list(range(1, scale + 1))
            neutral_idx = scale // 2  # 0-indexed neutral position
        
            plot_df = df[items].copy()
        
            # Compute proportions for each item
            prop_data = []
            for item in items:
                col = plot_df[item].dropna()
                n = len(col)
                props = [(col == v).sum() / n for v in scale_values]
                prop_data.append(props)
        
            prop_arr = np.array(prop_data)  # shape: (n_items, scale)
        
            fig, ax = plt.subplots(figsize=(7.0, max(3.0, len(items) * 0.55 + 1.5)))
        
            y_pos = np.arange(len(items))
            height = 0.6
        
            # Diverging: negative on left, positive on right
            neg_props = prop_arr[:, :neutral_idx]     # columns before neutral
            neutral_prop = prop_arr[:, neutral_idx]   # neutral column
            pos_props = prop_arr[:, neutral_idx + 1:] # columns after neutral
        
            # Starting x for left bars (negative, goes left from center)
            left_start = -(neg_props.sum(axis=1) + neutral_prop / 2)
        
            # Draw negative bars (left to right, so right-most color first)
            x_left = left_start.copy()
            for i in range(neg_props.shape[1] - 1, -1, -1):
                ax.barh(y_pos, neg_props[:, i], left=x_left, height=height,
                        color=colors[i], edgecolor="white", linewidth=0.5)
                x_left += neg_props[:, i]
        
            # Draw neutral bar
            x_neutral = -(neutral_prop / 2)
            ax.barh(y_pos, neutral_prop, left=x_neutral, height=height,
                    color=colors[neutral_idx], edgecolor="white", linewidth=0.5)
        
            # Draw positive bars
            x_right = neutral_prop / 2
            for i, j in enumerate(range(neutral_idx + 1, scale)):
                ax.barh(y_pos, pos_props[:, i], left=x_right, height=height,
                        color=colors[j], edgecolor="white", linewidth=0.5)
                x_right += pos_props[:, i]
        
            # Percentage labels on bars > 10%
            # (omitted for brevity — add if needed)
        
            # Axes formatting
            ax.axvline(0, color="black", linewidth=0.8, zorder=5)
            ax.set_yticks(y_pos)
            y_labels = [item_names.get(item, item) if item_names else item for item in items]
            ax.set_yticklabels(y_labels, fontsize=8)
            ax.set_xlabel("Proportion of respondents", fontsize=9)
        
            # X-axis: convert to percentage
            xticks = ax.get_xticks()
            ax.set_xticklabels([f"{abs(x)*100:.0f}%" for x in xticks], fontsize=8)
        
            # Legend
            legend_patches = [mpatches.Patch(color=colors[i], label=labels[i])
                              for i in range(scale)]
            ax.legend(handles=legend_patches, loc="lower center",
                      bbox_to_anchor=(0.5, -0.25), ncol=scale, fontsize=7.5,
                      frameon=False)
        
            ax.set_xlim(-1.0, 1.0)
            ax.grid(axis="x", color="#DDDDDD", linewidth=0.5)
            ax.spines[["top", "right"]].set_visible(False)
        
            plt.tight_layout()
        
            for ext in ["pdf", "png"]:
                outfile = f"{output_path}_diverging.{ext}"
                dpi = 300 if ext == "png" else None
                plt.savefig(outfile, dpi=dpi, bbox_inches="tight",
                            facecolor="white", edgecolor="none")
                print(f"Saved: {outfile}")
            plt.close()
        
        
        def main():
            parser = argparse.ArgumentParser(description="Likert scale analysis")
            parser.add_argument("--input", required=True)
            parser.add_argument("--items", nargs="+", required=True)
            parser.add_argument("--labels", nargs="+",
                                default=["Strongly Disagree", "Disagree", "Neutral",
                                          "Agree", "Strongly Agree"])
            parser.add_argument("--scale", type=int, default=5, choices=[5, 7])
            parser.add_argument("--reverse-items", nargs="+", default=None,
                                help="reverse-worded items to recode as (scale+1)-x "
                                     "BEFORE alpha/scoring (e.g. a 'I review critically' item)")
            parser.add_argument("--group", default=None)
            parser.add_argument("--pre-items", nargs="+", default=None)
            parser.add_argument("--post-items", nargs="+", default=None)
            parser.add_argument("--output", default="likert_analysis")
        
            args = parser.parse_args()
        
            df = load_data(args.input, args.items)
            print(f"\nLoaded: {len(df)} respondents, {len(args.items)} items")
        
            # ── Reverse-coding guard (run BEFORE any scoring/reliability) ──────────────
            # A reverse-worded item must be recoded or it sinks Cronbach's alpha (often
            # negative). See ~/.claude/rules/survey-scale-reliability.md.
            if args.reverse_items:
                print("\n── Reverse-coding applied ──────────────────")
                df = apply_reverse_coding(df, args.items, args.reverse_items, args.scale)
        
            # ── Descriptive statistics ────────────────────────────────────────────────
            print("\n── Descriptive Statistics ──────────────────")
            desc = descriptive_stats(df, args.items, args.labels)
            print(desc.to_string(index=False))
        
            # ── Frequency table ───────────────────────────────────────────────────────
            print("\n── Frequency Distribution ──────────────────")
            freq = frequency_table(df, args.items, args.labels, args.scale)
            print(freq.to_string(index=False))
        
            # ── Internal consistency + reverse-coding guard ───────────────────────────
            if len(args.items) >= 2:
                rests = item_rest_correlations(df, args.items)
                suspects = [it for it, r in rests.items() if r is not None and r < 0]
                if suspects:
                    print("\n── Reverse-coding check ────────────────────")
                    for it in args.items:
                        mark = "  <-- reverse-code suspect (negative item-rest)" if it in suspects else ""
                        print(f"   item-rest r  {it:<16} {rests[it]}{mark}")
                    print(f"   ⚠ Suspect items: {', '.join(suspects)}. If reverse-worded, "
                          "rerun with --reverse-items before reporting alpha.")
        
            if PINGOUIN_AVAILABLE:
                alpha = cronbach_alpha(df, args.items)
                if alpha is not None:
                    print(f"\n── Cronbach's Alpha: {alpha}")
                    if alpha < 0:
                        # A negative alpha is almost never a real measurement phenomenon;
                        # it is a reverse-coding bug. Do NOT defend it as multidimensional.
                        print("   ⚠ NEGATIVE alpha — almost always a reverse-coding bug, "
                              "not a 'multidimensional' construct.")
                        print("     Recode reverse-worded items (--reverse-items) and rerun "
                              "BEFORE interpreting. See survey-scale-reliability.md.")
                    else:
                        interp = ("excellent" if alpha >= 0.9 else
                                  "good" if alpha >= 0.8 else
                                  "acceptable" if alpha >= 0.7 else
                                  "questionable" if alpha >= 0.6 else "poor")
                        print(f"   Interpretation: {interp}")
        
            # ── Group comparison ──────────────────────────────────────────────────────
            if args.group and args.group in df.columns:
                print(f"\n── Group Comparison ({args.group}) — Mann-Whitney U ──")
                grp = compare_groups(df, args.items, args.group)
                if not grp.empty:
                    print(grp.to_string(index=False))
                    grp.to_csv(f"{args.output}_group_comparison.csv", index=False)
                    print(f"Saved: {args.output}_group_comparison.csv")
        
            # ── Pre-post comparison ───────────────────────────────────────────────────
            if args.pre_items and args.post_items:
                print("\n── Pre-Post Comparison — Wilcoxon Signed-Rank ──")
                pp = prepost_comparison(df, args.pre_items, args.post_items)
                print(pp.to_string(index=False))
                pp.to_csv(f"{args.output}_prepost.csv", index=False)
                print(f"Saved: {args.output}_prepost.csv")
        
            # ── Save tables ───────────────────────────────────────────────────────────
            desc.to_csv(f"{args.output}_descriptive.csv", index=False)
            freq.to_csv(f"{args.output}_frequency.csv", index=False)
            print(f"\nSaved: {args.output}_descriptive.csv")
            print(f"Saved: {args.output}_frequency.csv")
        
            # ── Diverging bar chart ───────────────────────────────────────────────────
            diverging_bar_chart(df, args.items, args.labels, args.scale,
                                 output_path=args.output)
        
            print("\n── Session Info ─────────────────────────────")
            print(f"Python: {sys.version.split()[0]}")
            for pkg in ["pandas", "numpy", "scipy", "pingouin"]:
                try:
                    import importlib
                    m = importlib.import_module(pkg)
                    print(f"  {pkg}: {m.__version__}")
                except Exception:
                    print(f"  {pkg}: not installed")
        
        
        if __name__ == "__main__":
            if len(sys.argv) == 1:
                print("No arguments provided. Run with --help for usage.")
                print("\nExample:")
                print("  python likert_summary.py --input survey.csv "
                      "--items Q1 Q2 Q3 Q4 Q5 --scale 5 --group specialty")
            else:
                main()
        
      • meta_analysis.R 16.3 KB · in bundle
      • propensity_score.py 16.8 KB
        """
        Template: Propensity Score Analysis
        Supports PS matching, IPTW, and overlap weighting for observational studies.
        Generates balance tables, Love plots, and weighted outcome analyses.
        
        Usage:
            Modify the CONFIGURATION section below, then run:
                python propensity_score.py
        
        Input:  CSV with treatment indicator, outcome, and covariate columns
        Output: balance table CSV, Love plot PDF/PNG, PS distribution plot, outcome results
        """
        
        # === REPRODUCIBILITY HEADER ===
        import sys
        import os
        import datetime
        import numpy as np
        import pandas as pd
        from scipy import stats
        
        np.random.seed(42)
        print(f"Date: {datetime.date.today()}")
        print(f"Python: {sys.version}")
        print(f"numpy: {np.__version__}, pandas: {pd.__version__}, scipy: {stats.scipy.__version__}")
        
        try:
            from sklearn.linear_model import LogisticRegression
            from sklearn.neighbors import NearestNeighbors
            import sklearn
            print(f"sklearn: {sklearn.__version__}")
        except ImportError:
            print("Error: scikit-learn not installed. Install with: pip install scikit-learn")
            sys.exit(1)
        
        try:
            import statsmodels.api as sm
            print(f"statsmodels: {sm.__version__}")
        except ImportError:
            print("Error: statsmodels not installed. Install with: pip install statsmodels")
            sys.exit(1)
        
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        
        STYLE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "style", "figure_style.mplstyle")
        if os.path.exists(STYLE_PATH):
            plt.style.use(STYLE_PATH)
        
        
        # === CONFIGURATION ===
        CONFIG = {
            # Data
            "data_path": "data.csv",
            "output_dir": ".",
        
            # Variables
            "treatment": "treatment",       # Binary treatment indicator (0/1)
            "outcome": "outcome",           # Outcome variable
            "outcome_type": "continuous",    # "continuous" or "binary"
            "covariates": ["age", "sex", "bmi", "comorbidity_score"],
            "categorical_covariates": ["sex"],
        
            # PS method: "matching", "iptw", "siptw", "overlap"
            "ps_method": "matching",
        
            # Matching options
            "caliper_sd_multiplier": 0.2,   # caliper = 0.2 * SD(logit PS)
            "matching_ratio": 1,            # 1:1 matching
        
            # Balance threshold
            "smd_threshold": 0.10,
        
            # IPTW options
            "stabilized_weights": True,
            "weight_truncation": 10.0,      # truncate weights > this value
        }
        
        
        # === HELPER FUNCTIONS ===
        
        def estimate_ps(df, treatment_col, covariates):
            """Estimate propensity scores using logistic regression."""
            X = df[covariates].values
            y = df[treatment_col].values
            model = LogisticRegression(max_iter=1000, random_state=42)
            model.fit(X, y)
            ps = model.predict_proba(X)[:, 1]
            return ps
        
        
        def calculate_smd(x1, x0, is_binary=False):
            """Calculate standardized mean difference."""
            if is_binary:
                p1, p0 = x1.mean(), x0.mean()
                denom = np.sqrt((p1 * (1 - p1) + p0 * (1 - p0)) / 2)
                if denom == 0:
                    return 0.0
                return (p1 - p0) / denom
            else:
                denom = np.sqrt((x1.var() + x0.var()) / 2)
                if denom == 0:
                    return 0.0
                return (x1.mean() - x0.mean()) / denom
        
        
        def balance_table(df, treatment_col, covariates, categorical_covs, weights=None):
            """Generate balance table with SMD before/after adjustment."""
            treated = df[treatment_col] == 1
            results = []
        
            for var in covariates:
                is_cat = var in categorical_covs
                x1 = df.loc[treated, var]
                x0 = df.loc[~treated, var]
        
                if weights is not None:
                    w1 = weights[treated]
                    w0 = weights[~treated]
                    # Weighted means
                    wm1 = np.average(x1, weights=w1)
                    wm0 = np.average(x0, weights=w0)
                    # Weighted SMD (approximate)
                    if is_cat:
                        denom = np.sqrt((wm1 * (1 - wm1) + wm0 * (1 - wm0)) / 2)
                    else:
                        wv1 = np.average((x1 - wm1) ** 2, weights=w1)
                        wv0 = np.average((x0 - wm0) ** 2, weights=w0)
                        denom = np.sqrt((wv1 + wv0) / 2)
                    smd_adj = (wm1 - wm0) / denom if denom > 0 else 0.0
                else:
                    smd_adj = None
        
                smd_raw = calculate_smd(x1, x0, is_binary=is_cat)
        
                row = {
                    "Variable": var,
                    "Treated_mean": x1.mean(),
                    "Treated_sd": x1.std(),
                    "Control_mean": x0.mean(),
                    "Control_sd": x0.std(),
                    "SMD_before": abs(smd_raw),
                }
                if smd_adj is not None:
                    row["SMD_after"] = abs(smd_adj)
                results.append(row)
        
            return pd.DataFrame(results)
        
        
        def plot_love(bal_df, smd_threshold, output_dir, title="Love Plot"):
            """Generate Love plot comparing SMD before and after adjustment."""
            fig, ax = plt.subplots(figsize=(8, max(3, len(bal_df) * 0.5)))
        
            y_pos = range(len(bal_df))
            ax.scatter(bal_df["SMD_before"], y_pos, marker="o", color="gray",
                       s=60, label="Before", zorder=3)
            if "SMD_after" in bal_df.columns:
                ax.scatter(bal_df["SMD_after"], y_pos, marker="s", color="navy",
                           s=60, label="After", zorder=4)
        
            ax.axvline(x=smd_threshold, color="red", linestyle="--", alpha=0.7,
                       label=f"Threshold ({smd_threshold})")
            ax.set_yticks(y_pos)
            ax.set_yticklabels(bal_df["Variable"])
            ax.set_xlabel("Absolute Standardized Mean Difference")
            ax.set_title(title)
            ax.legend(loc="best")
            ax.set_xlim(left=0)
        
            plt.tight_layout()
            for ext in ["pdf", "png"]:
                fig.savefig(os.path.join(output_dir, f"love_plot.{ext}"),
                            dpi=300, bbox_inches="tight")
            plt.close(fig)
            print(f"Saved: love_plot.pdf/.png")
        
        
        def plot_ps_distribution(ps, treatment, output_dir):
            """Plot PS distribution by treatment group."""
            fig, ax = plt.subplots(figsize=(8, 5))
        
            ax.hist(ps[treatment == 1], bins=30, alpha=0.5, density=True,
                    color="steelblue", label="Treated")
            ax.hist(ps[treatment == 0], bins=30, alpha=0.5, density=True,
                    color="coral", label="Control")
            ax.set_xlabel("Propensity Score")
            ax.set_ylabel("Density")
            ax.set_title("Propensity Score Distribution")
            ax.legend()
        
            plt.tight_layout()
            for ext in ["pdf", "png"]:
                fig.savefig(os.path.join(output_dir, f"ps_distribution.{ext}"),
                            dpi=300, bbox_inches="tight")
            plt.close(fig)
            print(f"Saved: ps_distribution.pdf/.png")
        
        
        def ps_matching(df, ps, treatment_col, caliper_sd_mult=0.2, ratio=1):
            """Perform 1:N nearest-neighbor PS matching with caliper."""
            logit_ps = np.log(ps / (1 - ps))
            caliper = caliper_sd_mult * logit_ps.std()
        
            treated_idx = df.index[df[treatment_col] == 1].values
            control_idx = df.index[df[treatment_col] == 0].values
        
            treated_logit = logit_ps[treated_idx].reshape(-1, 1)
            control_logit = logit_ps[control_idx].reshape(-1, 1)
        
            nn = NearestNeighbors(n_neighbors=ratio, metric="euclidean")
            nn.fit(control_logit)
            distances, indices = nn.kneighbors(treated_logit)
        
            matched_treated = []
            matched_control = []
            used_controls = set()
        
            for i, (dist_arr, idx_arr) in enumerate(zip(distances, indices)):
                for d, j in zip(dist_arr, idx_arr):
                    ctrl_orig_idx = control_idx[j]
                    if d <= caliper and ctrl_orig_idx not in used_controls:
                        matched_treated.append(treated_idx[i])
                        matched_control.append(ctrl_orig_idx)
                        used_controls.add(ctrl_orig_idx)
                        break
        
            matched_indices = matched_treated + matched_control
            n_unmatched = len(treated_idx) - len(matched_treated)
        
            print(f"\nPS Matching Results:")
            print(f"  Caliper: {caliper:.4f} (= {caliper_sd_mult} x SD(logit PS))")
            print(f"  Matched pairs: {len(matched_treated)}")
            print(f"  Unmatched treated: {n_unmatched}")
            print(f"  Unmatched controls: {len(control_idx) - len(matched_control)}")
        
            return df.loc[matched_indices].copy(), matched_indices
        
        
        def iptw_weights(ps, treatment, stabilized=True, truncation=10.0):
            """Calculate IPTW weights (ATE)."""
            if stabilized:
                p_treat = treatment.mean()
                w = np.where(treatment == 1, p_treat / ps, (1 - p_treat) / (1 - ps))
            else:
                w = np.where(treatment == 1, 1 / ps, 1 / (1 - ps))
        
            # Truncation
            n_truncated = (w > truncation).sum()
            if n_truncated > 0:
                print(f"  Truncated {n_truncated} weights > {truncation}")
                w = np.clip(w, None, truncation)
        
            print(f"\nIPTW Weights Summary:")
            print(f"  Mean: {w.mean():.2f}, SD: {w.std():.2f}")
            print(f"  Min: {w.min():.2f}, Max: {w.max():.2f}")
            print(f"  Stabilized: {stabilized}")
        
            return w
        
        
        def siptw_weights(ps, treatment, truncation=10.0):
            """Calculate Stabilized Inverse Probability of Treatment Weights (SIPTW).
        
            SIPTW maintains the sample size of the entire cohort and allows for
            appropriate estimation of the variance of the main effect. Increasingly
            used in emulated target trial frameworks (Yon DK group pattern).
        
            Weights:
                Treated:   P(T=1) / PS
                Control:   P(T=0) / (1 - PS)
        
            This is equivalent to stabilized IPTW but explicitly named SIPTW in some
            literature to distinguish from unstabilized IPTW.
            """
            p_treat = treatment.mean()
            w = np.where(treatment == 1, p_treat / ps, (1 - p_treat) / (1 - ps))
        
            # Truncation
            n_truncated = (w > truncation).sum()
            if n_truncated > 0:
                print(f"  Truncated {n_truncated} weights > {truncation}")
                w = np.clip(w, None, truncation)
        
            # Effective sample size
            ess_treated = (w[treatment == 1].sum()) ** 2 / (w[treatment == 1] ** 2).sum()
            ess_control = (w[treatment == 0].sum()) ** 2 / (w[treatment == 0] ** 2).sum()
        
            print(f"\nSIPTW Weights Summary:")
            print(f"  Mean: {w.mean():.2f}, SD: {w.std():.2f}")
            print(f"  Min: {w.min():.2f}, Max: {w.max():.2f}")
            print(f"  Effective sample size (treated): {ess_treated:.0f} / {(treatment == 1).sum()}")
            print(f"  Effective sample size (control): {ess_control:.0f} / {(treatment == 0).sum()}")
        
            return w
        
        
        def overlap_weights(ps, treatment):
            """Calculate overlap weights (ATO)."""
            w = np.where(treatment == 1, 1 - ps, ps)
        
            print(f"\nOverlap Weights Summary:")
            print(f"  Mean: {w.mean():.3f}, SD: {w.std():.3f}")
            print(f"  Min: {w.min():.3f}, Max: {w.max():.3f}")
        
            return w
        
        
        def weighted_outcome_analysis(df, treatment_col, outcome_col, outcome_type, weights):
            """Perform weighted outcome analysis."""
            import statsmodels.api as sm
        
            X = sm.add_constant(df[[treatment_col]])
            y = df[outcome_col]
        
            if outcome_type == "binary":
                model = sm.GLM(y, X, family=sm.families.Binomial(), freq_weights=weights)
            else:
                model = sm.WLS(y, X, weights=weights)
        
            result = model.fit()
            print(f"\n--- Weighted Outcome Analysis ---")
            print(result.summary2())
        
            # Extract treatment effect
            coef = result.params[treatment_col]
            ci = result.conf_int().loc[treatment_col]
            p_val = result.pvalues[treatment_col]
        
            if outcome_type == "binary":
                or_val = np.exp(coef)
                or_ci = np.exp(ci)
                print(f"\nTreatment effect (OR): {or_val:.2f} (95% CI: {or_ci[0]:.2f}-{or_ci[1]:.2f}), P = {p_val:.3f}")
            else:
                print(f"\nTreatment effect (β): {coef:.3f} (95% CI: {ci[0]:.3f}-{ci[1]:.3f}), P = {p_val:.3f}")
        
            return result
        
        
        # === MAIN ANALYSIS ===
        
        def main():
            config = CONFIG
            df = pd.read_csv(config["data_path"])
            output_dir = config["output_dir"]
            print(f"Data loaded: {df.shape[0]} rows x {df.shape[1]} columns")
        
            treatment_col = config["treatment"]
            outcome_col = config["outcome"]
            covariates = config["covariates"]
        
            # Encode categorical variables
            for cat_var in config.get("categorical_covariates", []):
                if cat_var in df.columns and df[cat_var].dtype == "object":
                    df[cat_var] = pd.Categorical(df[cat_var]).codes
        
            # Drop missing
            analysis_vars = [treatment_col, outcome_col] + covariates
            n_before = len(df)
            df = df.dropna(subset=analysis_vars)
            n_after = len(df)
            if n_before != n_after:
                print(f"Excluded {n_before - n_after} rows with missing data ({100*(n_before-n_after)/n_before:.1f}%)")
        
            treatment = df[treatment_col].values
            n_treated = treatment.sum()
            n_control = len(treatment) - n_treated
        
            print(f"\n{'='*60}")
            print(f"PROPENSITY SCORE ANALYSIS")
            print(f"{'='*60}")
            print(f"Method: {config['ps_method'].upper()}")
            print(f"Treated: {n_treated}, Control: {n_control}")
            print(f"Covariates: {len(covariates)}")
        
            # Step 1: Estimate PS
            print(f"\n--- Step 1: PS Estimation ---")
            ps = estimate_ps(df, treatment_col, covariates)
            df["ps"] = ps
            plot_ps_distribution(ps, treatment, output_dir)
        
            # Pre-adjustment balance
            print(f"\n--- Pre-adjustment Balance ---")
            bal_before = balance_table(df, treatment_col, covariates,
                                       config.get("categorical_covariates", []))
            print(bal_before[["Variable", "SMD_before"]].to_string(index=False))
            n_imbalanced = (bal_before["SMD_before"] > config["smd_threshold"]).sum()
            print(f"Variables with SMD > {config['smd_threshold']}: {n_imbalanced}/{len(covariates)}")
        
            # Step 2: Apply PS method
            weights = None
            if config["ps_method"] == "matching":
                print(f"\n--- Step 2: PS Matching ---")
                df_matched, _ = ps_matching(
                    df, ps, treatment_col,
                    caliper_sd_mult=config["caliper_sd_multiplier"],
                    ratio=config["matching_ratio"]
                )
                # Balance after matching
                bal_after = balance_table(df_matched, treatment_col, covariates,
                                          config.get("categorical_covariates", []))
                bal_combined = bal_before.copy()
                bal_combined["SMD_after"] = bal_after["SMD_before"].values
        
            elif config["ps_method"] == "iptw":
                print(f"\n--- Step 2: IPTW ---")
                weights = iptw_weights(ps, treatment,
                                        stabilized=config["stabilized_weights"],
                                        truncation=config["weight_truncation"])
                df["weights"] = weights
                bal_combined = balance_table(df, treatment_col, covariates,
                                             config.get("categorical_covariates", []),
                                             weights=weights)
        
            elif config["ps_method"] == "siptw":
                print(f"\n--- Step 2: SIPTW (Stabilized Inverse Probability of Treatment Weighting) ---")
                weights = siptw_weights(ps, treatment,
                                        truncation=config["weight_truncation"])
                df["weights"] = weights
                bal_combined = balance_table(df, treatment_col, covariates,
                                             config.get("categorical_covariates", []),
                                             weights=weights)
        
            elif config["ps_method"] == "overlap":
                print(f"\n--- Step 2: Overlap Weighting ---")
                weights = overlap_weights(ps, treatment)
                df["weights"] = weights
                bal_combined = balance_table(df, treatment_col, covariates,
                                             config.get("categorical_covariates", []),
                                             weights=weights)
        
            # Step 3: Balance assessment
            print(f"\n--- Step 3: Post-adjustment Balance ---")
            print(bal_combined[["Variable", "SMD_before", "SMD_after"]].to_string(index=False))
            n_imbalanced_after = (bal_combined["SMD_after"] > config["smd_threshold"]).sum()
            print(f"Variables with SMD > {config['smd_threshold']} after adjustment: "
                  f"{n_imbalanced_after}/{len(covariates)}")
        
            if n_imbalanced_after > 0:
                print("⚠ WARNING: Some covariates remain imbalanced. "
                      "Consider adding interaction terms to PS model or switching method.")
        
            # Love plot
            plot_love(bal_combined, config["smd_threshold"], output_dir)
        
            # Save balance table
            bal_combined.to_csv(os.path.join(output_dir, "balance_table.csv"), index=False)
            print(f"Saved: balance_table.csv")
        
            # Step 4: Outcome analysis
            print(f"\n--- Step 4: Outcome Analysis ---")
            if config["ps_method"] == "matching":
                # Simple comparison in matched data
                t_outcome = df_matched.loc[df_matched[treatment_col] == 1, outcome_col]
                c_outcome = df_matched.loc[df_matched[treatment_col] == 0, outcome_col]
                if config["outcome_type"] == "continuous":
                    stat, p = stats.ttest_ind(t_outcome, c_outcome)
                    diff = t_outcome.mean() - c_outcome.mean()
                    print(f"Mean difference: {diff:.3f}")
                    print(f"Treated: {t_outcome.mean():.3f} ± {t_outcome.std():.3f}")
                    print(f"Control: {c_outcome.mean():.3f} ± {c_outcome.std():.3f}")
                    print(f"t = {stat:.3f}, P = {p:.3f}")
                else:
                    # For binary outcome in matched data
                    tab = pd.crosstab(df_matched[treatment_col], df_matched[outcome_col])
                    print(tab)
            else:
                # Weighted analysis for IPTW/OW
                weighted_outcome_analysis(df, treatment_col, outcome_col,
                                           config["outcome_type"], weights)
        
            print(f"\n{'='*60}")
            print("Propensity score analysis complete.")
        
        
        if __name__ == "__main__":
            main()
        
      • regression.py 14.4 KB
        """
        Template: Regression Analysis (Logistic + Linear)
        Performs logistic regression (binary outcome) or multiple linear regression (continuous outcome).
        Generates OR/coefficient tables, model diagnostics, and publication-ready figures.
        
        Usage:
            Modify the CONFIGURATION section below, then run:
                python regression.py
        
        Input:  CSV with outcome and predictor variables
        Output: coefficient/OR table CSV, diagnostic plots PDF/PNG, summary text
        """
        
        # === REPRODUCIBILITY HEADER ===
        import sys
        import os
        import datetime
        import numpy as np
        import pandas as pd
        from scipy import stats
        
        np.random.seed(42)
        print(f"Date: {datetime.date.today()}")
        print(f"Python: {sys.version}")
        print(f"numpy: {np.__version__}, pandas: {pd.__version__}, scipy: {stats.scipy.__version__}")
        
        try:
            import statsmodels.api as sm
            from statsmodels.stats.outliers_influence import variance_inflation_factor
            print(f"statsmodels: {sm.__version__}")
        except ImportError:
            print("Error: statsmodels not installed. Install with: pip install statsmodels")
            sys.exit(1)
        
        try:
            from sklearn.metrics import roc_auc_score, brier_score_loss
            import sklearn
            print(f"sklearn: {sklearn.__version__}")
        except ImportError:
            print("Warning: scikit-learn not installed. Some metrics unavailable.")
        
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        
        STYLE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "style", "figure_style.mplstyle")
        if os.path.exists(STYLE_PATH):
            plt.style.use(STYLE_PATH)
        
        
        # === CONFIGURATION ===
        CONFIG = {
            # Data
            "data_path": "data.csv",
            "output_dir": ".",
        
            # Regression type: "logistic" or "linear"
            "regression_type": "logistic",
        
            # Variables
            "outcome": "event",
            "predictors": ["age", "sex", "bmi", "smoking"],
            "categorical_vars": ["sex", "smoking"],
        
            # Options
            "run_univariable": True,  # Run univariable analysis before multivariable
            "vif_threshold": 5.0,
            "epv_minimum": 10,
        }
        
        
        # === HELPER FUNCTIONS ===
        
        def calculate_vif(X):
            """Calculate VIF for each predictor."""
            vif_data = pd.DataFrame()
            vif_data["Variable"] = X.columns
            vif_data["VIF"] = [variance_inflation_factor(X.values, i) for i in range(X.shape[1])]
            return vif_data
        
        
        def logistic_or_table(model, var_names):
            """Generate OR table from logistic regression results."""
            or_vals = np.exp(model.params)
            ci = np.exp(model.conf_int())
            table = pd.DataFrame({
                "Variable": var_names,
                "OR": or_vals,
                "CI_lower": ci[0],
                "CI_upper": ci[1],
                "P_value": model.pvalues
            })
            table["OR_CI"] = table.apply(
                lambda r: f"{r['OR']:.2f} ({r['CI_lower']:.2f}-{r['CI_upper']:.2f})", axis=1
            )
            return table
        
        
        def linear_coef_table(model, var_names):
            """Generate coefficient table from linear regression results."""
            ci = model.conf_int()
            table = pd.DataFrame({
                "Variable": var_names,
                "Coefficient": model.params,
                "CI_lower": ci[0],
                "CI_upper": ci[1],
                "P_value": model.pvalues
            })
            table["Coef_CI"] = table.apply(
                lambda r: f"{r['Coefficient']:.3f} ({r['CI_lower']:.3f} to {r['CI_upper']:.3f})", axis=1
            )
            return table
        
        
        def hosmer_lemeshow_test(y_true, y_pred, n_groups=10):
            """Hosmer-Lemeshow goodness-of-fit test."""
            data = pd.DataFrame({"y": y_true, "p": y_pred})
            data["group"] = pd.qcut(data["p"], n_groups, duplicates="drop")
            grouped = data.groupby("group").agg(
                obs=("y", "sum"),
                exp=("p", "sum"),
                n=("y", "count"),
                mean_p=("p", "mean")
            )
            chi2 = ((grouped["obs"] - grouped["exp"]) ** 2 /
                    (grouped["exp"] * (1 - grouped["mean_p"]))).sum()
            df = len(grouped) - 2
            p_value = 1 - stats.chi2.cdf(chi2, df)
            return chi2, df, p_value
        
        
        def plot_diagnostic_4panel(model, outcome_name, output_dir):
            """Generate 4-panel diagnostic plot for linear regression."""
            fig, axes = plt.subplots(2, 2, figsize=(12, 10))
        
            fitted = model.fittedvalues
            residuals = model.resid
            std_resid = model.get_influence().resid_studentized_internal
            leverage = model.get_influence().hat_matrix_diag
            cooks_d = model.get_influence().cooks_distance[0]
        
            # 1. Residuals vs Fitted
            axes[0, 0].scatter(fitted, residuals, alpha=0.5, s=20)
            axes[0, 0].axhline(y=0, color="red", linestyle="--")
            axes[0, 0].set_xlabel("Fitted values")
            axes[0, 0].set_ylabel("Residuals")
            axes[0, 0].set_title("Residuals vs Fitted")
        
            # 2. Q-Q plot
            stats.probplot(std_resid, plot=axes[0, 1])
            axes[0, 1].set_title("Normal Q-Q")
        
            # 3. Scale-Location
            axes[1, 0].scatter(fitted, np.sqrt(np.abs(std_resid)), alpha=0.5, s=20)
            axes[1, 0].set_xlabel("Fitted values")
            axes[1, 0].set_ylabel("√|Standardized residuals|")
            axes[1, 0].set_title("Scale-Location")
        
            # 4. Residuals vs Leverage
            axes[1, 1].scatter(leverage, std_resid, alpha=0.5, s=20)
            axes[1, 1].axhline(y=0, color="red", linestyle="--")
            # Cook's distance contours
            n = len(fitted)
            threshold = 4 / n
            high_cook = cooks_d > threshold
            if high_cook.any():
                axes[1, 1].scatter(leverage[high_cook], std_resid[high_cook],
                                   color="red", s=40, zorder=5, label=f"Cook's D > {threshold:.3f}")
                axes[1, 1].legend()
            axes[1, 1].set_xlabel("Leverage")
            axes[1, 1].set_ylabel("Standardized residuals")
            axes[1, 1].set_title("Residuals vs Leverage")
        
            plt.suptitle(f"Diagnostic Plots: {outcome_name}", fontsize=14, y=1.02)
            plt.tight_layout()
        
            for ext in ["pdf", "png"]:
                fig.savefig(os.path.join(output_dir, f"diagnostic_plots.{ext}"),
                            dpi=300, bbox_inches="tight")
            plt.close(fig)
            print(f"Saved: diagnostic_plots.pdf/.png")
        
            # Report influential observations
            n_influential = high_cook.sum()
            if n_influential > 0:
                print(f"\nWarning: {n_influential} observation(s) with Cook's D > {threshold:.3f}")
        
        
        def plot_forest_or(or_table, output_dir, title="Multivariable Logistic Regression"):
            """Forest plot for odds ratios."""
            # Exclude intercept
            plot_data = or_table[or_table["Variable"] != "const"].copy()
            plot_data = plot_data.iloc[::-1]  # reverse for top-down display
        
            fig, ax = plt.subplots(figsize=(8, max(3, len(plot_data) * 0.6)))
        
            y_pos = range(len(plot_data))
            ax.errorbar(
                plot_data["OR"], y_pos,
                xerr=[plot_data["OR"] - plot_data["CI_lower"],
                      plot_data["CI_upper"] - plot_data["OR"]],
                fmt="o", color="navy", capsize=4, markersize=6
            )
            ax.axvline(x=1, color="red", linestyle="--", alpha=0.7)
            ax.set_yticks(y_pos)
            ax.set_yticklabels(plot_data["Variable"])
            ax.set_xlabel("Odds Ratio (95% CI)")
            ax.set_title(title)
            ax.set_xscale("log")
        
            plt.tight_layout()
            for ext in ["pdf", "png"]:
                fig.savefig(os.path.join(output_dir, f"forest_plot_or.{ext}"),
                            dpi=300, bbox_inches="tight")
            plt.close(fig)
            print(f"Saved: forest_plot_or.pdf/.png")
        
        
        # === MAIN ANALYSIS ===
        
        def run_logistic(df, config):
            """Run logistic regression analysis."""
            outcome = config["outcome"]
            predictors = config["predictors"]
            output_dir = config["output_dir"]
        
            y = df[outcome]
            n_events = y.sum()
            n_total = len(y)
            epv = n_events / len(predictors)
        
            print(f"\n{'='*60}")
            print(f"LOGISTIC REGRESSION")
            print(f"{'='*60}")
            print(f"Outcome: {outcome}")
            print(f"N = {n_total}, Events = {n_events} ({100*n_events/n_total:.1f}%)")
            print(f"Predictors: {len(predictors)}")
            print(f"EPV = {epv:.1f} (minimum recommended: {config['epv_minimum']})")
            if epv < config["epv_minimum"]:
                print(f"⚠ WARNING: EPV < {config['epv_minimum']}. Model may be unstable. "
                      "Consider reducing predictors or using penalized regression.")
        
            # --- Univariable analysis ---
            if config["run_univariable"]:
                print(f"\n--- Univariable Analysis ---")
                uni_results = []
                for var in predictors:
                    X_uni = sm.add_constant(df[[var]])
                    try:
                        model_uni = sm.Logit(y, X_uni).fit(disp=0)
                        or_val = np.exp(model_uni.params[var])
                        ci = np.exp(model_uni.conf_int().loc[var])
                        p_val = model_uni.pvalues[var]
                        uni_results.append({
                            "Variable": var,
                            "Uni_OR": or_val,
                            "Uni_CI_lower": ci[0],
                            "Uni_CI_upper": ci[1],
                            "Uni_P": p_val,
                            "Uni_OR_CI": f"{or_val:.2f} ({ci[0]:.2f}-{ci[1]:.2f})"
                        })
                    except Exception as e:
                        print(f"  {var}: failed ({e})")
                        uni_results.append({"Variable": var, "Uni_OR": np.nan})
                uni_df = pd.DataFrame(uni_results)
                print(uni_df[["Variable", "Uni_OR_CI", "Uni_P"]].to_string(index=False))
        
            # --- Multivariable analysis ---
            print(f"\n--- Multivariable Analysis ---")
            X = sm.add_constant(df[predictors])
            model = sm.Logit(y, X).fit(disp=0)
            print(model.summary2())
        
            # OR table
            var_names = ["const"] + predictors
            multi_table = logistic_or_table(model, var_names)
        
            # VIF (exclude intercept)
            vif_df = calculate_vif(df[predictors])
            print(f"\n--- VIF ---")
            print(vif_df.to_string(index=False))
            high_vif = vif_df[vif_df["VIF"] > config["vif_threshold"]]
            if len(high_vif) > 0:
                print(f"⚠ WARNING: Variables with VIF > {config['vif_threshold']}: "
                      f"{', '.join(high_vif['Variable'])}")
        
            # C-statistic
            y_pred = model.predict(X)
            c_stat = roc_auc_score(y, y_pred)
            # Bootstrap CI for C-statistic
            n_boot = 1000
            c_boots = []
            for i in range(n_boot):
                rng = np.random.RandomState(i)
                idx = rng.choice(len(y), len(y), replace=True)
                try:
                    c_boots.append(roc_auc_score(y.iloc[idx], y_pred.iloc[idx]))
                except ValueError:
                    continue
            c_ci = np.percentile(c_boots, [2.5, 97.5])
            print(f"\nC-statistic (AUC) = {c_stat:.3f} (95% CI: {c_ci[0]:.3f}-{c_ci[1]:.3f})")
        
            # Hosmer-Lemeshow
            hl_chi2, hl_df, hl_p = hosmer_lemeshow_test(y, y_pred)
            print(f"Hosmer-Lemeshow: chi2 = {hl_chi2:.2f}, df = {hl_df}, P = {hl_p:.3f}")
        
            # Brier score
            brier = brier_score_loss(y, y_pred)
            print(f"Brier score = {brier:.4f}")
        
            # Merge univariable + multivariable
            if config["run_univariable"]:
                combined = uni_df.merge(multi_table[multi_table["Variable"] != "const"],
                                        on="Variable", how="outer")
                combined.to_csv(os.path.join(output_dir, "logistic_regression_table.csv"), index=False)
            else:
                multi_table.to_csv(os.path.join(output_dir, "logistic_regression_table.csv"), index=False)
        
            # Forest plot
            plot_forest_or(multi_table, output_dir)
        
            # Results text
            print(f"\n--- Manuscript Text ---")
            print(f"The logistic regression model demonstrated a C-statistic of {c_stat:.3f} "
                  f"(95% CI, {c_ci[0]:.3f}-{c_ci[1]:.3f}) and adequate calibration "
                  f"(Hosmer-Lemeshow P = {hl_p:.2f}).")
        
            return model
        
        
        def run_linear(df, config):
            """Run multiple linear regression analysis."""
            outcome = config["outcome"]
            predictors = config["predictors"]
            output_dir = config["output_dir"]
        
            y = df[outcome]
            n_total = len(y)
        
            print(f"\n{'='*60}")
            print(f"MULTIPLE LINEAR REGRESSION")
            print(f"{'='*60}")
            print(f"Outcome: {outcome}")
            print(f"N = {n_total}")
            print(f"Predictors: {len(predictors)}")
            print(f"N per predictor: {n_total / len(predictors):.0f} (recommended >= 10-20)")
        
            # --- Model fitting ---
            X = sm.add_constant(df[predictors])
            model = sm.OLS(y, X).fit()
            print(model.summary2())
        
            # Coefficient table
            var_names = ["const"] + predictors
            coef_table = linear_coef_table(model, var_names)
            coef_table["R_squared"] = ""
            coef_table.loc[0, "R_squared"] = f"R²={model.rsquared:.3f}, Adj.R²={model.rsquared_adj:.3f}"
            coef_table.to_csv(os.path.join(output_dir, "linear_regression_table.csv"), index=False)
            print(f"\nR² = {model.rsquared:.3f}")
            print(f"Adjusted R² = {model.rsquared_adj:.3f}")
        
            # VIF
            vif_df = calculate_vif(df[predictors])
            print(f"\n--- VIF ---")
            print(vif_df.to_string(index=False))
            high_vif = vif_df[vif_df["VIF"] > config["vif_threshold"]]
            if len(high_vif) > 0:
                print(f"⚠ WARNING: Variables with VIF > {config['vif_threshold']}: "
                      f"{', '.join(high_vif['Variable'])}")
        
            # Diagnostic plots
            plot_diagnostic_4panel(model, outcome, output_dir)
        
            # Normality of residuals
            if n_total < 50:
                stat, p = stats.shapiro(model.resid)
                print(f"\nShapiro-Wilk test on residuals: W = {stat:.4f}, P = {p:.3f}")
            else:
                stat, p = stats.kstest(model.resid, "norm",
                                       args=(model.resid.mean(), model.resid.std()))
                print(f"\nKolmogorov-Smirnov test on residuals: D = {stat:.4f}, P = {p:.3f}")
        
            # Results text
            print(f"\n--- Manuscript Text ---")
            print(f"Multiple linear regression was performed with {outcome} as the dependent variable. "
                  f"The model explained {model.rsquared_adj*100:.1f}% of the variance "
                  f"(adjusted R² = {model.rsquared_adj:.2f}).")
        
            return model
        
        
        # === ENTRY POINT ===
        
        if __name__ == "__main__":
            # Load data
            df = pd.read_csv(CONFIG["data_path"])
            print(f"Data loaded: {df.shape[0]} rows x {df.shape[1]} columns")
        
            # Encode categorical variables if needed
            for cat_var in CONFIG.get("categorical_vars", []):
                if cat_var in df.columns and df[cat_var].dtype == "object":
                    df[cat_var] = pd.Categorical(df[cat_var]).codes
        
            # Drop rows with missing values in analysis variables
            analysis_vars = [CONFIG["outcome"]] + CONFIG["predictors"]
            n_before = len(df)
            df_complete = df[analysis_vars].dropna()
            n_after = len(df_complete)
            if n_before != n_after:
                print(f"Missing data: {n_before - n_after} rows excluded ({100*(n_before-n_after)/n_before:.1f}%)")
                if (n_before - n_after) / n_before > 0.05:
                    print("⚠ Consider multiple imputation (> 5% missing). "
                          "See analysis_guides/missing_data.md")
        
            # Run appropriate regression
            if CONFIG["regression_type"] == "logistic":
                model = run_logistic(df_complete, CONFIG)
            elif CONFIG["regression_type"] == "linear":
                model = run_linear(df_complete, CONFIG)
            else:
                print(f"Error: Unknown regression type '{CONFIG['regression_type']}'")
                sys.exit(1)
        
            print(f"\n{'='*60}")
            print("Analysis complete.")
        
      • repeated_measures.py 14.9 KB
        """
        Template: Repeated Measures / Mixed Models Analysis
        Supports RM ANOVA, Linear Mixed Models (LMM), and GEE for longitudinal data.
        Generates spaghetti plots, model summaries, and Time x Group interaction results.
        
        Usage:
            Modify the CONFIGURATION section below, then run:
                python repeated_measures.py
        
        Input:  CSV in wide or long format with repeated measurements
        Output: model summary, spaghetti plot PDF/PNG, group mean trajectory plot
        """
        
        # === REPRODUCIBILITY HEADER ===
        import sys
        import os
        import datetime
        import numpy as np
        import pandas as pd
        from scipy import stats
        
        np.random.seed(42)
        print(f"Date: {datetime.date.today()}")
        print(f"Python: {sys.version}")
        print(f"numpy: {np.__version__}, pandas: {pd.__version__}, scipy: {stats.scipy.__version__}")
        
        try:
            import statsmodels.api as sm
            import statsmodels.formula.api as smf
            print(f"statsmodels: {sm.__version__}")
        except ImportError:
            print("Error: statsmodels not installed. Install with: pip install statsmodels")
            sys.exit(1)
        
        try:
            import pingouin as pg
            print(f"pingouin: {pg.__version__}")
        except ImportError:
            print("Warning: pingouin not installed. RM ANOVA unavailable. Install: pip install pingouin")
            pg = None
        
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        
        STYLE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "style", "figure_style.mplstyle")
        if os.path.exists(STYLE_PATH):
            plt.style.use(STYLE_PATH)
        
        
        # === CONFIGURATION ===
        CONFIG = {
            # Data
            "data_path": "data.csv",
            "output_dir": ".",
            "data_format": "wide",  # "wide" or "long"
        
            # Variables (for wide format)
            "id_col": "subject_id",
            "group_col": "group",                # between-subject factor (optional, None if single group)
            "time_columns": ["t0", "t1", "t2", "t3"],  # column names for each time point
            "outcome_name": "score",             # name for the outcome in long format
        
            # Variables (for long format) -- used if data_format == "long"
            "time_col": "time",
            "outcome_col": "score",
        
            # Analysis method: "rm_anova", "lmm", "gee"
            "method": "lmm",
        
            # LMM options
            "random_effects": "intercept",  # "intercept" or "intercept_slope"
        
            # Covariates (optional, for LMM/GEE only)
            "covariates": [],
        }
        
        
        # === HELPER FUNCTIONS ===
        
        def wide_to_long(df, id_col, group_col, time_columns, outcome_name):
            """Convert wide format to long format."""
            if group_col and group_col in df.columns:
                id_vars = [id_col, group_col]
            else:
                id_vars = [id_col]
        
            df_long = df.melt(
                id_vars=id_vars,
                value_vars=time_columns,
                var_name="time_label",
                value_name=outcome_name
            )
        
            # Create numeric time variable
            time_map = {col: i for i, col in enumerate(time_columns)}
            df_long["time"] = df_long["time_label"].map(time_map)
        
            return df_long
        
        
        def plot_spaghetti(df_long, id_col, time_col, outcome_col, group_col, output_dir):
            """Generate spaghetti plot showing individual trajectories."""
            fig, axes = plt.subplots(1, 2, figsize=(14, 6))
        
            # Left: individual trajectories
            ax = axes[0]
            if group_col and group_col in df_long.columns:
                groups = df_long[group_col].unique()
                colors = plt.cm.tab10(np.linspace(0, 1, len(groups)))
                for grp, color in zip(groups, colors):
                    grp_data = df_long[df_long[group_col] == grp]
                    for subj_id in grp_data[id_col].unique():
                        subj = grp_data[grp_data[id_col] == subj_id]
                        ax.plot(subj[time_col], subj[outcome_col], alpha=0.2, color=color, linewidth=0.5)
                    # Add group label
                    ax.plot([], [], color=color, alpha=0.5, label=f"{grp}")
                ax.legend(title=group_col)
            else:
                for subj_id in df_long[id_col].unique():
                    subj = df_long[df_long[id_col] == subj_id]
                    ax.plot(subj[time_col], subj[outcome_col], alpha=0.15, color="steelblue", linewidth=0.5)
        
            ax.set_xlabel("Time")
            ax.set_ylabel(outcome_col)
            ax.set_title("Individual Trajectories")
        
            # Right: group means with error bars
            ax = axes[1]
            if group_col and group_col in df_long.columns:
                for grp, color in zip(groups, colors):
                    grp_data = df_long[df_long[group_col] == grp]
                    means = grp_data.groupby(time_col)[outcome_col].mean()
                    sems = grp_data.groupby(time_col)[outcome_col].sem()
                    ax.errorbar(means.index, means.values, yerr=1.96 * sems.values,
                                marker="o", capsize=4, label=f"{grp}", color=color)
                ax.legend(title=group_col)
            else:
                means = df_long.groupby(time_col)[outcome_col].mean()
                sems = df_long.groupby(time_col)[outcome_col].sem()
                ax.errorbar(means.index, means.values, yerr=1.96 * sems.values,
                            marker="o", capsize=4, color="steelblue")
        
            ax.set_xlabel("Time")
            ax.set_ylabel(f"{outcome_col} (mean ± 95% CI)")
            ax.set_title("Group Mean Trajectories")
        
            plt.tight_layout()
            for ext in ["pdf", "png"]:
                fig.savefig(os.path.join(output_dir, f"trajectories.{ext}"),
                            dpi=300, bbox_inches="tight")
            plt.close(fig)
            print(f"Saved: trajectories.pdf/.png")
        
        
        def run_rm_anova(df_long, id_col, time_col, outcome_col, group_col):
            """Run repeated measures ANOVA with Greenhouse-Geisser correction."""
            if pg is None:
                print("Error: pingouin package required for RM ANOVA. Install: pip install pingouin")
                return None
        
            print(f"\n{'='*60}")
            print("REPEATED MEASURES ANOVA")
            print(f"{'='*60}")
        
            # Missing data check
            n_subjects = df_long[id_col].nunique()
            n_timepoints = df_long[time_col].nunique()
            n_expected = n_subjects * n_timepoints
            n_actual = len(df_long.dropna(subset=[outcome_col]))
            n_missing = n_expected - n_actual
            if n_missing > 0:
                pct_missing = 100 * n_missing / n_expected
                print(f"⚠ Missing observations: {n_missing}/{n_expected} ({pct_missing:.1f}%)")
                if pct_missing > 5:
                    print("⚠ > 5% missing. RM ANOVA uses complete cases only. Consider LMM instead.")
                # Drop incomplete subjects
                complete_subjects = df_long.groupby(id_col)[outcome_col].count()
                complete_ids = complete_subjects[complete_subjects == n_timepoints].index
                df_long = df_long[df_long[id_col].isin(complete_ids)]
                print(f"  Complete subjects: {len(complete_ids)}/{n_subjects}")
        
            if group_col and group_col in df_long.columns:
                # Mixed ANOVA (between + within)
                aov = pg.mixed_anova(
                    data=df_long, dv=outcome_col, within=time_col,
                    between=group_col, subject=id_col, correction=True
                )
            else:
                # One-way RM ANOVA
                aov = pg.rm_anova(
                    data=df_long, dv=outcome_col, within=time_col,
                    subject=id_col, correction=True
                )
        
            print(f"\nRM ANOVA Results:")
            print(aov.to_string())
        
            # Sphericity check (Mauchly's test) via pingouin
            spher = pg.sphericity(
                data=df_long, dv=outcome_col, within=time_col,
                subject=id_col
            )
            if isinstance(spher, tuple):
                spher_result, W, chi2, dof, pval = spher
            else:
                spher_result = spher
                pval = None
        
            print(f"\nMauchly's sphericity test: {'Passed' if spher_result else 'Violated'}")
            if pval is not None:
                print(f"  P = {pval:.3f}")
            if not spher_result:
                print("  → Greenhouse-Geisser correction applied (see eps column)")
        
            # Post-hoc pairwise comparisons
            if group_col and group_col in df_long.columns:
                print(f"\n--- Post-hoc: Time within each group ---")
                for grp in df_long[group_col].unique():
                    grp_data = df_long[df_long[group_col] == grp]
                    pw = pg.pairwise_tests(
                        data=grp_data, dv=outcome_col, within=time_col,
                        subject=id_col, padjust="bonf"
                    )
                    print(f"\nGroup: {grp}")
                    print(pw[["Contrast", "A", "B", "T", "p-unc", "p-corr"]].to_string(index=False))
        
            return aov
        
        
        def run_lmm(df_long, id_col, time_col, outcome_col, group_col, random_effects, covariates):
            """Run Linear Mixed Model."""
            print(f"\n{'='*60}")
            print("LINEAR MIXED MODEL")
            print(f"{'='*60}")
        
            # Build formula
            fixed_parts = [time_col]
            if group_col and group_col in df_long.columns:
                fixed_parts.append(group_col)
                fixed_parts.append(f"{time_col}:{group_col}")
            fixed_parts.extend(covariates)
        
            formula = f"{outcome_col} ~ " + " + ".join(fixed_parts)
            print(f"Formula: {formula}")
        
            # Random effects
            if random_effects == "intercept_slope":
                re_formula = f"~{time_col}"
                print(f"Random effects: intercept + slope")
            else:
                re_formula = "~1"
                print(f"Random effects: intercept only")
        
            # Fit model
            try:
                model = smf.mixedlm(formula, data=df_long, groups=df_long[id_col],
                                     re_formula=re_formula)
                result = model.fit(reml=True)
                print(f"\n{result.summary()}")
        
                # Extract key results
                print(f"\n--- Fixed Effects ---")
                for name, coef in result.fe_params.items():
                    ci = result.conf_int().loc[name]
                    p = result.pvalues[name]
                    print(f"  {name}: β = {coef:.3f} (95% CI: {ci[0]:.3f} to {ci[1]:.3f}), P = {p:.3f}")
        
                # Random effects variance
                print(f"\n--- Random Effects ---")
                print(f"  Group variance: {result.cov_re.iloc[0, 0]:.4f}")
                print(f"  Residual variance: {result.scale:.4f}")
                print(f"  ICC (approx): {result.cov_re.iloc[0, 0] / (result.cov_re.iloc[0, 0] + result.scale):.3f}")
        
                # Model fit
                print(f"\nAIC: {result.aic:.1f}")
                print(f"BIC: {result.bic:.1f}")
                print(f"Log-likelihood: {result.llf:.1f}")
        
                # Save results
                fe_table = pd.DataFrame({
                    "Variable": result.fe_params.index,
                    "Coefficient": result.fe_params.values,
                    "CI_lower": result.conf_int()[0].values,
                    "CI_upper": result.conf_int()[1].values,
                    "P_value": result.pvalues.values
                })
        
                return result, fe_table
        
            except Exception as e:
                if random_effects == "intercept_slope":
                    print(f"⚠ Model with random slope failed to converge: {e}")
                    print("  Falling back to random intercept only...")
                    return run_lmm(df_long, id_col, time_col, outcome_col,
                                   group_col, "intercept", covariates)
                else:
                    print(f"Error: LMM failed: {e}")
                    return None, None
        
        
        def run_gee(df_long, id_col, time_col, outcome_col, group_col, covariates):
            """Run Generalized Estimating Equations."""
            from statsmodels.genmod.generalized_estimating_equations import GEE
            from statsmodels.genmod.cov_struct import Exchangeable
        
            print(f"\n{'='*60}")
            print("GEE (Generalized Estimating Equations)")
            print(f"{'='*60}")
        
            # Build formula
            fixed_parts = [time_col]
            if group_col and group_col in df_long.columns:
                fixed_parts.append(group_col)
                fixed_parts.append(f"{time_col}:{group_col}")
            fixed_parts.extend(covariates)
        
            formula = f"{outcome_col} ~ " + " + ".join(fixed_parts)
            print(f"Formula: {formula}")
        
            # Sort by id and time for proper correlation structure
            df_sorted = df_long.sort_values([id_col, time_col]).reset_index(drop=True)
        
            # Check cluster size
            n_clusters = df_sorted[id_col].nunique()
            print(f"Number of clusters (subjects): {n_clusters}")
            if n_clusters < 30:
                print("⚠ WARNING: GEE requires >= 30-40 clusters for reliable sandwich SE.")
        
            # Fit with exchangeable correlation
            try:
                model = GEE.from_formula(
                    formula, groups=id_col, data=df_sorted,
                    cov_struct=Exchangeable(), family=sm.families.Gaussian()
                )
                result = model.fit()
                print(f"\n{result.summary()}")
        
                print(f"\n--- Population-averaged Effects ---")
                for name in result.params.index:
                    coef = result.params[name]
                    ci = result.conf_int().loc[name]
                    p = result.pvalues[name]
                    print(f"  {name}: β = {coef:.3f} (95% CI: {ci[0]:.3f} to {ci[1]:.3f}), P = {p:.3f}")
        
                return result
        
            except Exception as e:
                print(f"Error: GEE failed: {e}")
                return None
        
        
        # === MAIN ANALYSIS ===
        
        def main():
            config = CONFIG
            output_dir = config["output_dir"]
        
            # Load data
            df = pd.read_csv(config["data_path"])
            print(f"Data loaded: {df.shape[0]} rows x {df.shape[1]} columns")
        
            id_col = config["id_col"]
            group_col = config.get("group_col")
            outcome_col = config.get("outcome_col", config.get("outcome_name", "score"))
            time_col = config.get("time_col", "time")
        
            # Convert to long format if needed
            if config["data_format"] == "wide":
                print("Converting wide → long format...")
                df_long = wide_to_long(
                    df, id_col, group_col,
                    config["time_columns"], config["outcome_name"]
                )
                outcome_col = config["outcome_name"]
                time_col = "time"
                print(f"Long format: {df_long.shape[0]} rows")
            else:
                df_long = df.copy()
        
            # Summary
            n_subjects = df_long[id_col].nunique()
            n_timepoints = df_long[time_col].nunique()
            print(f"\nSubjects: {n_subjects}")
            print(f"Time points: {n_timepoints}")
            if group_col and group_col in df_long.columns:
                print(f"Groups: {df_long[group_col].value_counts().to_dict()}")
        
            # Missing data report
            n_expected = n_subjects * n_timepoints
            n_observed = df_long[outcome_col].notna().sum()
            n_missing = n_expected - n_observed
            print(f"Observations: {n_observed}/{n_expected} ({100*n_missing/n_expected:.1f}% missing)")
        
            # Descriptive statistics per time point
            print(f"\n--- Descriptive Statistics by Time ---")
            desc = df_long.groupby(time_col)[outcome_col].agg(["count", "mean", "std"])
            print(desc.to_string())
        
            if group_col and group_col in df_long.columns:
                print(f"\n--- By Group and Time ---")
                desc_grp = df_long.groupby([group_col, time_col])[outcome_col].agg(["count", "mean", "std"])
                print(desc_grp.to_string())
        
            # Spaghetti plot
            plot_spaghetti(df_long, id_col, time_col, outcome_col, group_col, output_dir)
        
            # Run analysis
            method = config["method"]
            if method == "rm_anova":
                result = run_rm_anova(df_long, id_col, time_col, outcome_col, group_col)
            elif method == "lmm":
                result, fe_table = run_lmm(
                    df_long, id_col, time_col, outcome_col, group_col,
                    config["random_effects"], config.get("covariates", [])
                )
                if fe_table is not None:
                    fe_table.to_csv(os.path.join(output_dir, "lmm_results.csv"), index=False)
                    print(f"Saved: lmm_results.csv")
            elif method == "gee":
                result = run_gee(
                    df_long, id_col, time_col, outcome_col, group_col,
                    config.get("covariates", [])
                )
            else:
                print(f"Error: Unknown method '{method}'")
                sys.exit(1)
        
            print(f"\n{'='*60}")
            print("Repeated measures analysis complete.")
        
        
        if __name__ == "__main__":
            main()
        
      • sample_size.R 17.5 KB · in bundle
      • survey_weighted_analysis.py 13.4 KB
        """
        Template: Survey-Weighted Analysis for National Health Surveys
        Supports KNHANES, NHANES, KCHS and similar complex survey data.
        Produces weighted descriptives, wOR tables, and subgroup analyses.
        
        NOTE: For publication-quality survey analysis, R (survey package) is strongly
        recommended. This Python template handles basic weighted analysis but cannot
        fully account for strata/cluster in variance estimation. Use the companion
        R code blocks in analysis_guides/survey_weighted.md for complex designs.
        
        Usage:
            Modify the CONFIGURATION section below, then run:
                python survey_weighted_analysis.py
        
        Input:  CSV with survey design variables (weight, strata, cluster) + analysis variables
        Output: Weighted Table 1, wOR table, subgroup results
        """
        
        # === REPRODUCIBILITY HEADER ===
        import sys
        import os
        import datetime
        import numpy as np
        import pandas as pd
        from scipy import stats
        
        np.random.seed(42)
        print(f"Date: {datetime.date.today()}")
        print(f"Python: {sys.version}")
        print(f"numpy: {np.__version__}, pandas: {pd.__version__}, scipy: {stats.scipy.__version__}")
        
        try:
            import statsmodels.api as sm
            print(f"statsmodels: {sm.__version__}")
        except ImportError:
            print("Error: statsmodels not installed. Install with: pip install statsmodels")
            sys.exit(1)
        
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        
        STYLE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "style", "figure_style.mplstyle")
        if os.path.exists(STYLE_PATH):
            plt.style.use(STYLE_PATH)
        
        
        # === CONFIGURATION ===
        CONFIG = {
            # Data
            "data_path": "data.csv",
            "output_dir": ".",
        
            # Survey design variables
            "weight": "wt_itvex",       # Sampling weight column
            "strata": "kstrata",        # Stratification variable (for R code generation)
            "cluster": "psu",           # Cluster/PSU variable (for R code generation)
            "dataset_name": "KNHANES",  # "KNHANES", "NHANES", "KCHS"
        
            # Analysis variables
            "outcome": "diabetes",           # Binary outcome (0/1)
            "exposure": "depression",        # Primary exposure (binary or categorical)
            "covariates_model1": ["age", "sex"],
            "covariates_model2": ["age", "sex", "income", "education",
                                  "smoking", "alcohol", "bmi"],
            "categorical_vars": ["sex", "income", "education", "smoking"],
        
            # Subgroup stratification variables
            "subgroup_vars": ["sex", "age_group", "income", "obesity"],
        
            # Output
            "effect_measure": "wOR",  # "wOR" for logistic, "beta" for linear
        }
        
        
        # === HELPER FUNCTIONS ===
        
        def weighted_mean(x, w):
            """Calculate weighted mean."""
            return np.average(x, weights=w)
        
        
        def weighted_std(x, w):
            """Calculate weighted standard deviation."""
            wm = weighted_mean(x, w)
            return np.sqrt(np.average((x - wm) ** 2, weights=w))
        
        
        def weighted_proportion(x, w, level=1):
            """Calculate weighted proportion for a binary/categorical variable."""
            mask = x == level
            return np.average(mask, weights=w)
        
        
        def weighted_smd(x, treatment, weights, is_binary=False):
            """Calculate weighted standardized mean difference."""
            t_mask = treatment == 1
            c_mask = treatment == 0
        
            w1, w0 = weights[t_mask], weights[c_mask]
            x1, x0 = x[t_mask], x[c_mask]
        
            wm1 = np.average(x1, weights=w1)
            wm0 = np.average(x0, weights=w0)
        
            if is_binary:
                denom = np.sqrt((wm1 * (1 - wm1) + wm0 * (1 - wm0)) / 2)
            else:
                wv1 = np.average((x1 - wm1) ** 2, weights=w1)
                wv0 = np.average((x0 - wm0) ** 2, weights=w0)
                denom = np.sqrt((wv1 + wv0) / 2)
        
            return (wm1 - wm0) / denom if denom > 0 else 0.0
        
        
        def weighted_table1(df, group_col, continuous_vars, categorical_vars, weight_col):
            """Generate weighted Table 1 with group comparison."""
            groups = sorted(df[group_col].unique())
            results = []
        
            for var in continuous_vars:
                row = {"Variable": var, "Type": "continuous"}
                for g in groups:
                    mask = df[group_col] == g
                    wm = weighted_mean(df.loc[mask, var].values, df.loc[mask, weight_col].values)
                    ws = weighted_std(df.loc[mask, var].values, df.loc[mask, weight_col].values)
                    row[f"Group_{g}"] = f"{wm:.1f} ({ws:.1f})"
                # Weighted SMD
                smd = weighted_smd(
                    df[var].values, df[group_col].values,
                    df[weight_col].values, is_binary=False
                )
                row["SMD"] = f"{abs(smd):.3f}"
                results.append(row)
        
            for var in categorical_vars:
                levels = sorted(df[var].unique())
                for level in levels:
                    row = {"Variable": f"  {var} = {level}", "Type": "categorical"}
                    binary = (df[var] == level).astype(int)
                    for g in groups:
                        mask = df[group_col] == g
                        wp = weighted_proportion(binary[mask].values, df.loc[mask, weight_col].values)
                        n = mask.sum()
                        row[f"Group_{g}"] = f"{int(n * wp)} ({wp*100:.1f}%)"
                    smd = weighted_smd(
                        binary.values, df[group_col].values,
                        df[weight_col].values, is_binary=True
                    )
                    row["SMD"] = f"{abs(smd):.3f}"
                    results.append(row)
        
            return pd.DataFrame(results)
        
        
        def weighted_logistic(df, outcome_col, exposure_col, covariates, weight_col):
            """Run weighted logistic regression and return wOR with 95% CI.
        
            NOTE: This uses frequency weights which approximate survey weights for
            point estimates but do NOT correctly estimate variance for complex designs.
            For publication, generate and run the R code from survey_weighted.md.
            """
            formula_vars = [exposure_col] + covariates
            X = pd.get_dummies(df[formula_vars], drop_first=True, dtype=float)
            X = sm.add_constant(X)
            y = df[outcome_col]
            w = df[weight_col]
        
            model = sm.GLM(y, X, family=sm.families.Binomial(), freq_weights=w)
            result = model.fit()
        
            # Extract exposure effect
            # Find the exposure column(s) in the dummy-encoded X
            exp_cols = [c for c in X.columns if c.startswith(exposure_col)]
            if not exp_cols:
                exp_cols = [exposure_col]
        
            output_rows = []
            for col in exp_cols:
                coef = result.params[col]
                se = result.bse[col]
                p = result.pvalues[col]
                wor = np.exp(coef)
                ci_lo = np.exp(coef - 1.96 * se)
                ci_hi = np.exp(coef + 1.96 * se)
                output_rows.append({
                    "Variable": col,
                    "wOR": wor,
                    "CI_lower": ci_lo,
                    "CI_upper": ci_hi,
                    "P": p,
                    "formatted": f"{wor:.2f} ({ci_lo:.2f}-{ci_hi:.2f})",
                })
        
            return result, pd.DataFrame(output_rows)
        
        
        def generate_r_code(config):
            """Generate publication-ready R code for the same analysis."""
            dataset = config["dataset_name"]
            weight = config["weight"]
            strata = config["strata"]
            cluster = config["cluster"]
            outcome = config["outcome"]
            exposure = config["exposure"]
            covs_m1 = " + ".join(config["covariates_model1"])
            covs_m2 = " + ".join(config["covariates_model2"])
            subgroups = config["subgroup_vars"]
        
            r_code = f"""# === R Code: Survey-Weighted Analysis ({dataset}) ===
        # Requires: survey, tableone
        # install.packages(c("survey", "tableone"))
        
        library(survey)
        library(tableone)
        
        df <- read.csv("{config['data_path']}")
        
        # Step 1: Declare survey design
        design <- svydesign(
          id = ~{cluster},
          strata = ~{strata},
          weights = ~{weight},
          data = df,
          nest = TRUE
        )
        
        # Step 2: Weighted Table 1
        tab1 <- svyCreateTableOne(
          vars = c({', '.join([f'"{v}"' for v in config['covariates_model2']])}),
          strata = "{exposure}",
          data = design,
          test = TRUE,
          smd = TRUE
        )
        print(tab1, smd = TRUE)
        
        # Step 3: Model 1 (age + sex)
        model1 <- svyglm(
          {outcome} ~ {exposure} + {covs_m1},
          design = design,
          family = quasibinomial()
        )
        exp(cbind(wOR = coef(model1), confint(model1)))
        
        # Step 4: Model 2 (full adjustment)
        model2 <- svyglm(
          {outcome} ~ {exposure} + {covs_m2},
          design = design,
          family = quasibinomial()
        )
        exp(cbind(wOR = coef(model2), confint(model2)))
        
        # Step 5: Subgroup analyses
        """
            for sg in subgroups:
                r_code += f"""
        # Subgroup: {sg}
        for (level in unique(df${sg})) {{
          sub_design <- subset(design, {sg} == level)
          sub_model <- svyglm(
            {outcome} ~ {exposure} + {' + '.join([v for v in config['covariates_model2'] if v != sg])},
            design = sub_design,
            family = quasibinomial()
          )
          cat("\\n{sg} =", level, "\\n")
          print(exp(cbind(wOR = coef(sub_model), confint(sub_model)))["{exposure}", ])
        }}
        """
            return r_code
        
        
        # === MAIN ANALYSIS ===
        
        def main():
            config = CONFIG
            df = pd.read_csv(config["data_path"])
            output_dir = config["output_dir"]
            weight_col = config["weight"]
        
            print(f"Data loaded: {df.shape[0]} rows x {df.shape[1]} columns")
            print(f"Dataset: {config['dataset_name']}")
            print(f"Weight column: {weight_col}")
        
            # Check weight column exists
            if weight_col not in df.columns:
                print(f"ERROR: Weight column '{weight_col}' not found in data.")
                print(f"Available columns: {list(df.columns)}")
                sys.exit(1)
        
            # Drop missing
            analysis_vars = ([config["outcome"], config["exposure"], weight_col] +
                             config["covariates_model2"])
            n_before = len(df)
            df = df.dropna(subset=[v for v in analysis_vars if v in df.columns])
            n_after = len(df)
            if n_before != n_after:
                print(f"Excluded {n_before - n_after} rows with missing data "
                      f"({100*(n_before-n_after)/n_before:.1f}%)")
        
            outcome_col = config["outcome"]
            exposure_col = config["exposure"]
        
            # Weighted sample size
            total_weight = df[weight_col].sum()
            print(f"Unweighted N: {len(df):,}")
            print(f"Weighted N: {total_weight:,.0f}")
        
            print(f"\n{'='*60}")
            print(f"SURVEY-WEIGHTED ANALYSIS")
            print(f"{'='*60}")
        
            # --- Weighted Table 1 ---
            print(f"\n--- Weighted Table 1 ---")
            continuous_vars = [v for v in config["covariates_model2"]
                               if v not in config["categorical_vars"]]
            categorical_vars = [v for v in config["covariates_model2"]
                                if v in config["categorical_vars"]]
        
            tab1 = weighted_table1(
                df, exposure_col, continuous_vars, categorical_vars, weight_col
            )
            print(tab1.to_string(index=False))
            tab1.to_csv(os.path.join(output_dir, "weighted_table1.csv"), index=False)
            print("Saved: weighted_table1.csv")
        
            # --- Model 1: Minimal adjustment ---
            print(f"\n--- Model 1: Adjusted for {', '.join(config['covariates_model1'])} ---")
            result1, wor1 = weighted_logistic(
                df, outcome_col, exposure_col,
                config["covariates_model1"], weight_col
            )
            print(wor1[["Variable", "formatted", "P"]].to_string(index=False))
        
            # --- Model 2: Full adjustment ---
            print(f"\n--- Model 2: Adjusted for {', '.join(config['covariates_model2'])} ---")
            result2, wor2 = weighted_logistic(
                df, outcome_col, exposure_col,
                config["covariates_model2"], weight_col
            )
            print(wor2[["Variable", "formatted", "P"]].to_string(index=False))
        
            # Combine wOR results
            wor_combined = pd.DataFrame({
                "Exposure": wor1["Variable"],
                "Model1_wOR": wor1["formatted"],
                "Model1_P": wor1["P"].map(lambda x: f"{x:.3f}" if x >= 0.001 else "<0.001"),
                "Model2_wOR": wor2["formatted"],
                "Model2_P": wor2["P"].map(lambda x: f"{x:.3f}" if x >= 0.001 else "<0.001"),
            })
            wor_combined.to_csv(os.path.join(output_dir, "wor_results.csv"), index=False)
            print("\nSaved: wor_results.csv")
        
            # --- Subgroup Analyses ---
            print(f"\n--- Subgroup Analyses ---")
            subgroup_results = []
        
            for sg_var in config["subgroup_vars"]:
                if sg_var not in df.columns:
                    print(f"  Skipping {sg_var} (not in data)")
                    continue
        
                covs_no_sg = [v for v in config["covariates_model2"] if v != sg_var]
                for level in sorted(df[sg_var].unique()):
                    subset = df[df[sg_var] == level]
                    if len(subset) < 30:
                        continue
                    try:
                        _, wor_sg = weighted_logistic(
                            subset, outcome_col, exposure_col,
                            covs_no_sg, weight_col
                        )
                        for _, row in wor_sg.iterrows():
                            subgroup_results.append({
                                "Subgroup": sg_var,
                                "Level": level,
                                "wOR": row["formatted"],
                                "P": f"{row['P']:.3f}" if row["P"] >= 0.001 else "<0.001",
                            })
                    except Exception as e:
                        print(f"  {sg_var}={level}: analysis failed ({e})")
        
            if subgroup_results:
                sg_df = pd.DataFrame(subgroup_results)
                print(sg_df.to_string(index=False))
                sg_df.to_csv(os.path.join(output_dir, "subgroup_results.csv"), index=False)
                print("Saved: subgroup_results.csv")
        
            # --- Generate R code ---
            print(f"\n--- R Code (for publication-quality analysis) ---")
            r_code = generate_r_code(config)
            r_path = os.path.join(output_dir, "survey_analysis.R")
            with open(r_path, "w") as f:
                f.write(r_code)
            print(f"Saved: {r_path}")
            print("NOTE: Run the R code for correct variance estimation with strata/cluster.")
        
            # --- Summary ---
            print(f"\n{'='*60}")
            print("Survey-weighted analysis complete.")
            print(f"  Table 1: weighted_table1.csv")
            print(f"  wOR results: wor_results.csv")
            if subgroup_results:
                print(f"  Subgroup results: subgroup_results.csv")
            print(f"  R code: survey_analysis.R")
            print(f"\n⚠ Python results use frequency weights only.")
            print(f"  For publication, run survey_analysis.R with full design specification.")
        
        
        if __name__ == "__main__":
            main()
        
      • survival_analysis.py 12.5 KB
        """
        survival_analysis.py — Publication-ready Survival Analysis
        ===========================================================
        Kaplan-Meier curves, log-rank test, Cox proportional hazards model.
        
        Input CSV columns:
            time        : float — Time to event or censoring (in days/months/years)
            event       : int   — Event indicator (1 = event occurred, 0 = censored)
            group       : str   — Group variable for KM comparison (optional)
            [covariates]: float/int — Covariates for Cox model
        
        Outputs:
            - KM plot with at-risk table (PNG/PDF, 300 DPI)
            - Cox model results table (CSV)
            - Console: complete results summary
        
        Usage:
            python survival_analysis.py --input data.csv \
                --time time_months --event event \
                --group treatment_group \
                --covariates age sex bmi comorbidity \
                --time-unit "Months" \
                --output survival_analysis
        
        Dependencies:
            pip install lifelines pandas numpy matplotlib scipy
        """
        
        import argparse
        import sys
        from datetime import datetime
        
        import numpy as np
        import pandas as pd
        import matplotlib.pyplot as plt
        import matplotlib.gridspec as gridspec
        
        try:
            from lifelines import KaplanMeierFitter, CoxPHFitter
            from lifelines.statistics import logrank_test, multivariate_logrank_test
            from lifelines.utils import restricted_mean_survival_time, median_survival_times
            LIFELINES_AVAILABLE = True
        except ImportError:
            LIFELINES_AVAILABLE = False
            print("ERROR: lifelines not installed. Install with: pip install lifelines")
            sys.exit(1)
        
        # ── Reproducibility ───────────────────────────────────────────────────────────
        SCRIPT_VERSION = "1.0.0"
        SEED = 42
        np.random.seed(SEED)
        
        
        def _fmt_time(x):
            """Format a survival time; 'NR' (not reached) for inf/NaN."""
            if x is None or not np.isfinite(x):
                return "NR"
            return f"{x:.1f}"
        
        
        def median_with_ci(kmf, unit):
            """Median survival with its 95% CI from a fitted KaplanMeierFitter.
        
            A median point estimate reported without its CI is incomplete (analyze-stats
            Survival reporting rule). Returns 'NR' when the median is not reached.
            """
            med = kmf.median_survival_time_
            try:
                ci = median_survival_times(kmf.confidence_interval_)
                lo, hi = ci.iloc[0, 0], ci.iloc[0, 1]
            except Exception:
                lo = hi = float("nan")
            return f"{_fmt_time(med)} (95% CI {_fmt_time(lo)}–{_fmt_time(hi)}) {unit}"
        print(f"survival_analysis.py v{SCRIPT_VERSION} | {datetime.now().strftime('%Y-%m-%d')}")
        
        # ── Style ─────────────────────────────────────────────────────────────────────
        plt.rcParams.update({
            "font.family": "Arial",
            "font.size": 9,
            "axes.labelsize": 9,
            "xtick.labelsize": 8,
            "ytick.labelsize": 8,
        })
        
        WONG_COLORS = ["#0072B2", "#D55E00", "#009E73", "#E69F00", "#CC79A7", "#56B4E9"]
        LINE_STYLES = ["-", "--", "-.", ":", (0, (3, 1, 1, 1))]
        
        
        def load_data(filepath, time_col, event_col):
            df = pd.read_csv(filepath)
            for col in [time_col, event_col]:
                if col not in df.columns:
                    raise ValueError(f"Column '{col}' not found in {filepath}")
            df = df.dropna(subset=[time_col, event_col])
            return df
        
        
        def km_analysis(df, time_col, event_col, group_col=None,
                        time_unit="Months", output_path="survival"):
            """Kaplan-Meier analysis with at-risk table."""
            fig = plt.figure(figsize=(7.0, 5.0))
        
            if group_col and group_col in df.columns:
                groups = sorted(df[group_col].dropna().unique())
                n_groups = len(groups)
                gs = gridspec.GridSpec(2, 1, height_ratios=[3.5, 1.5], hspace=0.05)
            else:
                groups = None
                n_groups = 1
                gs = gridspec.GridSpec(2, 1, height_ratios=[3.5, 1.5], hspace=0.05)
        
            ax_km = fig.add_subplot(gs[0])
            ax_risk = fig.add_subplot(gs[1])
        
            fitters = {}
        
            if groups:
                for i, g in enumerate(groups):
                    mask = df[group_col] == g
                    d = df[mask]
                    kmf = KaplanMeierFitter()
                    kmf.fit(d[time_col], d[event_col], label=str(g))
                    fitters[g] = kmf
        
                    kmf.plot_survival_function(
                        ax=ax_km,
                        color=WONG_COLORS[i % len(WONG_COLORS)],
                        linestyle=LINE_STYLES[i % len(LINE_STYLES)],
                        linewidth=1.5,
                        ci_show=True,
                        ci_alpha=0.15,
                    )
        
                    # Median survival with 95% CI
                    print(f"  {g}: Median survival = {median_with_ci(kmf, time_unit)}")
        
                # Log-rank test
                if n_groups == 2:
                    g1, g2 = groups
                    lr = logrank_test(
                        df.loc[df[group_col] == g1, time_col],
                        df.loc[df[group_col] == g2, time_col],
                        df.loc[df[group_col] == g1, event_col],
                        df.loc[df[group_col] == g2, event_col],
                    )
                    p_str = f"P {'< .001' if lr.p_value < 0.001 else f'= {lr.p_value:.3f}'}"
                    ax_km.text(0.98, 0.5, f"Log-rank {p_str}",
                               transform=ax_km.transAxes, fontsize=8,
                               va="center", ha="right")
                    print(f"\n  Log-rank test: χ² = {lr.test_statistic:.3f}, P = {lr.p_value:.4f}")
                else:
                    mlr = multivariate_logrank_test(
                        df[time_col], df[group_col], df[event_col]
                    )
                    p_str = f"P {'< .001' if mlr.p_value < 0.001 else f'= {mlr.p_value:.3f}'}"
                    ax_km.text(0.98, 0.5, f"Log-rank {p_str}",
                               transform=ax_km.transAxes, fontsize=8,
                               va="center", ha="right")
            else:
                # Single-group KM
                kmf = KaplanMeierFitter()
                kmf.fit(df[time_col], df[event_col], label="All patients")
                fitters["All"] = kmf
                kmf.plot_survival_function(ax=ax_km, color=WONG_COLORS[0],
                                            linewidth=1.5, ci_show=True, ci_alpha=0.2)
                print(f"  Median survival: {median_with_ci(kmf, time_unit)}")
        
            # KM axis formatting
            ax_km.set_ylim(-0.02, 1.05)
            ax_km.set_xlim(left=0)
            ax_km.set_ylabel("Survival probability", fontsize=9)
            ax_km.set_xlabel("")
            ax_km.spines[["top", "right"]].set_visible(False)
            ax_km.legend(fontsize=8, frameon=False)
            ax_km.grid(axis="y", color="#EEEEEE", linewidth=0.5)
        
            # At-risk table
            ax_risk.set_axis_off()
            time_points = np.linspace(0, df[time_col].max() * 0.95, 6)
            row_offset = 1.0
        
            for i, (label, kmf) in enumerate(fitters.items()):
                at_risk = [kmf.event_table["at_risk"].iloc[
                    max(0, (kmf.event_table.index <= t).sum() - 1)
                ] for t in time_points]
                ax_risk.text(-0.01, row_offset - i * 0.35, str(label),
                             transform=ax_risk.transAxes, fontsize=7.5,
                             va="top", ha="right",
                             color=WONG_COLORS[i % len(WONG_COLORS)])
                for j, (t, n) in enumerate(zip(time_points, at_risk)):
                    ax_risk.text(j / (len(time_points) - 1), row_offset - i * 0.35,
                                  str(int(n)), transform=ax_risk.transAxes,
                                  fontsize=7.5, va="top", ha="center")
        
            ax_km.set_xticks(time_points)
            ax_km.set_xlabel(time_unit, fontsize=9)
        
            plt.suptitle("Kaplan-Meier Survival Curves", fontsize=10, y=1.01, fontweight="bold")
            plt.tight_layout()
        
            for ext in ["pdf", "png"]:
                outfile = f"{output_path}_km.{ext}"
                dpi = 300 if ext == "png" else None
                plt.savefig(outfile, dpi=dpi, bbox_inches="tight",
                            facecolor="white", edgecolor="none")
                print(f"Saved: {outfile}")
            plt.close()
        
        
        def cox_analysis(df, time_col, event_col, covariates, output_path="survival",
                         cluster_col=None):
            """Cox proportional hazards model.
        
            cluster_col: id column for nested observation units (e.g. multiple lesions /
            eyes / repeated episodes per subject). When set, lifelines computes a robust
            (cluster-sandwich) variance so the HR CIs reflect within-subject correlation
            rather than treating correlated rows as independent.
            """
            keep = [time_col, event_col] + covariates + ([cluster_col] if cluster_col else [])
            model_df = df[keep].dropna()
            print(f"\n── Cox PH Model (N = {len(model_df)}) ───────────────────────")
        
            # Events-per-variable (EPV) gate — mirror of the logistic EPV rule. A Wald CI
            # from a sparse-event model is not stable; warn and rely on the penalizer.
            n_events = int(model_df[event_col].sum())
            epv = n_events / max(len(covariates), 1)
            print(f"EPV = {epv:.1f} ({n_events} events / {len(covariates)} covariates; "
                  f"minimum recommended: 10)")
            if epv < 10:
                print("⚠ WARNING: EPV < 10 — Cox estimates may be unstable. The penalized "
                      "fit (penalizer=0.1) shrinks coefficients; consider Firth/penalized "
                      "Cox or profile-likelihood CIs and interpret Wald CIs with caution.")
        
            cph = CoxPHFitter(penalizer=0.1)
            fit_kw = {"duration_col": time_col, "event_col": event_col}
            if cluster_col:
                fit_kw["cluster_col"] = cluster_col  # robust (cluster-sandwich) SE
                print(f"Robust cluster-sandwich SE on '{cluster_col}' (nested units).")
            cph.fit(model_df, **fit_kw)
            cph.print_summary()
        
            # Check PH assumption
            print("\n── Proportional Hazards Assumption (Schoenfeld residuals) ───")
            try:
                cph.check_assumptions(model_df, p_value_threshold=0.05, show_plots=False)
            except Exception as e:
                print(f"  PH assumption check: {e}")
        
            # Save results table
            summary = cph.summary.copy()
            summary.columns = [c.replace(" ", "_") for c in summary.columns]
            outfile = f"{output_path}_cox_results.csv"
            summary.to_csv(outfile)
            print(f"\nSaved: {outfile}")
        
            # Concordance index
            print(f"\nConcordance index (C-statistic): {cph.concordance_index_:.3f}")
        
            return cph
        
        
        def rmst_analysis(df, time_col, event_col, group_col, t_star, output_path="survival"):
            """Restricted Mean Survival Time analysis."""
            if group_col not in df.columns:
                return
        
            groups = df[group_col].dropna().unique()
            print(f"\n── Restricted Mean Survival Time (t* = {t_star}) ────────────")
            for g in groups:
                d = df[df[group_col] == g]
                kmf = KaplanMeierFitter()
                kmf.fit(d[time_col], d[event_col])
                rmst = restricted_mean_survival_time(kmf, t=t_star)
                print(f"  {g}: RMST = {rmst:.2f}")
        
        
        def main():
            parser = argparse.ArgumentParser(description="Survival analysis")
            parser.add_argument("--input", required=True)
            parser.add_argument("--time", required=True, help="Time column name")
            parser.add_argument("--event", required=True, help="Event column name (1=event, 0=censored)")
            parser.add_argument("--group", default=None)
            parser.add_argument("--covariates", nargs="+", default=None)
            parser.add_argument("--cluster", default=None,
                                help="ID column for nested units → robust cluster SE in Cox")
            parser.add_argument("--time-unit", default="Months")
            parser.add_argument("--rmst-t", type=float, default=None,
                                help="t* for RMST (in same units as time)")
            parser.add_argument("--output", default="survival_analysis")
        
            args = parser.parse_args()
        
            df = load_data(args.input, args.time, args.event)
            print(f"\nN = {len(df)} | Events: {df[args.event].sum()} "
                  f"({df[args.event].mean()*100:.1f}%)")
        
            print("\n── Kaplan-Meier Analysis ────────────────────")
            km_analysis(df, args.time, args.event, args.group,
                        args.time_unit, args.output)
        
            if args.covariates:
                cox_analysis(df, args.time, args.event, args.covariates, args.output,
                             cluster_col=args.cluster)
        
            if args.rmst_t and args.group:
                rmst_analysis(df, args.time, args.event, args.group,
                              args.rmst_t, args.output)
        
            print(f"\n── Session Info ─────────────────────────────")
            print(f"Python: {sys.version.split()[0]}")
            for pkg in ["lifelines", "pandas", "numpy", "scipy"]:
                try:
                    import importlib
                    m = importlib.import_module(pkg)
                    print(f"  {pkg}: {m.__version__}")
                except Exception:
                    pass
        
        
        if __name__ == "__main__":
            if len(sys.argv) == 1:
                print("Usage: python survival_analysis.py --input data.csv "
                      "--time time_months --event event --group treatment")
            else:
                main()
        
      • table1_demographics.py 10 KB
        """
        Template: Table 1 — Baseline Demographics
        Generates a publication-ready demographics table from tabular data.
        
        Usage:
            Modify the CONFIGURATION section below, then run:
                python table1_demographics.py
        
        Input:  CSV or Excel file with one row per subject
        Output: table1_demographics.csv, table1_demographics.md (console), summary text
        """
        
        # === REPRODUCIBILITY HEADER ===
        import sys
        import datetime
        import numpy as np
        import pandas as pd
        from scipy import stats
        
        np.random.seed(42)
        print(f"Date: {datetime.date.today()}")
        print(f"Python: {sys.version}")
        print(f"numpy: {np.__version__}, pandas: {pd.__version__}, scipy: {stats.scipy.__version__}")
        print()
        
        # === CONFIGURATION (modify for your study) ===
        INPUT_FILE = "data.csv"           # Path to input data
        OUTPUT_DIR = "."                  # Output directory
        GROUP_COL = None                  # Column name for group comparison (None = no comparison)
        CONTINUOUS_VARS = []              # List of continuous variable column names
        CATEGORICAL_VARS = []            # List of categorical variable column names
        VAR_LABELS = {}                   # Optional display labels: {"col_name": "Display Name (unit)"}
        DECIMAL_PLACES = 1                # Decimal places for continuous variables
        # ==============================================
        
        
        def load_data(filepath: str) -> pd.DataFrame:
            """Load CSV or Excel file."""
            if filepath.endswith((".xlsx", ".xls")):
                return pd.read_excel(filepath)
            return pd.read_csv(filepath)
        
        
        def test_normality(series: pd.Series, alpha: float = 0.05) -> tuple:
            """Test normality using Shapiro-Wilk (n<50) or Kolmogorov-Smirnov (n>=50)."""
            clean = series.dropna()
            if len(clean) < 3:
                return False, np.nan
            if len(clean) < 50:
                stat, p = stats.shapiro(clean)
            else:
                stat, p = stats.kstest(clean, "norm", args=(clean.mean(), clean.std()))
            return p >= alpha, p
        
        
        def format_continuous(series: pd.Series, is_normal: bool, dp: int = 1) -> str:
            """Format continuous variable as mean +/- SD or median (IQR)."""
            clean = series.dropna()
            if is_normal:
                return f"{clean.mean():.{dp}f} +/- {clean.std():.{dp}f}"
            else:
                q1, median, q3 = clean.quantile([0.25, 0.5, 0.75])
                return f"{median:.{dp}f} ({q1:.{dp}f}-{q3:.{dp}f})"
        
        
        def format_categorical(series: pd.Series) -> dict:
            """Format categorical variable as n (%)."""
            counts = series.value_counts(dropna=False)
            total = len(series)
            result = {}
            for cat, n in counts.items():
                pct = 100.0 * n / total if total > 0 else 0.0
                label = str(cat) if pd.notna(cat) else "Missing"
                result[label] = f"{n} ({pct:.1f}%)"
            return result
        
        
        def compare_continuous(groups: list, is_normal: bool) -> tuple:
            """Compare continuous variable between groups."""
            clean_groups = [g.dropna() for g in groups]
            if len(clean_groups) == 2:
                if is_normal:
                    stat, p = stats.ttest_ind(*clean_groups)
                    return "t-test", p
                else:
                    stat, p = stats.mannwhitneyu(*clean_groups, alternative="two-sided")
                    return "Mann-Whitney U", p
            else:
                if is_normal:
                    stat, p = stats.f_oneway(*clean_groups)
                    return "ANOVA", p
                else:
                    stat, p = stats.kruskal(*clean_groups)
                    return "Kruskal-Wallis", p
        
        
        def compare_categorical(contingency: pd.DataFrame) -> tuple:
            """Compare categorical variable between groups using chi-square or Fisher's exact."""
            table = contingency.values
            # Use Fisher's exact test for 2x2 tables with any expected count < 5
            if table.shape == (2, 2):
                expected = stats.chi2_contingency(table, correction=False)[3]
                if (expected < 5).any():
                    odds_ratio, p = stats.fisher_exact(table)
                    return "Fisher's exact", p
            # Otherwise chi-square (with Yates' correction for 2x2)
            chi2, p, dof, expected = stats.chi2_contingency(table)
            if (expected < 5).any():
                print(f"  Warning: expected counts < 5 in some cells; consider collapsing categories.")
            return "Chi-square", p
        
        
        def format_p(p: float) -> str:
            """Format p-value per reporting rules."""
            if pd.isna(p):
                return ""
            if p < 0.001:
                return "<0.001"
            return f"{p:.3f}"
        
        
        def build_table1(df: pd.DataFrame) -> pd.DataFrame:
            """Build the full Table 1 dataframe."""
            rows = []
            test_footnotes = set()
        
            if GROUP_COL and GROUP_COL in df.columns:
                groups = df[GROUP_COL].dropna().unique()
                groups = sorted(groups, key=str)
                group_dfs = {str(g): df[df[GROUP_COL] == g] for g in groups}
            else:
                groups = []
                group_dfs = {}
        
            n_row = {"Variable": "n"}
            if group_dfs:
                for g_name, g_df in group_dfs.items():
                    n_row[g_name] = str(len(g_df))
            n_row["Overall"] = str(len(df))
            if group_dfs:
                n_row["p-value"] = ""
                n_row["Test"] = ""
            rows.append(n_row)
        
            # --- Continuous variables ---
            for var in CONTINUOUS_VARS:
                if var not in df.columns:
                    print(f"Warning: '{var}' not found in data, skipping.")
                    continue
                label = VAR_LABELS.get(var, var)
                is_normal, norm_p = test_normality(df[var])
                stat_type = "mean +/- SD" if is_normal else "median (IQR)"
        
                row = {"Variable": f"{label}, {stat_type}"}
                row["Overall"] = format_continuous(df[var], is_normal, DECIMAL_PLACES)
        
                if group_dfs:
                    for g_name, g_df in group_dfs.items():
                        row[g_name] = format_continuous(g_df[var], is_normal, DECIMAL_PLACES)
                    grp_series = [g_df[var] for g_df in group_dfs.values()]
                    test_name, p_val = compare_continuous(grp_series, is_normal)
                    row["p-value"] = format_p(p_val)
                    row["Test"] = test_name
                    test_footnotes.add(test_name)
        
                rows.append(row)
        
            # --- Categorical variables ---
            for var in CATEGORICAL_VARS:
                if var not in df.columns:
                    print(f"Warning: '{var}' not found in data, skipping.")
                    continue
                label = VAR_LABELS.get(var, var)
        
                # Header row for this variable
                header_row = {"Variable": f"{label}, n (%)"}
        
                if group_dfs:
                    contingency = pd.crosstab(df[var], df[GROUP_COL])
                    test_name, p_val = compare_categorical(contingency)
                    header_row["p-value"] = format_p(p_val)
                    header_row["Test"] = test_name
                    test_footnotes.add(test_name)
                    for g_name in group_dfs:
                        header_row[g_name] = ""
                header_row["Overall"] = ""
                rows.append(header_row)
        
                # Category rows
                all_cats = format_categorical(df[var])
                for cat_label, cat_val in all_cats.items():
                    cat_row = {"Variable": f"  {cat_label}"}
                    cat_row["Overall"] = cat_val
                    if group_dfs:
                        for g_name, g_df in group_dfs.items():
                            cat_counts = format_categorical(g_df[var])
                            cat_row[g_name] = cat_counts.get(cat_label, "0 (0.0%)")
                        cat_row["p-value"] = ""
                        cat_row["Test"] = ""
                    rows.append(cat_row)
        
            # Build dataframe
            table_df = pd.DataFrame(rows)
        
            # Reorder columns
            col_order = ["Variable"]
            if group_dfs:
                col_order += [str(g) for g in sorted(groups, key=str)]
            col_order += ["Overall"]
            if group_dfs:
                col_order += ["p-value", "Test"]
            table_df = table_df[col_order]
        
            print("\n--- Test Methods Used ---")
            for t in sorted(test_footnotes):
                print(f"  - {t}")
            print()
        
            return table_df
        
        
        def save_outputs(table_df: pd.DataFrame) -> None:
            """Save table as CSV and print markdown version."""
            import os
            csv_path = os.path.join(OUTPUT_DIR, "table1_demographics.csv")
        
            # Save CSV (without Test column for manuscript)
            export_df = table_df.drop(columns=["Test"], errors="ignore")
            export_df.to_csv(csv_path, index=False)
            print(f"Saved: {csv_path}")
        
            # Print markdown table
            print("\n--- Table 1. Baseline Characteristics ---\n")
            print(export_df.to_markdown(index=False))
        
        
        def print_results_text(df: pd.DataFrame, table_df: pd.DataFrame) -> None:
            """Print copy-paste-ready text for Results section."""
            print("\n--- Results Text (copy-paste ready) ---\n")
            n_total = len(df)
        
            if GROUP_COL and GROUP_COL in df.columns:
                groups = df[GROUP_COL].dropna().unique()
                group_counts = df[GROUP_COL].value_counts()
                parts = [f"{g} (n = {group_counts[g]})" for g in sorted(groups, key=str)]
                print(f"A total of {n_total} subjects were included and divided into "
                      f"{len(groups)} groups: {', '.join(parts)}. "
                      f"Baseline characteristics are summarized in Table 1.")
            else:
                print(f"A total of {n_total} subjects were included. "
                      f"Baseline characteristics are summarized in Table 1.")
        
            # Report missing data
            missing = df[CONTINUOUS_VARS + CATEGORICAL_VARS].isnull().sum()
            missing = missing[missing > 0]
            if len(missing) > 0:
                print(f"\nMissing data: ", end="")
                parts = [f"{VAR_LABELS.get(col, col)}: {n} ({100*n/len(df):.1f}%)"
                         for col, n in missing.items()]
                print("; ".join(parts) + ".")
        
        
        # === MAIN ===
        if __name__ == "__main__":
            print("=" * 60)
            print("Table 1: Baseline Demographics")
            print("=" * 60)
        
            df = load_data(INPUT_FILE)
            print(f"\nLoaded: {INPUT_FILE} ({df.shape[0]} rows, {df.shape[1]} columns)")
        
            # Auto-detect if CONTINUOUS_VARS and CATEGORICAL_VARS are empty
            if not CONTINUOUS_VARS and not CATEGORICAL_VARS:
                print("\nNo variables specified. Auto-detecting from data types...")
                for col in df.columns:
                    if col == GROUP_COL:
                        continue
                    if pd.api.types.is_numeric_dtype(df[col]) and df[col].nunique() > 10:
                        CONTINUOUS_VARS.append(col)
                        print(f"  Continuous: {col}")
                    elif df[col].nunique() <= 20:
                        CATEGORICAL_VARS.append(col)
                        print(f"  Categorical: {col}")
        
            table_df = build_table1(df)
            save_outputs(table_df)
            print_results_text(df, table_df)
        
    • analysis_run_workflow.md 7.5 KB
      # Binary diagnostic accuracy with an execution record
      
      Use this bounded workflow when reference labels and **prespecified predictions**
      are already literal `0`/`1`, with one row per independent analysis unit. It uses
      the existing diagnostic-accuracy template to produce counts, five proportions
      with Wilson 95% confidence intervals, and a confusion-matrix figure. It does not
      train a model, select a threshold, calculate AUC or compare models. Those tasks
      require a separate analysis plan and the corresponding template.
      
      ## Try the original synthetic example
      
      From this skill's directory, with Python 3.10+ and `numpy`, `pandas`, `scipy`,
      `scikit-learn` and `matplotlib` installed:
      
      ```bash
      python3 scripts/demo_analysis_run.py --out demo-project
      ```
      
      The new project contains generated row-level data, configuration and two runs.
      Open `demo-project/runs/first/_analysis_outputs.md`, the full precision CSV and
      both confusion-matrix files. The example checks independently specified TP/FP/
      TN/FN counts and reruns the calculation. Tests additionally compare the intervals
      against SciPy's implementation. It is an authored software control, not a clinical
      validation dataset or an independent evaluation of the whole skill.
      
      ## Run on a defined project
      
      Keep the complete installed skill directory: the runner imports its bundled
      template and style; it does not require another MedSci skill at runtime. Set
      `SKILL_DIR` to that directory and run these commands from the project directory:
      
      ```bash
      python3 "$SKILL_DIR/scripts/run_analysis.py" run --project-root . \
        --data data.csv --config analysis.json --out runs/first
      python3 "$SKILL_DIR/scripts/run_analysis.py" audit --project-root . --out runs/first
      python3 "$SKILL_DIR/scripts/run_analysis.py" run --project-root . \
        --data data.csv --config analysis.json --out runs/repeat
      python3 "$SKILL_DIR/scripts/run_analysis.py" compare --project-root . \
        --previous runs/first --out runs/repeat
      ```
      
      The configuration must contain exactly these fields; unknown options are errors:
      
      ```json
      {
        "schema_version": 1,
        "analysis_unit": "case",
        "unit_id_col": "unit_id",
        "truth_col": "truth",
        "prediction_col": "prediction",
        "missing_policy": "error",
        "independent_units": true,
        "data_status": "synthetic"
      }
      ```
      
      - Allowed declared units: `patient`, `participant`, `exam`, `lesion`, `image`,
        `case`, `study`, `sample`. Unique nonempty IDs are required, including excluded
        rows. Uniqueness alone does not establish independence: multiple lesions or
        images from one patient may require clustering and are outside this workflow.
      - Positive class is `1`; negative class is `0`. Scores, other labels and rounded
        aggregate percentages are refused. Do not reconstruct purported raw observations
        from a paper's rounded results. Map labels and fix predictions in separately
        preserved preprocessing code before running.
      - Empty labels fail by default. Use `complete_case` only with a justified plan;
        it records input/included rows, excluded rows and missing counts for each label.
        This records exclusion, without establishing that complete-case inference is valid.
      - For real inputs, `data_status` must be `deidentified_authorized`. This is the
        operator's declaration, not automatic de-identification or privacy approval.
        Reports omit row values and IDs but contain project-relative paths, column names,
        aggregate counts and hashes. Keep paths/column names non-identifying and review
        small-cell disclosure and reuse rights before sharing the outputs.
      - Inputs and output paths are relative to the project root. Hidden paths, symlinks,
        traversal and an existing output directory are refused. Raw inputs are read only;
        failed rendering or changed inputs/code during execution do not publish a completed
        run. Preserve data, configuration and code versions separately: hashes are not backups.
      
      ## One output manifest, with bounded checks
      
      `_analysis_outputs.md` retains the existing Tables/Figures/Data discovery sections
      and embeds one JSON execution record. It records the data/configuration hashes,
      runner/template/style hashes, dependency versions, resolved font hash, exact
      confusion counts, every metric's numerator/denominator, missing-data counts and
      output hashes. The command uses `<analyze-stats>` as a portable skill-path
      placeholder. The deterministic count workflow records seed 42 but uses no random
      sampling. Archive the recorded dependency/code versions for later reproduction;
      figure bytes may differ across rendering environments.
      
      The CSV stores proportions on the 0–1 scale at full float precision. Markdown
      rounding is display only. Sensitivity uses TP+FN, specificity TN+FP, PPV TP+FP,
      NPV TN+FN and accuracy all included rows. A zero denominator produces an undefined
      estimate and CI (JSON `null`, empty CSV cells), never zero performance. Figure cell
      percentages use **all included rows**, not the metric-specific denominators.
      
      `audit` reads without rewriting the record. `current` means that recorded input,
      code and output bytes still match and the readable manifest is unchanged; it does
      not rerun the analysis or audit the current environment. Legacy output lists
      without an execution record are not silently promoted. A mismatch returns `drift`
      and exit code 1; invalid/missing records or inputs return an error and exit code 2.
      
      `compare` compares recorded full precision values, including denominators and CIs,
      and includes both read-only byte audits. A different declared unit or label column
      sets `context_status: not_comparable`; changed input/code/environment records stay
      visible even if estimates match. Any byte-audit mismatch makes the top-level status
      `drift` and exits 1. `recorded_*_equal` describes the stored records, not an assertion
      that current files are equal or that the analyses are statistically equivalent.
      Preserve versioned input files if both old and new runs must remain current.
      
      These unsigned records are not tamper-proof. Study validity, reference-label
      correctness, independence, privacy clearance, reuse rights and visual inspection
      remain `not_assessed`; read the actual figure and analysis plan before reporting.
      
      ## Method and source scope
      
      The repository's existing Wilson implementation is retained and checked against
      the documented [SciPy Wilson interval](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats._result_classes.BinomTestResult.proportion_ci.html)
      without continuity correction. Binary figure ordering is explicitly `[0, 1]`,
      consistent with the [scikit-learn confusion-matrix interface](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.confusion_matrix.html).
      The example and documentation are original contributions under the repository
      license. No third-party paper, dataset, image, font or documentation text is bundled.
      
      Run the synthetic regression controls from the skill directory:
      
      ```bash
      python3 tests/test_analysis_run.py
      bash scripts/analysis_run_challenge/verify.sh
      ```
      
      ## Other analysis outputs
      
      For analyses outside the bounded binary workflow, retain the existing output-list
      format below. This list supports discovery but does not claim to bind an execution.
      
      After all analyses complete, save a manifest file `_analysis_outputs.md` in the output directory:
      
      ```markdown
      # Analysis Outputs
      Generated: {YYYY-MM-DD}
      Study type: {detected or user-specified type}
      
      ## Tables
      - `table1_demographics.csv` -- Baseline characteristics
      - `diagnostic_accuracy_table.csv` -- Performance metrics with 95% CIs
      
      ## Figures
      - `roc_curve.pdf` / `roc_curve.png` -- ROC curves (vector / 300 DPI)
      
      ## Data
      - `predictions.csv` -- Per-subject model predictions with ground truth
      ```
      
  • scripts
    • analysis_run_challenge
      • README.md 761 B
        # Analysis execution record challenge
        
        Run `bash verify.sh` from this directory. Requires Python 3.10+, numpy, pandas,
        SciPy, scikit-learn and matplotlib; no network or private data is used.
        
        The bundled demo creates original synthetic observations in a temporary project.
        The normal control checks exact confusion counts, two matching calculations and
        a current byte audit. The defect control adds one synthetic input row: two audits
        must report drift without rewriting the old record. The temporary project is removed.
        The complete regression suite under `../../tests/test_analysis_run.py` additionally
        covers units, denominators, undefined metrics, rounded-input refusal and output/code
        changes. These controls test software behavior, not clinical validity.
        
      • verify.sh 1.5 KB
        #!/usr/bin/env bash
        # Standalone original synthetic normal and stale-result controls.
        set -euo pipefail
        HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
        python3 - "$HERE/.." <<'PY'
        import json
        from pathlib import Path
        import subprocess
        import sys
        import tempfile
        
        scripts = Path(sys.argv[1]).resolve()
        with tempfile.TemporaryDirectory() as temp:
            project = Path(temp) / "project"
            demo = subprocess.run([sys.executable, str(scripts / "demo_analysis_run.py"),
                                   "--out", str(project)], capture_output=True, text=True, check=True)
            result = json.loads(demo.stdout)
            assert result["synthetic_counts"] == {"TP": 8, "FP": 3, "TN": 7, "FN": 2}
            assert result["comparison"]["status"] == "compared"
            assert result["comparison"]["recorded_numeric_results_equal"]
            runner = [sys.executable, str(scripts / "run_analysis.py")]
            audit = runner + ["audit", "--project-root", str(project), "--out", "runs/first"]
            assert subprocess.run(audit, capture_output=True).returncode == 0
            manifest = project / "runs/first/_analysis_outputs.md"
            original = manifest.read_bytes()
            with (project / "data.csv").open("a") as stream:
                stream.write("synthetic_changed,0,1\n")
            for _ in range(2):
                check = subprocess.run(audit, capture_output=True, text=True)
                assert check.returncode == 1
                assert "input:data" in json.loads(check.stdout)["changes"]
                assert manifest.read_bytes() == original
        print("PASS: synthetic calculation, repeat, stale-input detection and read-only audit")
        PY
        
    • check_generated_code.py 14.7 KB
      #!/usr/bin/env python3
      """Generated-code quality gate for analysis scripts (analyze-stats Phase 3.5).
      
      AI-generated analysis code carries a recurring set of reproducibility-hygiene
      "slop" patterns that pass a casual read but break reproducibility or violate
      the data-integrity rules every medsci-skills analysis must follow. This linter
      scans emitted .py / .R scripts before they are reported as final and flags:
      
        MISSING_SEED            randomness is used (sampling, bootstrap, train/test
                                split, shuffling, rng) but no seed is set
                                (np.random.seed / set.seed / random_state= /
                                default_rng / RandomState). Non-reproducible. (Major)
        HARDCODED_DATA_LITERAL  a large hand-typed numeric literal that looks like
                                tabular data — either alongside a real data-file read,
                                or very large on its own. The data-integrity rule is
                                "never hand-type CSV data into scripts; use read_csv +
                                subset." (Major)
        HARDCODED_ABS_PATH      an absolute filesystem path literal (/Users/, /home/,
                                C:\\, ~/Documents). Non-portable and a PII risk. (Major)
        INPLACE_SOURCE_OVERWRITE the same path is both read as input and written as
                                output — silently overwriting raw source data. The
                                data-integrity rule is "never modify raw data." (Major)
        DEBUG_LEFTOVER          a debugger/print artifact left in (breakpoint(),
                                pdb.set_trace(), browser(), print("debug"...)) or a
                                TODO/FIXME/XXX marker. (Flag)
        UNUSED_IMPORT           (Python only) an imported name never referenced again;
                                dead dependency. (Flag)
      
      The gate is conservative on the Major checks (it fires HARDCODED_DATA_LITERAL
      only on genuinely table-shaped literals, MISSING_SEED only when a real
      randomness call is present) so it stays quiet on legitimate analysis code.
      
      INPUTS
        positional   one or more .py / .R / .r files.
        --code-dir   directory to scan recursively for .py / .R / .r files.
        (at least one source must be provided via either form.)
      
      OUTPUT
        A findings table (stdout) and, with --out, a JSON artifact:
          {files[], claims[{verdict, severity, file, line, detail}], summary}
        Exit 1 (with --strict) when any Major claim exists; exit 2 on input error.
      
      Stdlib-only (ast / json / re / argparse / pathlib). Exit codes: 0 clean (or
      report-only), 1 Major claim(s) found (with --strict), 2 input/usage error.
      """
      
      from __future__ import annotations
      
      import argparse
      import ast
      import json
      import re
      import sys
      from pathlib import Path
      
      # --- shared regexes (language-agnostic unless noted) ------------------------
      
      ABS_PATH = re.compile(r"""['"](?:/Users/|/home/|~/Documents|~/Desktop|~/Downloads|[A-Za-z]:\\\\)[^'"]*['"]""")
      
      DEBUG_PY = re.compile(
          r"\bbreakpoint\s*\(|\bpdb\.set_trace\s*\(|^\s*import\s+pdb\b|"
          r"\bprint\s*\(\s*['\"](?:debug|here|test|xxx|todo|checkpoint)\b", re.IGNORECASE)
      DEBUG_R = re.compile(
          r"\bbrowser\s*\(\s*\)|\bdebug(?:once)?\s*\(|"
          r"\bprint\s*\(\s*paste0?\s*\(\s*['\"](?:debug|here|test)\b|"
          r"\bcat\s*\(\s*['\"](?:debug|here)\b", re.IGNORECASE)
      TODO_MARKER = re.compile(r"#\s*(?:TODO|FIXME|XXX)\b", re.IGNORECASE)
      
      # randomness signals vs seed signals
      RAND_PY = re.compile(
          r"np\.random\.|numpy\.random\.|\brandom\.(?:sample|shuffle|choice|random|randint|randrange)\b|"
          r"\bRandomState\b|\bdefault_rng\b|\btrain_test_split\b|\bKFold\b|\bStratifiedKFold\b|"
          r"\bShuffleSplit\b|\bresample\s*\(|\bbootstrap\b|\bpermutation\b")
      SEED_PY = re.compile(
          r"np\.random\.seed\s*\(|numpy\.random\.seed\s*\(|\brandom\.seed\s*\(|"
          r"\bRandomState\s*\(|\bdefault_rng\s*\(|\brandom_state\s*=")
      RAND_R = re.compile(
          r"\bsample\s*\(|\bsample\.int\s*\(|\brnorm\s*\(|\brunif\s*\(|\brbinom\s*\(|"
          r"\brpois\s*\(|\bboot\s*\(|\bcreateDataPartition\s*\(")
      SEED_R = re.compile(r"\bset\.seed\s*\(")
      
      # data-file reads / writes (for INPLACE_SOURCE_OVERWRITE and the DATA_LITERAL gate)
      READ_CALL = re.compile(
          r"(?:read_csv|read\.csv|read_excel|read\.xlsx|read_parquet|read_table|read\.table|"
          r"read_csv2|read_tsv|read_sas|read_stata|read_feather|read_json|loadtxt|genfromtxt)"
          r"\s*\(\s*([^,\)]+)")
      WRITE_FN = re.compile(
          r"(?:to_csv|write\.csv|write_csv|write_csv2|to_excel|write\.xlsx|to_parquet|"
          r"write_parquet|to_feather|savetxt|write\.table|write_tsv|fwrite)\s*\(")
      
      STR_LITERAL = re.compile(r"""['"]([^'"]+)['"]""")
      # bracketed/paren literal whose body is mostly comma-separated numbers
      NUM_LITERAL_BODY = re.compile(r"[\[(]([^\[\]()]*?\d[^\[\]()]*?)[\])]")
      NUM_TOKEN = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
      
      DATA_LITERAL_MIN = 12       # numbers in one literal to look table-shaped (with a read)
      DATA_LITERAL_STANDALONE = 24  # numbers in one literal to flag even with no read
      
      
      def _first_path_literal(arg: str) -> str | None:
          m = STR_LITERAL.search(arg)
          return m.group(1) if m else None
      
      
      def strip_comments(src: str) -> str:
          """Blank out `#`-to-EOL comments while preserving byte offsets and line count,
          so seed/randomness detection never matches a mention inside a comment (e.g.
          '# no set.seed() used') yet reported line numbers stay correct. Runs only for
          the seed/randomness checks; path/literal checks keep the full source."""
          out = []
          for line in src.split("\n"):
              i = line.find("#")
              out.append(line if i < 0 else line[:i] + " " * (len(line) - i))
          return "\n".join(out)
      
      
      def check_text_common(src: str, lang: str) -> list[dict]:
          claims: list[dict] = []
          lines = src.splitlines()
          has_read = bool(READ_CALL.search(src))
          code = strip_comments(src)  # comment-free copy for seed/randomness logic
      
          # MISSING_SEED
          rand, seed = (RAND_PY, SEED_PY) if lang == "py" else (RAND_R, SEED_R)
          rm = rand.search(code)
          if rm and not seed.search(code):
              ln = src[:rm.start()].count("\n") + 1
              claims.append({
                  "verdict": "MISSING_SEED", "severity": "Major", "line": ln,
                  "detail": (f"randomness ('{rm.group(0).strip()[:30]}') is used but no seed is set "
                             f"({'np.random.seed/random_state=' if lang == 'py' else 'set.seed()'}); "
                             f"the result is not reproducible"),
              })
      
          # HARDCODED_ABS_PATH
          am = ABS_PATH.search(src)
          if am:
              ln = src[:am.start()].count("\n") + 1
              claims.append({
                  "verdict": "HARDCODED_ABS_PATH", "severity": "Major", "line": ln,
                  "detail": f"absolute path literal {am.group(0)[:50]} — non-portable and a PII risk",
              })
      
          # HARDCODED_DATA_LITERAL
          for m in NUM_LITERAL_BODY.finditer(src):
              body = m.group(1)
              # ignore obvious non-data: ranges, single repeated, function-call args with kwargs
              if "=" in body:  # kwargs like figsize=(8,6) or linspace(0,1,...) — not table data
                  continue
              # A list/tuple of string literals (e.g. a hex-color palette
              # ['#000000','#E69F00',...] — exactly the colorblind-safe WONG palette that
              # make-figures recommends) is NOT hand-typed tabular data. Strip quoted
              # substrings before counting numeric tokens, so digits living inside string
              # literals (the "00" in '#E69F00', RGBA codes, category labels) don't make a
              # string list look table-shaped. Genuine numeric data is unquoted.
              nums = NUM_TOKEN.findall(STR_LITERAL.sub("", body))
              if len(nums) >= DATA_LITERAL_STANDALONE or (len(nums) >= DATA_LITERAL_MIN and has_read):
                  ln = src[:m.start()].count("\n") + 1
                  claims.append({
                      "verdict": "HARDCODED_DATA_LITERAL", "severity": "Major", "line": ln,
                      "detail": (f"a hand-typed numeric literal with {len(nums)} values"
                                 + (" alongside a data-file read" if has_read else "")
                                 + "; load tabular data with read_csv()/read.csv() + subset, "
                                   "never hand-type it into the script"),
                  })
                  break  # one report per file is enough to act on
      
          # INPLACE_SOURCE_OVERWRITE — a write call writing to a path also read as input.
          # Reads capture the first call argument; writes scan the call window for any
          # string literal (the output path is often the 2nd arg, e.g. write.csv(df, "x")).
          reads = {p for a in READ_CALL.findall(src) if (p := _first_path_literal(a))}
          writes: set[str] = set()
          for wm in WRITE_FN.finditer(src):
              window = src[wm.end():wm.end() + 200]
              window = window.split(")")[0]  # stay within this call's arg list
              writes.update(STR_LITERAL.findall(window))
          overlap = reads & writes
          if overlap:
              path = sorted(overlap)[0]
              idx = src.find(path)
              ln = src[:idx].count("\n") + 1 if idx >= 0 else 0
              claims.append({
                  "verdict": "INPLACE_SOURCE_OVERWRITE", "severity": "Major", "line": ln,
                  "detail": (f"'{path}' is both read as input and written as output; writing to the "
                             f"source path overwrites raw data — write derived outputs to a new path"),
              })
      
          # DEBUG_LEFTOVER
          debug_re = DEBUG_PY if lang == "py" else DEBUG_R
          for i, line in enumerate(lines, 1):
              if debug_re.search(line) or TODO_MARKER.search(line):
                  claims.append({
                      "verdict": "DEBUG_LEFTOVER", "severity": "Flag", "line": i,
                      "detail": f"debug/marker leftover: {line.strip()[:60]}",
                  })
                  break  # first occurrence per file
      
          return claims
      
      
      def check_unused_imports_py(src: str) -> list[dict]:
          """Python-only, AST-based. An imported name never referenced elsewhere."""
          try:
              tree = ast.parse(src)
          except SyntaxError:
              return []  # don't guess on unparseable files
          imported: list[tuple[str, str, int]] = []  # (bound_name, display, lineno)
          for node in ast.walk(tree):
              if isinstance(node, ast.Import):
                  for alias in node.names:
                      bound = (alias.asname or alias.name).split(".")[0]
                      imported.append((bound, alias.asname or alias.name, node.lineno))
              elif isinstance(node, ast.ImportFrom):
                  if node.module == "__future__":
                      continue
                  for alias in node.names:
                      if alias.name == "*":
                          return []  # star import: cannot reason about usage
                      bound = alias.asname or alias.name
                      imported.append((bound, f"{node.module or ''}.{alias.name}", node.lineno))
          # collect all Name usages outside the import statements
          used: set[str] = set()
          for node in ast.walk(tree):
              if isinstance(node, ast.Name):
                  used.add(node.id)
              elif isinstance(node, ast.Attribute):
                  pass  # base Name already captured by the Name walk
          claims = []
          for bound, display, lineno in imported:
              if bound not in used:
                  claims.append({
                      "verdict": "UNUSED_IMPORT", "severity": "Flag", "line": lineno,
                      "detail": f"'{display}' is imported but never used; remove the dead import",
                  })
          return claims
      
      
      def check_file(path: Path) -> list[dict]:
          lang = "py" if path.suffix.lower() == ".py" else "r"
          src = path.read_text(encoding="utf-8", errors="replace")
          claims = check_text_common(src, lang)
          if lang == "py":
              claims += check_unused_imports_py(src)
          for c in claims:
              c["file"] = str(path)
          return claims
      
      
      def gather_files(positional: list[str], code_dir: str | None) -> list[Path]:
          files: list[Path] = []
          for p in positional:
              pp = Path(p)
              if not pp.is_file():
                  sys.stderr.write(f"ERROR: not a file: {p}\n")
                  sys.exit(2)
              files.append(pp)
          if code_dir:
              d = Path(code_dir)
              if not d.is_dir():
                  sys.stderr.write(f"ERROR: not a directory: {code_dir}\n")
                  sys.exit(2)
              for ext in ("*.py", "*.R", "*.r"):
                  files.extend(sorted(d.rglob(ext)))
          # dedupe, preserve order
          seen, uniq = set(), []
          for f in files:
              key = str(f.resolve())
              if key not in seen:
                  seen.add(key)
                  uniq.append(f)
          if not uniq:
              sys.stderr.write("ERROR: no .py/.R source files to scan\n")
              sys.exit(2)
          return uniq
      
      
      def analyze(positional: list[str], code_dir: str | None) -> dict:
          files = gather_files(positional, code_dir)
          claims: list[dict] = []
          for f in files:
              claims += check_file(f)
          n_major = sum(1 for c in claims if c["severity"] == "Major")
          return {
              "files": [str(f) for f in files],
              "claims": claims,
              "summary": {
                  "n_files": len(files),
                  "n_claims": len(claims),
                  "n_major": n_major,
                  "n_flag": len(claims) - n_major,
                  "verdict": "MAJOR_CANDIDATE" if n_major else "OK",
              },
          }
      
      
      def render(result: dict) -> str:
          lines = ["| File:Line | Check | Severity | Detail |", "|---|---|---|---|"]
          for c in result["claims"]:
              loc = f"{Path(c['file']).name}:{c.get('line', 0)}"
              lines.append(f"| {loc} | {c['verdict']} | {c['severity']} | {c['detail']} |")
          if len(lines) == 2:
              lines.append("| (none) | — | — | scripts are reproducibility-clean |")
          return "\n".join(lines)
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Generated-code quality gate (analyze-stats Phase 3.5).")
          ap.add_argument("files", nargs="*", help=".py / .R source files to scan")
          ap.add_argument("--code-dir", help="directory to scan recursively for .py/.R files")
          ap.add_argument("--out", help="write JSON artifact to this path")
          ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists")
          ap.add_argument("--quiet", action="store_true", help="suppress stdout table")
          args = ap.parse_args()
      
          result = analyze(args.files, args.code_dir)
      
          if not args.quiet:
              print("=" * 41)
              print(" Generated-Code Quality (Phase 3.5)")
              print("=" * 41)
              print(render(result))
              print()
              s = result["summary"]
              if s["n_major"]:
                  print(f"MAJOR candidate: {s['n_major']} reproducibility/integrity issue(s) "
                        f"across {s['n_files']} file(s).")
              else:
                  print(f"OK: {s['n_files']} file(s) reproducibility-clean "
                        f"({s['n_flag']} minor flag(s)).")
      
          if args.out:
              Path(args.out).parent.mkdir(parents=True, exist_ok=True)
              Path(args.out).write_text(json.dumps({"detector": "check_generated_code", **result}, indent=2), encoding="utf-8")
              if not args.quiet:
                  print(f"\nwrote {args.out}")
      
          return 1 if (args.strict and result["summary"]["n_major"]) else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_separation.py 11.1 KB
      #!/usr/bin/env python3
      """Complete / quasi-complete separation: the logistic model that "runs" and is meaningless.
      
      A predictor that perfectly (or almost perfectly) predicts the outcome breaks maximum
      likelihood: the estimate diverges, and no finite MLE exists. The failure is silent. `glm`
      does not error — it returns, with an odds ratio of 0.00 (or an enormous one), a p value of
      0.99, and an AUC. That AUC gets written into a table.
      
      This happens routinely in diagnostic imaging, because the good signs are the pathognomonic
      ones. A sign with 100% specificity and 100% PPV — T2-FLAIR mismatch for IDH status, the
      string sign, a halo sign — has an empty cell against the outcome by construction. Enter it
      as a covariate in an incremental-value model and the model is numerically undefined while
      looking entirely healthy.
      
      So this runs on the DATA, before any model is fitted. It is a cross-tabulation, not an
      inference: a zero cell is arithmetic, and arithmetic can be checked in advance.
      
      Verdicts:
        COMPLETE_SEPARATION (major)  an empty predictor x outcome cell — the MLE does not exist
        QUASI_SEPARATION (major)     a cell below the sparsity floor — the estimate is unstable
                                     and its CI is not trustworthy even when the model converges
      
      Both name the two remedies, because the choice between them is a study-design decision and
      not a numerical one:
      
        1. Firth's penalised likelihood (`logistf` in R, `Logit(...).fit_regularized` in
           statsmodels) — keeps one model, gives finite estimates.
        2. A two-stage rule: classify the sign-positive cases directly, and model only the
           sign-negative remainder. When the sign is pathognomonic this is usually also the
           clinically meaningful design, because a sign-positive patient is already diagnosed and
           the interesting question is what to do with everyone else.
      
      Usage:
          check_separation.py --data cohort.csv --outcome idh_mutant \\
              [--predictor t2flair_mismatch --predictor sex] [--auto] \\
              [--sparse-floor 5] [--out qc/separation.json] [--strict]
      
      With --auto, every column other than the outcome is screened (categorical columns up to
      --max-levels, plus continuous columns for perfect separation). Stdlib only.
      """
      
      from __future__ import annotations
      
      import argparse
      import csv
      import json
      import sys
      from collections import Counter, defaultdict
      from pathlib import Path
      
      MISSING = {"", "na", "n/a", "nan", "null", "none", "."}
      
      REMEDY = (
          "Remedies (this is a design choice, not a numerical one): (1) Firth's penalised "
          "likelihood (`logistf` in R) keeps a single model and yields finite estimates; "
          "(2) a two-stage rule — classify the sign-positive cases directly and model only the "
          "sign-negative remainder. When the predictor is pathognomonic, (2) is usually also the "
          "clinically meaningful design: a sign-positive patient is already diagnosed."
      )
      
      
      def is_missing(v: str) -> bool:
          return v.strip().lower() in MISSING
      
      
      def numeric(v: str) -> float | None:
          try:
              return float(v)
          except ValueError:
              return None
      
      
      def load(path: Path) -> tuple[list[str], list[dict[str, str]]]:
          with path.open(newline="", encoding="utf-8-sig", errors="replace") as fh:
              r = csv.DictReader(fh)
              rows = [row for row in r]
              return (r.fieldnames or []), rows
      
      
      def check_categorical(pred: str, pairs: list[tuple[str, str]], floor: int) -> list[dict]:
          """Cross-tabulate a categorical predictor against the outcome and read the cells."""
          table: dict[str, Counter] = defaultdict(Counter)
          outcomes = sorted({o for _, o in pairs})
          for p, o in pairs:
              table[p][o] += 1
      
          findings: list[dict] = []
          for level in sorted(table):
              for out in outcomes:
                  n = table[level][out]
                  if n == 0:
                      findings.append(
                          {
                              "verdict": "COMPLETE_SEPARATION",
                              "severity": "major",
                              "predictor": pred,
                              "cell": {"level": level, "outcome": out, "n": 0},
                              "table": {lv: dict(c) for lv, c in table.items()},
                              "detail": (
                                  f"`{pred}` = {level!r} has ZERO cases with outcome = {out!r}. The "
                                  f"predictor separates the outcome perfectly at this level, so the "
                                  f"logistic MLE does not exist: the model will still run and report an "
                                  f"odds ratio near 0 (or enormous) with p ~ 1, and any AUC it produces "
                                  f"is a numerical artifact. {REMEDY}"
                              ),
                          }
                      )
                  elif n < floor:
                      findings.append(
                          {
                              "verdict": "QUASI_SEPARATION",
                              "severity": "major",
                              "predictor": pred,
                              "cell": {"level": level, "outcome": out, "n": n},
                              "table": {lv: dict(c) for lv, c in table.items()},
                              "detail": (
                                  f"`{pred}` = {level!r} has only {n} case(s) with outcome = {out!r} "
                                  f"(below the sparsity floor of {floor}). The estimate for this level is "
                                  f"unstable and its confidence interval is not trustworthy even when the "
                                  f"model converges. {REMEDY}"
                              ),
                          }
                      )
          return findings
      
      
      def check_continuous(pred: str, pairs: list[tuple[float, str]]) -> list[dict]:
          """A continuous predictor whose ranges do not overlap across the outcome separates it
          perfectly — the same failure, reached from the other direction."""
          by_out: dict[str, list[float]] = defaultdict(list)
          for v, o in pairs:
              by_out[o].append(v)
          if len(by_out) != 2:
              return []
          (a, va), (b, vb) = sorted(by_out.items())
          if max(va) < min(vb) or max(vb) < min(va):
              return [
                  {
                      "verdict": "COMPLETE_SEPARATION",
                      "severity": "major",
                      "predictor": pred,
                      "cell": {
                          f"{a}_range": [min(va), max(va)],
                          f"{b}_range": [min(vb), max(vb)],
                      },
                      "detail": (
                          f"`{pred}` separates the outcome perfectly: its range for {a!r} "
                          f"([{min(va)}, {max(va)}]) does not overlap its range for {b!r} "
                          f"([{min(vb)}, {max(vb)}]). A threshold classifies every case, so the logistic "
                          f"MLE diverges. {REMEDY}"
                      ),
                  }
              ]
          return []
      
      
      def audit(data: Path, outcome: str, predictors: list[str], auto: bool,
                floor: int, max_levels: int) -> dict:
          fields, rows = load(data)
          if outcome not in fields:
              raise SystemExit(f"outcome column {outcome!r} not in {data.name} (columns: {', '.join(fields)})")
      
          out_vals = {r[outcome].strip() for r in rows if not is_missing(r[outcome])}
          if len(out_vals) != 2:
              raise SystemExit(
                  f"outcome {outcome!r} has {len(out_vals)} distinct values ({sorted(out_vals)}); "
                  "separation is defined for a binary outcome."
              )
      
          if auto:
              predictors = [c for c in fields if c != outcome]
          missing_cols = [p for p in predictors if p not in fields]
          if missing_cols:
              raise SystemExit(f"predictor column(s) not in {data.name}: {', '.join(missing_cols)}")
      
          findings: list[dict] = []
          screened: list[str] = []
          skipped: list[dict] = []
      
          for pred in predictors:
              pairs = [
                  (r[pred].strip(), r[outcome].strip())
                  for r in rows
                  if not is_missing(r[pred]) and not is_missing(r[outcome])
              ]
              if not pairs:
                  skipped.append({"predictor": pred, "reason": "no complete cases"})
                  continue
      
              levels = {p for p, _ in pairs}
              nums = [numeric(p) for p, _ in pairs]
              all_numeric = all(n is not None for n in nums)
      
              if len(levels) <= max_levels and not (all_numeric and len(levels) > max_levels):
                  findings.extend(check_categorical(pred, pairs, floor))
                  screened.append(pred)
              elif all_numeric:
                  findings.extend(check_continuous(pred, [(n, o) for n, (_, o) in zip(nums, pairs)]))  # type: ignore[arg-type]
                  screened.append(pred)
              else:
                  skipped.append(
                      {"predictor": pred, "reason": f"{len(levels)} levels, not numeric — an identifier?"}
                  )
      
          return {
              "detector": "check_separation",
              "data": str(data),
              "outcome": outcome,
              "outcome_levels": sorted(out_vals),
              "screened": screened,
              "skipped": skipped,
              "sparse_floor": floor,
              "findings": findings,
              "summary": {
                  "COMPLETE_SEPARATION": sum(1 for f in findings if f["verdict"] == "COMPLETE_SEPARATION"),
                  "QUASI_SEPARATION": sum(1 for f in findings if f["verdict"] == "QUASI_SEPARATION"),
              },
              "model_safe": not findings,
          }
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
          ap.add_argument("--data", required=True, type=Path, help="CSV, one row per analysis unit")
          ap.add_argument("--outcome", required=True, help="binary outcome column")
          ap.add_argument("--predictor", action="append", default=[], dest="predictors",
                          help="predictor entering the model (repeatable)")
          ap.add_argument("--auto", action="store_true", help="screen every column except the outcome")
          ap.add_argument("--sparse-floor", type=int, default=5,
                          help="a non-zero cell below this is quasi-separation (default 5)")
          ap.add_argument("--max-levels", type=int, default=10,
                          help="a column with more distinct values than this is treated as continuous")
          ap.add_argument("--out", type=Path, help="write the JSON audit record here")
          ap.add_argument("--strict", action="store_true", help="exit 1 if any separation is found")
          ap.add_argument("--quiet", action="store_true")
          a = ap.parse_args()
      
          if not a.data.is_file():
              raise SystemExit(f"not found: {a.data}")
          if not a.predictors and not a.auto:
              raise SystemExit("give at least one --predictor, or --auto to screen every column")
      
          rep = audit(a.data, a.outcome, a.predictors, a.auto, a.sparse_floor, a.max_levels)
      
          if a.out:
              a.out.parent.mkdir(parents=True, exist_ok=True)
              a.out.write_text(json.dumps(rep, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
      
          if not a.quiet:
              print(f"{a.data.name}: outcome {a.outcome!r}, {len(rep['screened'])} predictor(s) screened")
              for s in rep["skipped"]:
                  print(f"  skipped {s['predictor']}: {s['reason']}")
              for f in rep["findings"]:
                  print(f"  [{f['severity'].upper()}] {f['verdict']} — {f['detail']}")
              if not rep["findings"]:
                  print("  OK — no empty or sparse predictor x outcome cell; the MLE is well defined")
      
          return 1 if (a.strict and rep["findings"]) else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • demo_analysis_run.py 2 KB
      #!/usr/bin/env python3
      """Create an original synthetic project, calculate twice and compare the records.
      
      Usage: demo_analysis_run.py --out NEW_PROJECT_DIRECTORY
      No study data, downloaded sources or network access are used.
      """
      import argparse
      import csv
      import json
      from pathlib import Path
      
      import run_analysis
      
      
      def main():
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("--out", required=True, help="New directory; existing directories are refused")
          args = parser.parse_args()
          root = Path(args.out).resolve()
          root.mkdir(parents=True, exist_ok=False)
          with (root / "data.csv").open("w", newline="", encoding="utf-8") as stream:
              writer = csv.writer(stream)
              writer.writerow(["unit_id", "truth", "prediction"])
              for i in range(20):
                  writer.writerow([f"synthetic_{i:02}", int(i < 10), int(i < 8 or 10 <= i < 13)])
          config = {"schema_version": 1, "analysis_unit": "case", "unit_id_col": "unit_id",
                    "truth_col": "truth", "prediction_col": "prediction", "missing_policy": "error",
                    "independent_units": True, "data_status": "synthetic"}
          (root / "analysis.json").write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
          first = run_analysis.run(root, "data.csv", "analysis.json", "runs/first")
          # Independent fixture arithmetic: the first ten rows are positive; predictions
          # include eight of those and three of the remaining ten rows.
          if first["counts"] != {"TP": 8, "FN": 2, "FP": 3, "TN": 7}:
              raise RuntimeError("Synthetic confusion counts disagree with the authored fixture")
          run_analysis.run(root, "data.csv", "analysis.json", "runs/repeat")
          comparison = run_analysis.compare(root, "runs/first", "runs/repeat")
          if comparison["status"] != "compared" or not comparison["recorded_numeric_results_equal"]:
              raise RuntimeError("Synthetic rerun failed its execution-record comparison")
          print(json.dumps({"synthetic_counts": first["counts"], "comparison": comparison}, indent=2))
      
      
      if __name__ == "__main__":
          main()
      
    • rating_monotonicity.py 5.4 KB
      #!/usr/bin/env python3
      """Confidence-weighted rating → AUC monotonicity probe (reusable template).
      
      NOT an auto-discovered detector — it needs the score *encoding* as input, so it is
      a template/helper you point at your own score definition, not a manuscript scanner.
      
      When an observer / reader study collapses a (binary-call × confidence) rating into a
      single score used as the ROC/AUC predictor, that score MUST be strictly monotonic in
      "evidence for the positive label" across the full ladder:
      
          negative-call, highest confidence   = strongest evidence AGAINST positive = LOWEST
          …
          negative-call, lowest confidence
          positive-call, lowest confidence
          …
          positive-call, highest confidence    = strongest evidence FOR positive = HIGHEST
      
      A *folded* score — the classic bug `cws = confidence if positive_call else (K+1) − confidence`
      — makes negative/high-confidence collide with positive/low-confidence and is NOT
      monotonic; it understates discrimination and can flip a gradient to equivalence. Prose
      review cannot see this; re-checking the encoding does.
      
      Input JSON (`--encoding score_def.json`):
          {
            "confidence_levels": [1, 2, 3, 4, 5],
            "scores": {
              "positive": {"1": 6, "2": 7, "3": 8, "4": 9, "5": 10},
              "negative": {"1": 5, "2": 4, "3": 3, "4": 2, "5": 1}
            }
          }
      `scores[call][confidence]` is the numeric value fed to the ROC/AUC routine for that cell.
      
      Exit codes: 0 monotonic, 1 a collision/inversion (folded or mis-encoded), 2 usage.
      Stdlib-only (json / argparse / sys). Run `--demo` for the 10-combination unit test.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      
      
      def evidence_order(levels: list) -> list[tuple[str, object]]:
          """Cells ordered from strongest-against-positive to strongest-for-positive."""
          asc = sorted(levels, key=lambda x: float(x))
          # negative call: highest confidence first (strongest against) → lowest
          neg = [("negative", c) for c in reversed(asc)]
          # positive call: lowest confidence first → highest (strongest for)
          pos = [("positive", c) for c in asc]
          return neg + pos
      
      
      def check_encoding(spec: dict) -> dict:
          levels = spec["confidence_levels"]
          scores = spec["scores"]
          order = evidence_order(levels)
          seq = []
          for call, conf in order:
              # JSON object keys are strings; tolerate int/str confidence keys.
              cell = scores[call]
              val = cell.get(str(conf), cell.get(conf))
              if val is None:
                  return {"ok": False, "problems": [f"missing score for {call}/conf {conf}"],
                          "sequence": seq}
              seq.append({"call": call, "confidence": conf, "score": val})
          problems = []
          for a, b in zip(seq, seq[1:]):
              if b["score"] == a["score"]:
                  problems.append(
                      f"collision: {a['call']}/{a['confidence']} and {b['call']}/{b['confidence']} "
                      f"share score {a['score']} (a folded/mirrored encoding maps opposite evidence "
                      f"to the same value)")
              elif b["score"] < a["score"]:
                  problems.append(
                      f"inversion: {a['call']}/{a['confidence']} (={a['score']}) ranks above "
                      f"{b['call']}/{b['confidence']} (={b['score']}) but carries weaker evidence "
                      f"for the positive label")
          return {"ok": not problems, "problems": problems,
                  "sequence": [(s["call"], s["confidence"], s["score"]) for s in seq]}
      
      
      def _demo() -> int:
          """The 10-combination (K=5) unit test: a correct directional encoding passes,
          the folded encoding fails on collisions."""
          levels = [1, 2, 3, 4, 5]
          correct = {"confidence_levels": levels,
                     "scores": {"positive": {str(c): 5 + c for c in levels},
                                "negative": {str(c): 6 - c for c in levels}}}
          folded = {"confidence_levels": levels,
                    "scores": {"positive": {str(c): c for c in levels},
                               "negative": {str(c): 6 - c for c in levels}}}
          rc = check_encoding(correct)
          rf = check_encoding(folded)
          ok = rc["ok"] and not rf["ok"]
          print(f"correct directional encoding: monotonic={rc['ok']} (expected True)")
          print(f"folded encoding:              monotonic={rf['ok']} (expected False)")
          if rf["problems"]:
              print(f"  folded problems[0]: {rf['problems'][0]}")
          print("DEMO PASS" if ok else "DEMO FAIL")
          return 0 if ok else 1
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Confidence-weighted rating→AUC monotonicity probe.")
          ap.add_argument("--encoding", help="JSON score-definition file (see module docstring)")
          ap.add_argument("--demo", action="store_true", help="run the 10-combination unit test")
          args = ap.parse_args()
      
          if args.demo:
              return _demo()
          if not args.encoding:
              sys.stderr.write("ERROR: --encoding FILE or --demo is required\n")
              return 2
          try:
              spec = json.loads(open(args.encoding, encoding="utf-8").read())
          except (OSError, ValueError) as e:
              sys.stderr.write(f"ERROR: cannot read encoding: {e}\n")
              return 2
      
          res = check_encoding(spec)
          if res["ok"]:
              print("OK: the (call × confidence) → score encoding is strictly monotonic.")
              return 0
          print("NON-MONOTONIC encoding (folded or mis-ordered) — this mis-estimates the AUC:")
          for p in res["problems"]:
              print(f"  - {p}")
          return 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • run_analysis.py 17.4 KB
      #!/usr/bin/env python3
      """Run a bounded binary diagnostic-accuracy workflow with its existing template.
      
      Usage: run_analysis.py run --project-root . --data data.csv --config analysis.json --out runs/run1
             run_analysis.py audit --project-root . --out runs/run1
             run_analysis.py compare --project-root . --previous runs/run1 --out runs/run2
      
      The existing _analysis_outputs.md is both the readable output index and the
      execution record. No raw rows/identifiers are copied into reports. Audit checks
      recorded file versions, not whether a study design or declaration is true.
      """
      from __future__ import annotations
      
      import argparse
      import contextlib
      import csv
      from datetime import datetime, timezone
      import hashlib
      import importlib.util
      import io
      import json
      import math
      from pathlib import Path, PurePosixPath
      import platform
      import sys
      import tempfile
      
      SKILL = Path(__file__).resolve().parents[1]
      CODE = {"runner": Path(__file__).resolve(),
              "template": SKILL / "references/templates/diagnostic_accuracy.py",
              "style": SKILL / "references/style/figure_style.mplstyle"}
      MANIFEST = "_analysis_outputs.md"
      BEGIN = "<!-- MEDSCI_ANALYSIS_RUN_BEGIN -->\n```json\n"
      END = "\n```\n<!-- MEDSCI_ANALYSIS_RUN_END -->\n"
      
      
      def sha256(path: Path) -> str:
          h = hashlib.sha256()
          with path.open("rb") as stream:
              for chunk in iter(lambda: stream.read(1024 * 1024), b""):
                  h.update(chunk)
          return h.hexdigest()
      
      
      def local_path(root: Path, relative: str) -> Path:
          if not isinstance(relative, str) or not relative or "\\" in relative or ":" in relative:
              raise ValueError("Use a project-relative POSIX path")
          parts = PurePosixPath(relative).parts
          if not parts or relative.startswith("/") or any(p.startswith(".") for p in parts):
              raise ValueError("Absolute, hidden or traversing paths are not supported")
          path = root
          for part in parts:
              path = path / part
              if path.is_symlink():
                  raise ValueError("Symlink paths are not supported")
          path.resolve().relative_to(root)
          return path
      
      
      def configuration(path: Path) -> dict:
          value = json.loads(path.read_text(encoding="utf-8"))
          fields = {"schema_version", "analysis_unit", "unit_id_col", "truth_col",
                    "prediction_col", "missing_policy", "independent_units", "data_status"}
          if not isinstance(value, dict) or set(value) != fields:
              raise ValueError("Configuration must contain exactly the documented fields")
          if type(value["schema_version"]) is not int or value["schema_version"] != 1:
              raise ValueError("Configuration schema_version must be 1")
          if value["analysis_unit"] not in {"patient", "participant", "exam", "lesion", "image", "case", "study", "sample"}:
              raise ValueError("Unsupported analysis unit")
          if value["independent_units"] is not True:
              raise ValueError("This workflow requires declared independent units; clustered analysis is separate")
          if value["missing_policy"] not in {"error", "complete_case"}:
              raise ValueError("Choose error or complete_case for missing labels")
          if value["data_status"] not in {"synthetic", "deidentified_authorized"}:
              raise ValueError("Declare synthetic or deidentified_authorized data before running")
          columns = [value[k] for k in ("unit_id_col", "truth_col", "prediction_col")]
          if any(not isinstance(x, str) or not x.strip() for x in columns) or len(set(columns)) != 3:
              raise ValueError("Three distinct nonempty input columns are required")
          return value
      
      
      def read_rows(path: Path, config: dict):
          import numpy as np
          ids, truth, predictions = set(), [], []
          counts = {"input_rows": 0, "included_rows": 0, "excluded_missing_labels": 0,
                    "missing_truth": 0, "missing_prediction": 0}
          with path.open(encoding="utf-8-sig", newline="") as stream:
              reader = csv.DictReader(stream)
              headers = reader.fieldnames or []
              if len(set(headers)) != len(headers):
                  raise ValueError("Duplicate CSV headers")
              if not {config[k] for k in ("unit_id_col", "truth_col", "prediction_col")} <= set(headers):
                  raise ValueError("Required row-level columns are missing; aggregate percentages are unsupported")
              for row in reader:
                  if None in row or any(v is None for v in row.values()):
                      raise ValueError("Malformed CSV row")
                  counts["input_rows"] += 1
                  unit = row[config["unit_id_col"]].strip()
                  if not unit or unit in ids:
                      raise ValueError("Missing or repeated unit ID; no raw identifiers are printed")
                  ids.add(unit)
                  a, b = (row[config[k]].strip() for k in ("truth_col", "prediction_col"))
                  if a not in {"", "0", "1"} or b not in {"", "0", "1"}:
                      raise ValueError("Labels must be literal 0/1 or empty; scores and rounded summaries are unsupported")
                  counts["missing_truth"] += int(a == "")
                  counts["missing_prediction"] += int(b == "")
                  if not a or not b:
                      counts["excluded_missing_labels"] += 1
                      if config["missing_policy"] == "error":
                          raise ValueError("Missing labels; choose a justified missing-data policy explicitly")
                      continue
                  truth.append(int(a))
                  predictions.append(int(b))
          counts["included_rows"] = len(truth)
          if not truth:
              raise ValueError("No complete observations")
          return np.array(truth), np.array(predictions), counts
      
      
      def load_template():
          spec = importlib.util.spec_from_file_location("medsci_diagnostic_template", CODE["template"])
          module = importlib.util.module_from_spec(spec)
          spec.loader.exec_module(module)
          return module
      
      
      def file_record(path: Path, relative: str) -> dict:
          return {"path": relative, "sha256": sha256(path), "bytes": path.stat().st_size}
      
      
      def code_records() -> dict:
          return {role: file_record(path, path.relative_to(SKILL).as_posix()) for role, path in CODE.items()}
      
      
      def render_manifest(record: dict) -> str:
          rows = record["metrics"]
          lines = ["# Analysis Outputs", f"Generated: {record['generated_at']}",
                   "Study type: binary diagnostic accuracy from prespecified labels", "",
                   "## Tables", "- `diagnostic_accuracy_table.csv` -- Full precision estimates, counts and Wilson 95% CIs", "",
                   "## Figures", "- `confusion_matrix.pdf` / `confusion_matrix.png` -- Counts; percentages use included N", "",
                   "## Data", "Input data are referenced by hash below; no raw rows or IDs are copied.", "",
                   "## Results", f"Declared analysis unit: {record['config']['analysis_unit']}.",
                   f"Included: {record['cohort']['included_rows']}/{record['cohort']['input_rows']} rows; "
                   f"excluded for missing labels: {record['cohort']['excluded_missing_labels']}.", "",
                   "| Metric | Numerator / denominator | Estimate | 95% CI | Status |",
                   "|---|---:|---:|---|---|"]
          for row in rows:
              estimate = "undefined" if row["estimate"] is None else f"{row['estimate']:.3f}"
              interval = "undefined" if row["ci_lower"] is None else f"{row['ci_lower']:.3f} to {row['ci_upper']:.3f}"
              lines.append(f"| {row['metric']} | {row['numerator']} / {row['denominator']} | {estimate} | {interval} | {row['status']} |")
          lines += ["", "## Scope", "Wilson intervals are conditional on independent units and the declared sample.",
                    "Unique IDs do not establish independence, valid reference labels or representative sampling.",
                    "No p-value comparison, AUC, calibration, threshold optimization or clinical-utility analysis is performed.",
                    "Study validity, privacy clearance, reuse rights and visual review remain not_assessed.",
                    "Hashes identify versions, not a backup or authenticated proof of correct source declarations.", "",
                    "## Execution record", BEGIN.rstrip("\n"),
                    json.dumps(record, indent=2, ensure_ascii=False, allow_nan=False), END.lstrip("\n").rstrip("\n"), ""]
          return "\n".join(lines)
      
      
      def read_manifest(directory: Path) -> tuple[dict, bool]:
          path = local_path(directory, MANIFEST)
          text = path.read_text(encoding="utf-8")
          if text.count(BEGIN) != 1 or text.count(END) != 1:
              raise ValueError("Missing or ambiguous execution record; legacy manifests are not bound")
          payload = text.split(BEGIN, 1)[1].split(END, 1)[0]
          record = json.loads(payload)
          if not isinstance(record, dict) or record.get("schema_version") != 1 or record.get("workflow") != "binary_diagnostic_accuracy":
              raise ValueError("Unsupported execution record")
          return record, text == render_manifest(record)
      
      
      def run(root: Path, data_rel: str, config_rel: str, out_rel: str) -> dict:
          data, config_path, out = (local_path(root, x) for x in (data_rel, config_rel, out_rel))
          if out.exists():
              raise ValueError("Output directory already exists; use a new run directory")
          if data == config_path or data.samefile(config_path):
              raise ValueError("Data and configuration must be distinct files")
          before = {"data": file_record(data, data_rel), "config": file_record(config_path, config_rel)}
          code = code_records()
          config = configuration(config_path)
          truth, predictions, cohort = read_rows(data, config)
          template = load_template()
          template.np.random.seed(42)
          metrics = template.compute_metrics(truth, predictions)
          tp, fp, tn, fn = (metrics["_counts"][k] for k in ("TP", "FP", "TN", "FN"))
          denominators = {"Sensitivity": (tp, tp + fn), "Specificity": (tn, tn + fp),
                          "PPV": (tp, tp + fp), "NPV": (tn, tn + fn), "Accuracy": (tp + tn, len(truth))}
          rows = []
          for name, (numerator, denominator) in denominators.items():
              estimate, lower, upper = metrics[name]
              rows.append({"metric": name, "numerator": int(numerator), "denominator": int(denominator),
                           "estimate": float(estimate) if math.isfinite(estimate) else None,
                           "ci_lower": float(lower) if math.isfinite(lower) else None,
                           "ci_upper": float(upper) if math.isfinite(upper) else None,
                           "status": "defined" if denominator else "undefined_zero_denominator",
                           "analysis_unit": config["analysis_unit"], "ci_method": "wilson_95_no_continuity_correction"})
          from matplotlib import font_manager
          font = Path(font_manager.findfont(font_manager.FontProperties()))
          environment = {"python": platform.python_version(), "numpy": template.np.__version__,
                         "pandas": template.pd.__version__, "scipy": template.scipy.__version__,
                         "sklearn": template.sklearn.__version__, "matplotlib": template.matplotlib.__version__,
                         "font_file": font.name, "font_sha256": sha256(font)}
          out.parent.mkdir(parents=True, exist_ok=True)
          with tempfile.TemporaryDirectory(prefix=".analysis-run-", dir=out.parent) as temporary:
              stage = Path(temporary) / "result"
              stage.mkdir()
              with (stage / "diagnostic_accuracy_table.csv").open("w", encoding="utf-8", newline="") as stream:
                  writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
                  writer.writeheader()
                  writer.writerows(rows)
              with contextlib.redirect_stdout(io.StringIO()):
                  template.plot_confusion_matrix(truth, {"Prespecified predictions": predictions},
                                                 ["Prespecified predictions"], str(stage))
              outputs = [file_record(stage / name, name) for name in (
                  "diagnostic_accuracy_table.csv", "confusion_matrix.pdf", "confusion_matrix.png")]
              after = {"data": file_record(data, data_rel), "config": file_record(config_path, config_rel)}
              if before != after or code != code_records():
                  raise ValueError("An input, code or style file changed during execution; run not published")
              record = {"schema_version": 1, "workflow": "binary_diagnostic_accuracy",
                        "generated_at": datetime.now(timezone.utc).isoformat(), "inputs": before,
                        "code": code, "config": config, "environment": environment,
                        "random_seed": 42, "randomness_used": False,
                        "cohort": cohort, "counts": metrics["_counts"], "metrics": rows, "outputs": outputs,
                        "command": ["python3", "<analyze-stats>/scripts/run_analysis.py", "run", "--project-root", ".",
                                    "--data", data_rel, "--config", config_rel, "--out", out_rel],
                        "checks": {"binary_labels": "passed", "unique_declared_unit_ids": "passed",
                                   "input_code_unchanged_during_run": "passed", "study_validity": "not_assessed",
                                   "privacy_clearance": "not_assessed", "reuse_rights": "not_assessed", "visual_review": "not_assessed"}}
              (stage / MANIFEST).write_text(render_manifest(record), encoding="utf-8")
              if out.exists():
                  raise ValueError("Output appeared during execution; nothing overwritten")
              stage.rename(out)
          return record
      
      
      def audit(root: Path, out_rel: str) -> dict:
          directory = local_path(root, out_rel)
          record, text_matches = read_manifest(directory)
          changes = [] if text_matches else ["manifest_text_changed"]
          for role, entry in record["inputs"].items():
              path = local_path(root, entry["path"])
              if not path.is_file() or sha256(path) != entry["sha256"]:
                  changes.append(f"input:{role}")
          if set(record["code"]) != set(CODE):
              changes.append("code:inventory")
          for role, path in CODE.items():
              if record["code"].get(role, {}).get("sha256") != sha256(path):
                  changes.append(f"code:{role}")
          expected = {MANIFEST}
          for entry in record["outputs"]:
              expected.add(entry["path"])
              path = local_path(directory, entry["path"])
              if not path.is_file() or sha256(path) != entry["sha256"]:
                  changes.append(f"output:{entry['path']}")
          actual = {p.name for p in directory.iterdir()}
          if actual != expected:
              changes.append("output:inventory")
          return {"status": "drift" if changes else "current", "changes": changes,
                  "scope": "Recorded input/code/output bytes only; no re-analysis or design approval",
                  "study_validity": "not_assessed"}
      
      
      def compare(root: Path, previous: str, current: str) -> dict:
          old, _ = read_manifest(local_path(root, previous))
          new, _ = read_manifest(local_path(root, current))
          # Declared units and columns are part of the estimand context, even if the
          # displayed estimate happens to be identical. Do not compare rounded prose.
          keys = ("analysis_unit", "unit_id_col", "truth_col", "prediction_col", "missing_policy", "independent_units")
          comparable = all(old["config"][k] == new["config"][k] for k in keys)
          context_changed = [k for k in ("inputs", "code", "environment") if old[k] != new[k]]
          previous_audit, current_audit = audit(root, previous), audit(root, current)
          drift = any(result["status"] == "drift" for result in (previous_audit, current_audit))
          return {"status": "drift" if drift else "compared",
                  "context_status": "not_comparable" if not comparable else "context_changed" if context_changed else "same_context",
                  "context_changed": context_changed, "declared_context_matches": comparable,
                  "recorded_numeric_results_equal": old["metrics"] == new["metrics"] if comparable else None,
                  "cohort_equal": old["cohort"] == new["cohort"],
                  "recorded_outputs_equal": old["outputs"] == new["outputs"],
                  "previous_audit": previous_audit, "current_audit": current_audit,
                  "scope": "Comparison of recorded values, not equivalence or a rerun; consult both byte audits",
                  "study_validity": "not_assessed"}
      
      
      def main() -> int:
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("mode", choices=("run", "audit", "compare"))
          parser.add_argument("--project-root", default=".")
          parser.add_argument("--data")
          parser.add_argument("--config")
          parser.add_argument("--out", required=True)
          parser.add_argument("--previous")
          args = parser.parse_args()
          try:
              root = Path(args.project_root).resolve()
              if not root.is_dir():
                  raise ValueError("Project root must exist")
              if args.mode == "run":
                  if not args.data or not args.config or args.previous:
                      raise ValueError("run requires --data and --config, without --previous")
                  record = run(root, args.data, args.config, args.out)
                  result = {"status": "completed", "manifest": f"{args.out}/{MANIFEST}",
                            "included_rows": record["cohort"]["included_rows"], "study_validity": "not_assessed"}
              elif args.data or args.config:
                  raise ValueError("audit/compare read recorded inputs; do not supply --data or --config")
              elif args.mode == "audit":
                  if args.previous:
                      raise ValueError("--previous is only used by compare")
                  result = audit(root, args.out)
              else:
                  if not args.previous:
                      raise ValueError("compare requires --previous")
                  result = compare(root, args.previous, args.out)
              print(json.dumps(result, indent=2, allow_nan=False))
              return 1 if result["status"] == "drift" else 0
          except (OSError, ValueError, KeyError, TypeError, ImportError) as exc:
              print(f"Analysis workflow error ({type(exc).__name__}): {exc}", file=sys.stderr)
              return 2
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • tests
    • fixtures
      • gen_bad.py 743 B
        """
        Analysis: synthetic BAD fixture for the generated-code quality gate.
        Date: 2020-01-01
        Random seed: (intentionally missing)
        """
        import numpy as np
        import pandas as pd
        import json  # unused import (dead dependency)
        
        # absolute path literal + source read
        df = pd.read_csv("/Users/researcher/data/cohort.csv")
        
        # hand-typed tabular data instead of read_csv + subset
        ref = pd.DataFrame({
            "auc": [0.81, 0.83, 0.79, 0.88, 0.84, 0.77, 0.82, 0.86, 0.80, 0.85, 0.78, 0.87, 0.83, 0.81],
        })
        
        # randomness with no seed set -> non-reproducible
        boot = np.random.choice(df["auc"].values, size=1000, replace=True)
        
        breakpoint()  # debugger left in
        
        # writes back to the source path -> overwrites raw data
        df.to_csv("/Users/researcher/data/cohort.csv")
        
      • gen_bad.R 578 B · in bundle
      • gen_clean.py 553 B
        """
        Analysis: synthetic CLEAN fixture for the generated-code quality gate.
        Date: 2026-01-01
        Random seed: 42
        """
        import numpy as np
        import pandas as pd
        
        np.random.seed(42)
        
        # portable relative path; no hand-typed data
        df = pd.read_csv("cohort.csv")
        
        # seeded randomness -> reproducible
        boot = np.random.choice(df["auc"].values, size=1000, replace=True)
        boot_mean = float(np.mean(boot))
        
        # derived output written to a NEW path, not the source
        summary = df["auc"].describe()
        summary.to_csv("auc_summary.csv")
        print(f"bootstrap mean AUC = {boot_mean:.3f}")
        
      • gen_palette.py 742 B
        """
        Analysis: synthetic CLEAN fixture exercising the colorblind-safe palette.
        A hex-color list must NOT be flagged HARDCODED_DATA_LITERAL even alongside a
        data-file read (regression for the WONG-palette false positive).
        Date: 2026-01-01
        Random seed: 42
        """
        import numpy as np
        import pandas as pd
        
        np.random.seed(42)
        
        # The Wong (2011) colorblind-safe palette that make-figures recommends. Eight
        # string literals; the digits live inside the hex codes, not in tabular data.
        WONG = ["#000000", "#E69F00", "#56B4E9", "#009E73",
                "#F0E442", "#0072B2", "#D55E00", "#CC79A7"]
        
        # portable relative path; no hand-typed numeric data
        df = pd.read_csv("cohort.csv")
        
        means = df.groupby("group")["auc"].mean()
        print(WONG[0], float(means.iloc[0]))
        
    • test_analysis_run.py 16.5 KB
      #!/usr/bin/env python3
      """Original synthetic controls for execution records and exact binary counts."""
      import contextlib
      import csv
      from fractions import Fraction
      import io
      import json
      import math
      from pathlib import Path
      import shutil
      import subprocess
      import sys
      import tempfile
      import unittest
      from unittest.mock import patch
      
      sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
      import run_analysis as run
      
      CONFIG = {"schema_version": 1, "analysis_unit": "case", "unit_id_col": "unit_id",
                "truth_col": "truth", "prediction_col": "prediction", "missing_policy": "error",
                "independent_units": True, "data_status": "synthetic"}
      
      
      def write_data(root):
          with (root / "data.csv").open("w", newline="") as stream:
              writer = csv.writer(stream)
              writer.writerow(["unit_id", "truth", "prediction"])
              # The fixture is assembled from four groups, not inferred from rounded metrics.
              index = 0
              for truth, prediction, count in ((1, 1, 8), (1, 0, 2), (0, 1, 3), (0, 0, 7)):
                  for _ in range(count):
                      writer.writerow([f"unit_{index}", truth, prediction])
                      index += 1
          (root / "analysis.json").write_text(json.dumps(CONFIG))
      
      
      class AnalysisTests(unittest.TestCase):
          @classmethod
          def setUpClass(cls):
              cls.base = tempfile.TemporaryDirectory()
              cls.base_path = Path(cls.base.name).resolve()
              write_data(cls.base_path)
              cls.record = run.run(cls.base_path, "data.csv", "analysis.json", "runs/first")
      
          @classmethod
          def tearDownClass(cls):
              cls.base.cleanup()
      
          def setUp(self):
              self.temp = tempfile.TemporaryDirectory()
              self.addCleanup(self.temp.cleanup)
              self.root = Path(self.temp.name).resolve()
              shutil.copytree(self.base_path, self.root, dirs_exist_ok=True)
              self.output = self.root / "runs/first"
      
          def config(self, **changes):
              value = {**CONFIG, **changes}
              (self.root / "analysis.json").write_text(json.dumps(value))
      
          def execute(self, out="runs/second"):
              return run.run(self.root, "data.csv", "analysis.json", out)
      
          def test_exact_independent_counts_and_denominators(self):
              # Independent integer arithmetic, without calling the implementation's metric helper.
              cells = self.record["counts"]
              self.assertEqual(cells, {"TP": 8, "FP": 3, "TN": 7, "FN": 2})
              expected = {"Sensitivity": Fraction(8, 10), "Specificity": Fraction(7, 10),
                          "PPV": Fraction(8, 11), "NPV": Fraction(7, 9), "Accuracy": Fraction(15, 20)}
              for row in self.record["metrics"]:
                  self.assertEqual(Fraction(row["numerator"], row["denominator"]), expected[row["metric"]])
                  self.assertAlmostEqual(row["estimate"], float(expected[row["metric"]]), places=14)
      
          def test_intervals_match_independent_scipy_implementation(self):
              from scipy.stats import binomtest
              for row in self.record["metrics"]:
                  reference = binomtest(row["numerator"], row["denominator"]).proportion_ci(method="wilson")
                  self.assertAlmostEqual(row["ci_lower"], reference.low, places=12)
                  self.assertAlmostEqual(row["ci_upper"], reference.high, places=12)
      
          def test_unrounded_csv_and_readable_manifest_are_same_record(self):
              with (self.output / "diagnostic_accuracy_table.csv").open() as stream:
                  rows = list(csv.DictReader(stream))
              self.assertAlmostEqual(float(rows[2]["estimate"]), 8 / 11, places=14)
              self.assertNotEqual(float(rows[2]["estimate"]), 0.727)
              record, matches = run.read_manifest(self.output)
              self.assertTrue(matches)
              self.assertEqual(record["metrics"], self.record["metrics"])
              self.assertEqual(record["checks"]["study_validity"], "not_assessed")
      
          def test_same_context_rerun_preserves_sources_and_outputs(self):
              before = {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in (
                  self.root / "data.csv", self.root / "analysis.json")}
              self.execute()
              self.assertEqual(before, {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in (
                  self.root / "data.csv", self.root / "analysis.json")})
              result = run.compare(self.root, "runs/first", "runs/second")
              self.assertEqual(result["status"], "compared")
              self.assertEqual(result["context_status"], "same_context")
              self.assertTrue(result["recorded_numeric_results_equal"])
              self.assertTrue(result["recorded_outputs_equal"])
      
          def test_changed_input_stays_drift_on_repeated_audits(self):
              with (self.root / "data.csv").open("a") as stream:
                  stream.write("additional_unit,0,0\n")
              old = (self.output / run.MANIFEST).read_bytes()
              for _ in range(2):
                  self.assertIn("input:data", run.audit(self.root, "runs/first")["changes"])
              self.assertEqual(old, (self.output / run.MANIFEST).read_bytes())
      
          def test_changed_configuration_does_not_rebind_old_run(self):
              self.config(missing_policy="complete_case")
              self.assertIn("input:config", run.audit(self.root, "runs/first")["changes"])
      
          def test_same_estimates_different_unit_not_comparable(self):
              self.config(analysis_unit="lesion")
              second = self.execute()
              self.assertEqual(self.record["metrics"][0]["estimate"], second["metrics"][0]["estimate"])
              result = run.compare(self.root, "runs/first", "runs/second")
              self.assertEqual(result["context_status"], "not_comparable")
              self.assertIsNone(result["recorded_numeric_results_equal"])
      
          def test_same_percentage_different_denominator_is_not_equal_result(self):
              with (self.root / "data.csv").open() as stream:
                  rows = list(csv.DictReader(stream))
              with (self.root / "data.csv").open("a", newline="") as stream:
                  writer = csv.writer(stream)
                  for row in rows:
                      writer.writerow([row["unit_id"] + "_copy", row["truth"], row["prediction"]])
              second = self.execute()
              self.assertEqual(self.record["metrics"][0]["estimate"], second["metrics"][0]["estimate"])
              self.assertEqual(second["metrics"][0]["denominator"], 20)
              comparison = run.compare(self.root, "runs/first", "runs/second")
              self.assertFalse(comparison["recorded_numeric_results_equal"])
              self.assertFalse(comparison["cohort_equal"])
      
          def test_repeated_unit_ids_are_not_silently_treated_as_independent(self):
              with (self.root / "data.csv").open("a") as stream:
                  stream.write("unit_0,0,0\n")
              with self.assertRaisesRegex(ValueError, "repeated unit"):
                  self.execute()
              self.assertFalse((self.root / "runs/second").exists())
      
          def test_clustered_unit_declaration_is_not_supported(self):
              self.config(independent_units=False)
              with self.assertRaisesRegex(ValueError, "independent"):
                  self.execute()
      
          def test_missing_labels_require_explicit_policy(self):
              with (self.root / "data.csv").open("a") as stream:
                  stream.write("missing_1,,1\nmissing_2,0,\nmissing_both,,\n")
              with self.assertRaisesRegex(ValueError, "Missing labels"):
                  self.execute()
              self.config(missing_policy="complete_case")
              result = self.execute()
              self.assertEqual(result["cohort"], {"input_rows": 23, "included_rows": 20,
                  "excluded_missing_labels": 3, "missing_truth": 2, "missing_prediction": 2})
      
          def test_one_class_keeps_undefined_metrics_and_two_by_two_figure(self):
              (self.root / "data.csv").write_text("unit_id,truth,prediction\na,0,0\nb,0,0\n")
              result = self.execute()
              sensitivity = result["metrics"][0]
              self.assertEqual(sensitivity["status"], "undefined_zero_denominator")
              self.assertIsNone(sensitivity["estimate"])
              self.assertIsNone(sensitivity["ci_lower"])
              self.assertEqual(result["metrics"][-1]["estimate"], 1.0)
              self.assertTrue((self.root / "runs/second/confusion_matrix.pdf").is_file())
      
          def test_rounded_scores_and_nonbinary_labels_rejected_without_echo(self):
              for value in ("0.727", "2", "NaN", "private_marker"):
                  (self.root / "data.csv").write_text(f"unit_id,truth,prediction\na,1,{value}\n")
                  with self.subTest(value=value), self.assertRaises(ValueError) as error:
                      self.execute()
                  self.assertNotIn(value, str(error.exception))
      
          def test_aggregate_metrics_cannot_be_reverse_engineered_into_rows(self):
              (self.root / "data.csv").write_text("sensitivity,specificity,N\n0.800,0.700,20\n")
              with self.assertRaisesRegex(ValueError, "aggregate"):
                  self.execute()
      
          def test_duplicate_headers_and_malformed_rows_rejected(self):
              for text in ("unit_id,truth,truth,prediction\na,1,1,1\n", "unit_id,truth,prediction\na,1\n"):
                  (self.root / "data.csv").write_text(text)
                  with self.assertRaises(ValueError):
                      self.execute()
      
          def test_unknown_configuration_and_privacy_status_not_silently_ignored(self):
              for changes in ({"threshold": 0.5}, {"data_status": "unknown"}, {"truth_col": "unit_id"}):
                  self.config(**changes)
                  with self.subTest(changes=changes), self.assertRaises(ValueError):
                      self.execute()
      
          def test_existing_output_and_input_alias_not_overwritten(self):
              original = (self.output / run.MANIFEST).read_bytes()
              for target in ("runs/first", "data.csv"):
                  with self.assertRaisesRegex(ValueError, "already exists"):
                      self.execute(target)
              self.assertEqual(original, (self.output / run.MANIFEST).read_bytes())
      
          def test_symlinks_traversal_hidden_and_absolute_paths_rejected(self):
              for path in ("../outside", "/tmp/output", ".hidden", "a/../b", "C:/outside", "a\\b"):
                  with self.subTest(path=path), self.assertRaises(ValueError):
                      run.local_path(self.root, path)
              (self.root / "alias").symlink_to(self.root / "data.csv")
              with self.assertRaises(ValueError):
                  run.local_path(self.root, "alias")
      
          def test_modified_or_missing_output_is_drift(self):
              (self.output / "diagnostic_accuracy_table.csv").write_text("manually edited\n")
              (self.output / "confusion_matrix.png").unlink()
              result = run.audit(self.root, "runs/first")
              self.assertIn("output:diagnostic_accuracy_table.csv", result["changes"])
              self.assertIn("output:confusion_matrix.png", result["changes"])
      
          def test_extra_file_and_edited_readable_summary_are_visible(self):
              (self.output / "unexpected.txt").write_text("synthetic")
              path = self.output / run.MANIFEST
              path.write_text(path.read_text().replace("Included: 20/20", "Included: 19/20", 1))
              result = run.audit(self.root, "runs/first")
              self.assertIn("manifest_text_changed", result["changes"])
              self.assertIn("output:inventory", result["changes"])
      
          def test_changed_code_is_visible_with_identical_numeric_results(self):
              self.execute()
              path = self.output / run.MANIFEST
              old, _ = run.read_manifest(self.output)
              old["code"]["runner"]["sha256"] = "0" * 64
              path.write_text(run.render_manifest(old))
              result = run.compare(self.root, "runs/first", "runs/second")
              self.assertTrue(result["recorded_numeric_results_equal"])
              self.assertEqual(result["status"], "drift")
              self.assertEqual(result["context_status"], "context_changed")
              self.assertIn("code", result["context_changed"])
              self.assertIn("code:runner", result["previous_audit"]["changes"])
      
          def test_actual_code_change_invalidates_prior_run(self):
              copied = self.root / "skill_copy"
              for path in run.CODE.values():
                  destination = copied / path.relative_to(run.SKILL)
                  destination.parent.mkdir(parents=True, exist_ok=True)
                  shutil.copy2(path, destination)
              replacements = {role: copied / path.relative_to(run.SKILL) for role, path in run.CODE.items()}
              with patch.object(run, "SKILL", copied), patch.object(run, "CODE", replacements):
                  self.execute()
                  with replacements["template"].open("a") as stream:
                      stream.write("\n# Synthetic code-version change.\n")
                  result = run.audit(self.root, "runs/second")
                  self.assertIn("code:template", result["changes"])
      
          def test_equal_records_do_not_hide_modified_output_or_exit_successfully(self):
              self.execute()
              (self.output / "diagnostic_accuracy_table.csv").write_text("edited output\n")
              result = run.compare(self.root, "runs/first", "runs/second")
              self.assertTrue(result["recorded_numeric_results_equal"])
              self.assertTrue(result["recorded_outputs_equal"])
              self.assertEqual(result["status"], "drift")
              proc = subprocess.run([sys.executable, str(run.CODE["runner"]), "compare",
                  "--project-root", str(self.root), "--previous", "runs/first", "--out", "runs/second"],
                  capture_output=True, text=True)
              self.assertEqual(proc.returncode, 1, proc.stderr)
              self.assertEqual(json.loads(proc.stdout)["status"], "drift")
      
          def test_input_changes_during_plot_do_not_publish_a_run(self):
              template = run.load_template()
              original = template.plot_confusion_matrix
              def changing(*args):
                  original(*args)
                  with (self.root / "data.csv").open("a") as stream:
                      stream.write("added_during_run,0,0\n")
              with patch.object(template, "plot_confusion_matrix", side_effect=changing), patch.object(run, "load_template", return_value=template):
                  with self.assertRaisesRegex(ValueError, "changed during execution"):
                      self.execute()
              self.assertFalse((self.root / "runs/second").exists())
              self.assertTrue((self.output / run.MANIFEST).is_file())
      
          def test_failed_plot_leaves_no_completed_manifest(self):
              template = run.load_template()
              with patch.object(template, "plot_confusion_matrix", side_effect=OSError("synthetic renderer failure")), patch.object(run, "load_template", return_value=template):
                  with self.assertRaises(OSError):
                      self.execute()
              self.assertFalse((self.root / "runs/second").exists())
      
          def test_legacy_or_corrupt_manifest_is_not_a_current_run(self):
              for text in ("# Analysis Outputs\n- table.csv\n", run.BEGIN + "[]" + run.END):
                  (self.output / run.MANIFEST).write_text(text)
                  with self.assertRaises(ValueError):
                      run.audit(self.root, "runs/first")
      
          def test_real_pdf_png_and_no_raw_identifiers_in_record(self):
              self.assertTrue((self.output / "confusion_matrix.pdf").read_bytes().startswith(b"%PDF"))
              self.assertTrue((self.output / "confusion_matrix.png").read_bytes().startswith(b"\x89PNG"))
              self.assertNotIn("unit_0", (self.output / run.MANIFEST).read_text())
      
          def test_cli_run_and_read_only_audit(self):
              base = [sys.executable, str(run.CODE["runner"])]
              proc = subprocess.run(base + ["run", "--project-root", str(self.root), "--data", "data.csv",
                                           "--config", "analysis.json", "--out", "runs/cli"], capture_output=True, text=True)
              self.assertEqual(proc.returncode, 0, proc.stderr)
              path = self.root / "runs/cli" / run.MANIFEST
              before = path.read_bytes(), path.stat().st_mtime_ns
              proc = subprocess.run(base + ["audit", "--project-root", str(self.root), "--out", "runs/cli"], capture_output=True, text=True)
              self.assertEqual(proc.returncode, 0, proc.stderr)
              self.assertEqual(before, (path.read_bytes(), path.stat().st_mtime_ns))
      
          def test_template_import_and_zero_denominator_contract(self):
              with contextlib.redirect_stdout(io.StringIO()) as output:
                  template = run.load_template()
              self.assertEqual(output.getvalue(), "")
              self.assertTrue(all(math.isnan(x) for x in template.wilson_ci(0, 0)))
      
          def test_shipped_demo_runs_and_refuses_existing_project(self):
              command = [sys.executable, str(run.SKILL / "scripts/demo_analysis_run.py"),
                         "--out", str(self.root / "demo")]
              proc = subprocess.run(command, capture_output=True, text=True)
              self.assertEqual(proc.returncode, 0, proc.stderr)
              self.assertEqual(json.loads(proc.stdout)["comparison"]["status"], "compared")
              source = self.root / "demo/data.csv"
              before = source.read_bytes(), source.stat().st_mtime_ns
              proc = subprocess.run(command, capture_output=True, text=True)
              self.assertNotEqual(proc.returncode, 0)
              self.assertEqual(before, (source.read_bytes(), source.stat().st_mtime_ns))
      
      
      if __name__ == "__main__":
          unittest.main()
      
    • test_generated_code.sh 3.2 KB
      #!/usr/bin/env bash
      # Regression test for the generated-code quality gate (analyze-stats Phase 3.5).
      # Synthetic, PII-free fixtures reproduce reproducibility/integrity slop in both
      # Python and R (missing seed, hardcoded absolute path, hand-typed tabular data,
      # in-place source overwrite, debug leftover, unused import) and a clean script.
      # Absolute-path literals use a synthetic /Users/researcher/ that does not match
      # the repo PII blocklist (personal home dirs only).
      # Stdlib-only (python3).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/check_generated_code.py"
      BAD_PY="$HERE/fixtures/gen_bad.py"
      BAD_R="$HERE/fixtures/gen_bad.R"
      CLEAN="$HERE/fixtures/gen_clean.py"
      OUT="$(mktemp -t gencode_XXXX).json"
      trap 'rm -f "$OUT"' EXIT
      
      fail=0
      check() { local label="$1"; shift
          if "$@" >/dev/null 2>&1; then printf '  PASS  %s\n' "$label"
          else printf '  FAIL  %s\n' "$label"; fail=$((fail+1)); fi
      }
      has_verdict() { python3 -c "
      import json,sys
      d=json.load(open('$OUT'))
      assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found'
      "; }
      
      [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
      
      # (1) bad Python script -> exit 1 with all four Major verdicts + flags
      python3 "$SCRIPT" "$BAD_PY" --out "$OUT" --strict --quiet >/dev/null 2>&1
      check "exit 1 (bad .py)" test "$?" -eq 1
      check "MISSING_SEED detected" has_verdict MISSING_SEED
      check "HARDCODED_ABS_PATH detected" has_verdict HARDCODED_ABS_PATH
      check "HARDCODED_DATA_LITERAL detected" has_verdict HARDCODED_DATA_LITERAL
      check "INPLACE_SOURCE_OVERWRITE detected" has_verdict INPLACE_SOURCE_OVERWRITE
      check "UNUSED_IMPORT detected" has_verdict UNUSED_IMPORT
      check "DEBUG_LEFTOVER detected" has_verdict DEBUG_LEFTOVER
      
      # (2) bad R script -> exit 1 with R-side Major verdicts
      python3 "$SCRIPT" "$BAD_R" --out "$OUT" --strict --quiet >/dev/null 2>&1
      check "exit 1 (bad .R)" test "$?" -eq 1
      check "MISSING_SEED detected (R)" has_verdict MISSING_SEED
      check "HARDCODED_ABS_PATH detected (R)" has_verdict HARDCODED_ABS_PATH
      check "HARDCODED_DATA_LITERAL detected (R)" has_verdict HARDCODED_DATA_LITERAL
      check "INPLACE_SOURCE_OVERWRITE detected (R)" has_verdict INPLACE_SOURCE_OVERWRITE
      
      # (3) clean Python script -> exit 0
      python3 "$SCRIPT" "$CLEAN" --strict --quiet >/dev/null 2>&1
      check "exit 0 (clean .py)" test "$?" -eq 0
      
      # (4) --code-dir scans the fixtures directory (finds Major issues -> exit 1)
      python3 "$SCRIPT" --code-dir "$HERE/fixtures" --strict --quiet >/dev/null 2>&1
      check "exit 1 (--code-dir scan)" test "$?" -eq 1
      
      # (5) hex-color palette + data read -> NOT HARDCODED_DATA_LITERAL (WONG-palette
      #     false-positive regression); the script is otherwise clean -> exit 0
      PALETTE="$HERE/fixtures/gen_palette.py"
      python3 "$SCRIPT" "$PALETTE" --out "$OUT" --quiet >/dev/null 2>&1
      check "no HARDCODED_DATA_LITERAL on hex-color palette" python3 -c "
      import json
      d=json.load(open('$OUT'))
      assert not any(c['verdict']=='HARDCODED_DATA_LITERAL' for c in d['claims']), 'palette flagged as data literal'
      "
      python3 "$SCRIPT" "$PALETTE" --strict --quiet >/dev/null 2>&1
      check "exit 0 on clean palette script" test "$?" -eq 0
      
      echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
      exit "$fail"
      
    • test_separation.sh 6.7 KB
      #!/usr/bin/env bash
      # Regression test for skills/analyze-stats/scripts/check_separation.py.
      #
      # The positive fixture is the real shape: a pathognomonic imaging sign (100% specific, 100%
      # PPV) entered as a covariate. Its cross-tab against the outcome has an empty cell, so the
      # logistic MLE does not exist — but glm still returns, with OR ~ 0, p ~ 0.99, and an AUC that
      # would have been reported as a result. The gate must catch that from the DATA, before any
      # model is fitted.
      #
      # The negatives matter just as much: a balanced predictor and an overlapping continuous one
      # must stay silent, and an identifier column must be skipped rather than flagged.
      set -u
      
      REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
      V="$REPO_ROOT/skills/analyze-stats/scripts/check_separation.py"
      TMP="$(mktemp -d)"
      trap 'rm -rf "$TMP"' EXIT
      
      pass=0
      fail=0
      ck() {
        local label="$1" expected="$2" actual="$3"
        if [ "$expected" = "$actual" ]; then
          printf '  PASS  %-56s exit=%s\n' "$label" "$actual"
          pass=$((pass + 1))
        else
          printf '  FAIL  %-56s expected=%s actual=%s\n' "$label" "$expected" "$actual"
          fail=$((fail + 1))
        fi
      }
      
      # --- the pathognomonic sign: mismatch=1 occurs ONLY in idh_mutant=1 (specificity 100%) ------
      # sex is balanced; age overlaps; patient_id is an identifier.
      python3 - "$TMP" <<'PY'
      import csv, random
      from pathlib import Path
      rows = []
      # 12 sign-positive, all mutant  -> the empty cell: (mismatch=1, idh=0) has n=0
      for i in range(12):
          rows.append({"patient_id": f"P{i:03d}", "t2flair_mismatch": 1, "idh_mutant": 1,
                       "sex": i % 2, "age": 40 + (i % 20), "ki67": 10 + (i % 30)})
      # 20 sign-negative mutant, 25 sign-negative wildtype
      for i in range(12, 32):
          rows.append({"patient_id": f"P{i:03d}", "t2flair_mismatch": 0, "idh_mutant": 1,
                       "sex": i % 2, "age": 35 + (i % 25), "ki67": 5 + (i % 40)})
      for i in range(32, 57):
          rows.append({"patient_id": f"P{i:03d}", "t2flair_mismatch": 0, "idh_mutant": 0,
                       "sex": i % 2, "age": 45 + (i % 25), "ki67": 8 + (i % 35)})
      with (Path(sys.argv[1] if False else __import__("sys").argv[1]) / "sep.csv").open("w", newline="") as fh:
          w = csv.DictWriter(fh, fieldnames=list(rows[0]))
          w.writeheader(); w.writerows(rows)
      
      # quasi-separation: EVERY cell is non-empty, but one is tiny. rare_sign=1 occurs in 18
      # wildtype cases and only 2 mutant ones -> (rare_sign=1, mutant) = 2, below the floor of 5.
      # (An empty cell would be COMPLETE separation, which is a different verdict.)
      q = [dict(r) for r in rows]
      n_mut = n_wt = 0
      for r in q:
          if r["idh_mutant"] == 1 and n_mut < 2:
              r["rare_sign"] = 1; n_mut += 1
          elif r["idh_mutant"] == 0 and n_wt < 18:
              r["rare_sign"] = 1; n_wt += 1
          else:
              r["rare_sign"] = 0
      with (Path(__import__("sys").argv[1]) / "quasi.csv").open("w", newline="") as fh:
          w = csv.DictWriter(fh, fieldnames=list(q[0]))
          w.writeheader(); w.writerows(q)
      
      # continuous perfect separation: marker ranges do not overlap across the outcome
      c = []
      for i in range(30):
          c.append({"patient_id": f"C{i:03d}", "idh_mutant": 1, "marker": 10 + i * 0.5, "age": 40 + i % 20})
      for i in range(30):
          c.append({"patient_id": f"D{i:03d}", "idh_mutant": 0, "marker": 40 + i * 0.5, "age": 45 + i % 20})
      with (Path(__import__("sys").argv[1]) / "cont.csv").open("w", newline="") as fh:
          w = csv.DictWriter(fh, fieldnames=list(c[0]))
          w.writeheader(); w.writerows(c)
      PY
      
      # 1) the pathognomonic sign is caught, before any model is fitted
      python3 "$V" --data "$TMP/sep.csv" --outcome idh_mutant --predictor t2flair_mismatch --strict --quiet > /dev/null 2>&1
      ck "pathognomonic sign fires COMPLETE_SEPARATION (--strict)" 1 "$?"
      
      python3 "$V" --data "$TMP/sep.csv" --outcome idh_mutant --predictor t2flair_mismatch \
        --out "$TMP/s.json" --quiet > /dev/null 2>&1
      python3 - "$TMP/s.json" <<'PY'
      import json, sys
      r = json.load(open(sys.argv[1]))
      f = r["findings"]
      assert len(f) == 1, [x["verdict"] for x in f]
      assert f[0]["verdict"] == "COMPLETE_SEPARATION"
      assert f[0]["cell"]["n"] == 0
      assert r["model_safe"] is False
      # the message must name BOTH remedies — the choice is a design decision
      d = f[0]["detail"].lower()
      assert "firth" in d, "Firth remedy not named"
      assert "two-stage" in d, "two-stage remedy not named"
      PY
      ck "empty cell reported; both remedies named" 0 "$?"
      
      # 2) a balanced predictor must stay silent — a gate that fires on everything is noise
      python3 "$V" --data "$TMP/sep.csv" --outcome idh_mutant --predictor sex --strict --quiet > /dev/null 2>&1
      ck "balanced binary predictor does not fire" 0 "$?"
      
      # 3) an overlapping continuous predictor must stay silent
      python3 "$V" --data "$TMP/sep.csv" --outcome idh_mutant --predictor age --strict --quiet > /dev/null 2>&1
      ck "overlapping continuous predictor does not fire" 0 "$?"
      
      # 4) a continuous predictor whose ranges do NOT overlap is the same failure
      python3 "$V" --data "$TMP/cont.csv" --outcome idh_mutant --predictor marker --out "$TMP/c.json" --quiet > /dev/null 2>&1
      python3 - "$TMP/c.json" <<'PY'
      import json, sys
      f = json.load(open(sys.argv[1]))["findings"]
      assert len(f) == 1 and f[0]["verdict"] == "COMPLETE_SEPARATION", [x["verdict"] for x in f]
      PY
      ck "non-overlapping continuous predictor fires" 0 "$?"
      
      # 5) a sparse (non-zero) cell is quasi-separation, not complete
      python3 "$V" --data "$TMP/quasi.csv" --outcome idh_mutant --predictor rare_sign --out "$TMP/q.json" --quiet > /dev/null 2>&1
      python3 - "$TMP/q.json" <<'PY'
      import json, sys
      r = json.load(open(sys.argv[1]))
      v = {f["verdict"] for f in r["findings"]}
      assert "QUASI_SEPARATION" in v, v
      assert "COMPLETE_SEPARATION" not in v, "a sparse cell is not an empty one"
      PY
      ck "sparse cell is QUASI, not COMPLETE" 0 "$?"
      
      # 6) --auto screens every column, and an identifier is skipped rather than flagged
      python3 "$V" --data "$TMP/sep.csv" --outcome idh_mutant --auto --out "$TMP/a.json" --quiet > /dev/null 2>&1
      python3 - "$TMP/a.json" <<'PY'
      import json, sys
      r = json.load(open(sys.argv[1]))
      assert "t2flair_mismatch" in r["screened"]
      assert any(s["predictor"] == "patient_id" for s in r["skipped"]), "identifier should be skipped"
      assert not any(f["predictor"] == "patient_id" for f in r["findings"]), "identifier flagged as a predictor"
      assert any(f["predictor"] == "t2flair_mismatch" for f in r["findings"])
      PY
      ck "--auto screens all; identifier skipped, not flagged" 0 "$?"
      
      # 7) a non-binary outcome is a usage error, not a silent pass
      python3 "$V" --data "$TMP/sep.csv" --outcome age --predictor sex --quiet > /dev/null 2>&1
      ck "non-binary outcome fails loudly" 1 "$?"
      
      # 8) the JSON envelope names the detector (repo-wide artifact contract)
      python3 - "$TMP/s.json" <<'PY'
      import json, sys
      assert json.load(open(sys.argv[1]))["detector"] == "check_separation"
      PY
      ck "JSON envelope self-identifies" 0 "$?"
      
      echo "----"
      echo "test_separation: $pass passed, $fail failed"
      [ "$fail" -eq 0 ]
      
    • test_survival_template.sh 2.6 KB
      #!/usr/bin/env bash
      # Test the survival_analysis.py template hardening (A1):
      #   - median survival reported WITH a 95% CI (not a bare point estimate)
      #   - Cox events-per-variable (EPV) gate
      #   - cluster-robust (sandwich) SE for nested observation units
      # Static assertions always run (CI-safe, no heavy deps). A runtime smoke runs
      # only when lifelines is importable; otherwise it SKIPs (never fails CI).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      TPL="$HERE/../references/templates/survival_analysis.py"
      PASS=0
      FAIL=0
      ok()  { echo "  PASS: $1"; PASS=$((PASS+1)); }
      bad() { echo "  FAIL: $1"; FAIL=$((FAIL+1)); }
      
      # --- static: the template must compile + contain the hardened code paths ---
      python3 -m py_compile "$TPL" 2>/dev/null && ok "template compiles" || bad "template syntax error"
      
      grep -q "def median_with_ci" "$TPL" && ok "median_with_ci helper present" || bad "no median_with_ci helper"
      grep -q "median_survival_times" "$TPL" && ok "imports median CI util" || bad "median CI util not imported"
      # no bare median print left behind (the old `Median survival = {med:.1f}` form)
      grep -qE 'Median survival[^\n]*median_survival_time_' "$TPL" && bad "bare median print still present" || ok "no bare median print"
      grep -q "cluster_col" "$TPL" && ok "Cox cluster_col (robust SE) param" || bad "no cluster_col param"
      grep -q -- "--cluster" "$TPL" && ok "--cluster CLI arg" || bad "no --cluster CLI arg"
      grep -qiE "EPV =|EPV <" "$TPL" && ok "Cox EPV gate present" || bad "no Cox EPV gate"
      
      # --- runtime smoke (optional, lifelines required) ---
      if python3 -c "import lifelines" 2>/dev/null; then
        WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
        python3 - "$WORK" <<'PY'
      import csv, os, sys, random
      random.seed(0)
      work = sys.argv[1]
      rows = [("t", "e", "g", "age", "pid")]
      for i in range(120):
          rows.append((round(random.expovariate(1/12), 2), int(random.random() < 0.6),
                       "A" if i % 2 else "B", round(40 + random.gauss(0, 10), 1), i // 2))
      with open(os.path.join(work, "s.csv"), "w", newline="") as f:
          csv.writer(f).writerows(rows)
      PY
        OUT="$(python3 "$TPL" --input "$WORK/s.csv" --time t --event e --group g \
              --covariates age --cluster pid --output "$WORK/o" 2>&1)"
        echo "$OUT" | grep -q "95% CI" && ok "runtime: median prints 95% CI" || bad "runtime: median CI missing"
        echo "$OUT" | grep -q "EPV =" && ok "runtime: EPV line printed" || bad "runtime: EPV missing"
        echo "$OUT" | grep -q "cluster-sandwich" && ok "runtime: cluster SE applied" || bad "runtime: cluster SE missing"
      else
        echo "  SKIP: lifelines not installed (static checks only)"
      fi
      
      echo ""
      echo "test_survival_template: $PASS passed, $FAIL failed"
      [ "$FAIL" -eq 0 ]
      
  • SKILL.md 59 KB
    ---
    name: analyze-stats
    description: Statistical analysis for medical research papers. Generates reproducible Python/R code with publication-ready tables and figures. Supports diagnostic accuracy, inter-rater agreement, meta-analysis, survival analysis, survey data, group comparisons, regression, propensity score, and repeated measures.
    triggers: statistics, statistical analysis, analyze data, run stats, table 1, demographics table, ROC curve, agreement analysis, ICC, kappa, survival analysis, Kaplan-Meier, group comparison, logistic regression, linear regression, regression, propensity score, PSM, IPTW, SIPTW, overlap weighting, repeated measures, mixed model, GEE, longitudinal, survey weighted, KNHANES, NHANES, NHIS cohort, complex survey, wOR, weighted odds ratio, claims-based, ICD-10
    tools: Read, Write, Edit, Bash, Grep, Glob
    model: inherit
    ---
    
    # Statistical Analysis Skill
    
    You are assisting a medical researcher with statistical analyses for medical research papers.
    Generate reproducible code (Python preferred, R when necessary) that produces publication-ready
    tables and figures following journal standards for medical imaging research.
    
    ## Data Privacy Check
    
    Before reading any data file, check whether it might contain Protected Health Information (PHI):
    
    1. If `*_deidentified.*` files exist in the working directory, use those preferentially.
    2. If only raw CSV/Excel files exist (no `*_deidentified.*` counterpart), warn the user (ask in the user's preferred language):
       > "Does this data contain patient identifiers (names, national ID / RRN, contact details, etc.)?
       > If so, please de-identify it first with the `/deidentify` skill."
    3. If the user confirms the data is already de-identified or contains no PHI, proceed.
    4. **NEVER** display raw PHI values (names, phone numbers, RRN) in your output. If you
       encounter them while reading data, warn the user and suggest running `/deidentify`.
    
    ## Reference Files
    
    - **Templates**: `${CLAUDE_SKILL_DIR}/references/templates/` -- reusable analysis scripts
    - **Analysis guides**: `${CLAUDE_SKILL_DIR}/references/analysis_guides/` -- on-demand methodology references
    - **Table standards**: `${CLAUDE_SKILL_DIR}/references/table-standards/` -- journal-specific table formatting
      - `table-standards.md` -- universal rules, AMA rules, footnote system, mistakes checklist
      - `journal-profiles/` -- YAML profiles per journal (radiology, jama, nejm, lancet, eur_rad, ajr)
      - `table-types/` -- templates per table type (Table 1, diagnostic accuracy, regression, survival/Cox, agreement/reliability, meta-analysis, model comparison, incremental value, reader study (MRMC))
      - `tool-comparison.md` -- R/Python tool comparison and recommended pipelines
    - **Figure style**: `${CLAUDE_SKILL_DIR}/references/style/figure_style.mplstyle`
    - **Project data**: See CLAUDE.md for data locations under `2_Data/`
    
    Read relevant templates before generating analysis code. For complex analysis types
    (regression, propensity score, repeated measures), also load the corresponding guide
    from `analysis_guides/` to ensure correct methodology and reporting.
    
    ## Workflow
    
    ### Phase 1: Data Assessment
    
    1. **Read the data file** (CSV, Excel, TSV, or other tabular format).
    2. **Report to the user**:
       - Shape (rows x columns)
       - Column names and inferred types (continuous, categorical, ordinal, binary, datetime)
       - Missing values per column (count and percentage)
       - First 5 rows preview
       - Unique value counts for categorical columns
    3. **Identify the analysis unit**: patient, exam, lesion, image, rater, study, etc.
    
    ### Phase 2: Analysis Plan
    
    **Precondition (observational studies).** Before proposing an analysis plan for an observational design (cohort, case-control, cross-sectional, registry, or survey), confirm that a literature-grounded variable operationalization exists — a `variable_operationalization.md` from `/define-variables`, or an equivalent codebook-backed definition table. If none exists, **warn** the user and recommend running `/define-variables` first, so exposure / outcome / covariate definitions and cutoffs are citation-backed rather than invented ad hoc from the data dictionary (ad-hoc phenotype/cutoff definitions are a common reviewer-rejection trigger for observational work — see the dictionary-first discipline). This is a WARN, not a hard block: proceed on explicit user confirmation, recording that the operationalization artifact was not available. For stricter projects, treat the missing artifact as a hard stop until `/define-variables` has run. (This mirrors the same precondition already enforced in `/write-protocol` before drafting Methods.)
    
    Based on the data structure and research question, propose an analysis plan:
    
    1. **Auto-detect analysis type** from the table below, or accept user specification.
    2. **List specific tests** to be performed.
    3. **Identify primary and secondary endpoints**.
    4. **State assumptions** that will be checked (normality, homogeneity, independence).
    5. **Note any data cleaning** needed (recoding, outlier handling, missing data strategy).
    6. **Anchor the estimand to the research question.** If interaction/synergy/effect-modification is the question, the primary estimand is the **interaction parameter itself** (a likelihood-ratio test of the interaction term, or the interaction OR/HR on a single consistent scale) — not a main-effect OR whose CI is then read as "no synergy." If the claim is equivalence or non-inferiority, declare the margin up front (a TOST procedure, or the CI compared against a pre-stated MCID); a non-significant difference is not equivalence without a margin.
    
    7. **Screen every categorical/binary predictor for separation — before fitting anything.**
       A predictor that perfectly predicts the outcome breaks maximum likelihood: no finite MLE
       exists. The failure is silent — `glm` does not error, it returns an odds ratio near 0 (or
       enormous), *p* ≈ 0.99, and an AUC that then gets written into a table. This is routine in
       diagnostic imaging, because the good signs are the pathognomonic ones (T2-FLAIR mismatch,
       the string sign, a halo sign): 100% specificity means an empty cell by construction.
    
       ```bash
       python3 "${CLAUDE_SKILL_DIR}/scripts/check_separation.py" \
         --data cohort.csv --outcome idh_mutant --auto --strict
       ```
    
       `COMPLETE_SEPARATION` (an empty cell) and `QUASI_SEPARATION` (a cell below the sparsity
       floor) both halt the plan. The remedy is a **design** decision, not a numerical one:
       Firth's penalised likelihood keeps one model, while a **two-stage rule** — classify the
       sign-positive cases directly, model only the sign-negative remainder — is usually the
       clinically meaningful choice for a pathognomonic sign, because a sign-positive patient is
       already diagnosed and the real question is what to do with everyone else. Decide this in
       the plan; do not discover it in the output.
    
    Present the plan and **wait for user approval** before executing.
    
    | Type | When to use | Python packages | R packages | Primary output |
    |------|-------------|-----------------|------------|----------------|
    | Table 1 (Demographics) | Baseline characteristics | pandas, scipy | tableone | Demographics table |
    | Diagnostic Accuracy | Sensitivity/specificity/AUC | sklearn, scipy | pROC | ROC curve, performance table |
    | Inter-rater Agreement | Multiple raters rating same items | krippendorff, pingouin | irr, psych | ICC/Kappa table |
    | Meta-analysis | Pooling effect sizes across studies | -- | meta, metafor | Forest + funnel plots |
    | DTA Meta-analysis | Pooling diagnostic accuracy across studies | -- | meta, metafor, mada | SROC + paired forest plots |
    | Survey/Likert | Ordinal rating scales | pingouin, scipy | psych | Descriptive + reliability |
    | Survival | Time-to-event outcomes | lifelines | survival | KM curves, Cox table |
    | Group Comparison | Comparing 2+ groups | scipy, pingouin | -- | Test results + effect sizes |
    | Correlation | Association between variables | scipy, pingouin | -- | Scatter + correlation matrix |
    | Logistic Regression | Binary outcome + predictors | statsmodels, sklearn | -- | OR table, C-statistic, forest plot |
    | Linear Regression | Continuous outcome + predictors | statsmodels | -- | Coefficient table, R², diagnostic plots |
    | Propensity Score | Observational treatment comparison | sklearn, statsmodels | MatchIt, WeightIt, cobalt | Balance table, Love plot, weighted analysis |
    | Survey-Weighted | Complex survey data (KNHANES, NHANES, KCHS) | statsmodels | survey, tableone, gWQS | Weighted Table 1, wOR table, subgroup results |
    | Repeated Measures | Longitudinal / multi-timepoint data | pingouin, statsmodels | lme4, nlme, geepack | Spaghetti plot, LMM/GEE/RM ANOVA results |
    
    For **Logistic Regression**, **Linear Regression**, **Propensity Score**, **Survey-Weighted**, and **Repeated Measures**:
    load the corresponding guide from `${CLAUDE_SKILL_DIR}/references/analysis_guides/` before generating code.
    For **Survey-Weighted** analysis, also load `survey_weighted.md`. For NHIS claims-based studies, load `nhis_icd10_mapping.md`.
    For test selection guidance, load `${CLAUDE_SKILL_DIR}/references/analysis_guides/test_selection.md`.
    
    ### Phase 3: Execute
    
    Generate and run a Python (preferred) or R script following these rules:
    
    #### Script Structure
    
    Every script MUST start with a reproducibility header:
    
    ```python
    """
    Analysis: {description}
    Date: {YYYY-MM-DD}
    Random seed: 42
    Python: {version}
    Key packages: {package==version, ...}
    """
    import numpy as np
    import pandas as pd
    np.random.seed(42)
    ```
    
    #### Execution Rules
    
    1. **Random seed**: Always `np.random.seed(42)` or `set.seed(42)`.
    2. **Figure style**: Always load the matplotlib style file:
       ```python
       import matplotlib.pyplot as plt
       style_path = os.path.join(os.environ.get('CLAUDE_SKILL_DIR', '.'), 'references/style/figure_style.mplstyle')
       if os.path.exists(style_path):
           plt.style.use(style_path)
       ```
    3. **Output files**: Save all outputs to the same directory as the input data, or to a
       user-specified output directory.
    4. **Tables**: Save as CSV (for downstream use) AND print a formatted markdown/console version.
    5. **Figures**: Save as both PDF (vector) and PNG (300 DPI).
    6. **Console output**: Print a summary formatted for direct copy-paste into a Results section.
    
    #### Assumption Checking
    
    Before running parametric tests, always check and report:
    
    - **Normality**: Shapiro-Wilk test (n < 50) or Kolmogorov-Smirnov (n >= 50), plus visual QQ plot
    - **Homogeneity of variance**: Levene's test
    - **If assumptions violated**: Use non-parametric alternatives and report why
    
    #### Multiple Comparisons
    
    - If running 3+ tests on the same dataset, apply Bonferroni or Benjamini-Hochberg correction.
    - Always report both uncorrected and corrected p-values.
    - State the correction method used.
    
    #### Stratified & Ordinal-Trend Reporting
    
    - **Strata disjointness gate (before any ordinal trend test).** Before running a Cochran-Armitage trend test (or any analysis that treats tiers as an ordered partition), assert the strata are mutually exclusive and exhaustive: `sum(n per stratum) == unique N` and `sum(events per stratum) == total events`. A trend test on overlapping or non-exhaustive strata is invalid. Emit the per-stratum N/event table and the reconciliation in the output (this is the analysis-side mirror of `/self-review` `check_cohort_arithmetic.py` `PARTITION_OVERLAP`).
    - **Secondary stratum-HR validation checklist.** Every secondary stratum hazard/odds ratio must be reported with (a) its **reference contrast** (which category is the referent), (b) the **event count** in each stratum, and (c) a **sparse-stratum caveat** when any stratum has a low event count (a rule of thumb: < 10 events makes the estimate unstable). A bare "HR 1.55 in lean participants" without the referent and the events is uninterpretable.
    - **Proportion CI lower-bound clamp.** Clamp every proportion confidence-interval lower bound to `max(0, lower)`; a zero-event Wilson/score interval can emit a negative or absurd tiny-exponent lower bound (e.g., `3.47e-16`) that is a display artifact, not a real bound. Report `0` (or `0.0%`) instead, and prefer an exact (Clopper-Pearson) interval for zero/near-zero cells.
    
    #### Output Manifest
    
    After all analyses complete, save `_analysis_outputs.md` in the output directory.
    Use the [output format and bound binary workflow](references/analysis_run_workflow.md)
    when producing the analysis outputs.
    
    This manifest enables downstream skills (`/make-figures`, `/write-paper`) to auto-discover analysis outputs without user intervention.
    
    For **prespecified binary predictions on independent units**, use the bundled
    `scripts/run_analysis.py run` workflow described in
    [`references/analysis_run_workflow.md`](references/analysis_run_workflow.md).
    It executes the existing diagnostic template and embeds data/configuration/code/
    output hashes, exact counts, metric-specific denominators and the reproduction
    command in this same manifest. `audit` checks recorded versions without rewriting
    them; `compare` separates declared context and recorded numeric equality from byte
    drift. It does not select thresholds or establish study validity, privacy clearance
    or reuse rights. The original synthetic example runs with
    `python3 ${CLAUDE_SKILL_DIR}/scripts/demo_analysis_run.py --out demo-project`.
    
    ### Phase 3.5: Generated-Code Quality Gate
    
    Before reporting any script as final, lint every emitted `.py`/`.R` file for the
    reproducibility-hygiene "slop" that AI-generated analysis code recurrently carries:
    
    ```bash
    python3 ${CLAUDE_SKILL_DIR}/scripts/check_generated_code.py {script.py} --strict
    # or scan a whole output directory:
    python3 ${CLAUDE_SKILL_DIR}/scripts/check_generated_code.py --code-dir {analysis_dir} --strict
    ```
    
    **Major findings (fix before reporting the script):**
    - `MISSING_SEED` — randomness used (sampling, bootstrap, train/test split, rng) with no
      `np.random.seed` / `set.seed` / `random_state=` / `default_rng`. Non-reproducible.
    - `HARDCODED_DATA_LITERAL` — a hand-typed, table-shaped numeric literal instead of
      `read_csv()`/`read.csv()` + subset. This is the data-integrity rule "never hand-type CSV
      data into scripts."
    - `HARDCODED_ABS_PATH` — an absolute path literal (`/Users/`, `/home/`, `C:\`, `~/Documents`).
      Non-portable and a PII risk.
    - `INPLACE_SOURCE_OVERWRITE` — writing to the same path read as input; this overwrites raw
      data. Write derived outputs to a new path ("never modify raw data").
    
    **Flags (fix when tidying):** `DEBUG_LEFTOVER` (a `breakpoint()` / `browser()` / debug print
    / TODO marker left in) and `UNUSED_IMPORT` (a dead Python dependency).
    
    The gate is conservative on the Major checks — it fires `HARDCODED_DATA_LITERAL` only on
    genuinely table-shaped literals and `MISSING_SEED` only on a real randomness call — so it
    stays quiet on legitimate analysis code. It is the analysis-side mirror of the
    data-integrity and reproducibility checks `/self-review` is built to catch downstream.
    
    ### Phase 4: Report
    
    After execution, generate manuscript-ready text:
    
    1. **Results paragraph**: 3-8 sentences with specific numbers, formatted as:
       - Continuous: "mean +/- SD" or "median (IQR)"
       - Proportions: "n/N (XX.X%)"
       - Test results: "statistic = X.XX, p = 0.XXX"
       - Effect sizes: "Cohen's d = X.XX (95% CI: X.XX-X.XX)"
       - AUC: "AUC = 0.XXX (95% CI: 0.XXX-0.XXX)"
    2. **Table/figure captions**: Draft captions referencing table/figure numbers.
    3. **Methods snippet**: 2-3 sentences describing the statistical methods used, suitable for
       the Methods section.
    
    ## Statistical Reporting Rules (Always Enforced)
    
    These rules apply to ALL analyses without exception:
    
    1. **Exact p-values**: Report exact values (e.g., p = 0.034), not inequalities.
       Exception: report as p < 0.001 when the value is below 0.001.
    2. **Confidence intervals**: Always report 95% CIs for primary endpoints.
    3. **Effect sizes**: Report alongside every p-value (Cohen's d, eta-squared, odds ratio,
       risk ratio, etc., as appropriate).
    4. **Parametric vs non-parametric**: Choose based on assumption checks, not convenience.
       Report the assumption test results.
    5. **Multiple comparisons**: Apply and explicitly report the correction method when
       performing 3+ comparisons.
    6. **Sample size reporting**: Always state n for each group/analysis.
    7. **Missing data**: Report how many cases were excluded and why.
    8. **Decimal places**: p-values to 3 decimals, proportions to 1 decimal, means/SDs to
       appropriate precision for the measurement.
    9. **Design/power statistics are code outputs, never hand-computed.** Any minimum detectable
       effect (MDE), a-priori or post-hoc power, or required sample size that will appear in the
       manuscript MUST be emitted by this committed script — printed with its method and inputs
       (n per arm, alpha, power, allocation ratio, one/two-sided) — not computed in a side tool
       (G*Power, an online calculator) and pasted in. Use one method family consistently
       (e.g. the exact noncentral-t via `statsmodels` `TTestIndPower` or `scipy`'s `nct`); do not
       mix a normal approximation for some values with exact-t for others. A value that exists only
       in the manuscript with no script that reproduces it is the failure mode `/self-review`
       Phase 2.5a-2 is built to catch.
    10. **Estimand & CI output contract.** Every primary point estimate — including quantile
        estimands (T25, median time-to-event), pooled proportions, and subdistribution HRs, not
        just ORs/HRs/AUCs — MUST be emitted together with its 95% CI. In the output CSV, carry the
        interval as explicit columns (`estimate, ci_lower, ci_upper`) or as a single text column in
        `est (lo–hi)` form; never emit a point estimate with no interval in an adjacent column.
        Round ORs/HRs/sHRs to 2 decimals and AUC/C-statistic to 3. This is the output side of the
        `/self-review` §C assertion that "all primary metrics have 95% CIs."
    
    ### Effect-Size Real-World Translation
    
    Whenever a primary result is a correlation, a standardized coefficient, a regression slope, an
    OR/HR/RR, or a Cohen's d, also report it as a **plain-language unit shift** a non-statistician can
    act on. The coefficient answers "is there an association"; the translation answers "how much, in
    units I use". This complements rule 3 above (report effect sizes) — it does not replace it.
    
    **When to apply**
    - Any continuous-exposure to continuous-outcome association reported as Spearman's rho, Pearson's r,
      or a standardized slope.
    - Any OR/HR/RR where the audience needs an absolute-risk feel.
    - Reader / expert-elicitation studies, clinical-utility framing, abstracts, and figure captions.
    
    **Procedure**
    1. **Pick an anchored contrast on the exposure**, not a 1-unit step. Default: 25th to 75th percentile
       (IQR). State both endpoints in native units.
    2. **Translate to the outcome scale.**
       - For a rank/standardized association (Spearman's rho or a per-SD slope) under an approximately
         monotonic-linear assumption:
         `delta_outcome ~= ((x_p75 - x_p25) / SD_x) * |rho| * SD_outcome`.
         Report as: "going from {x_p25} to {x_p75} {units} is associated with about {delta_outcome}
         {outcome units} on average."
       - For a regression slope b: `delta_outcome = b * (x_p75 - x_p25)` (cleaner; no monotonicity caveat).
       - State the assumption explicitly; the IQR translation is a more defensible verbal guide than an
         SD-scaled one.
    3. **For OR/HR/RR**, accompany the relative measure with an absolute one at a stated baseline risk:
       the absolute risk difference, and NNT = 1 / ARR (or NNH = 1 / ARI). Always state the baseline risk used.
    4. **Bound the claim**: report the contrast, the assumption, and a CI on the coefficient; do not imply
       causation from a crude or unadjusted estimate.
    
    **Worked example (synthetic)**
    rho = 0.39 between a fasting marker (IQR 0.6 to 3.5 units, SD 3.05) and an index (SD 2.13):
    `((3.5 - 0.6) / 3.05) * 0.39 * 2.13 ~= 0.8` -> "Going from the 25th to the 75th percentile of the
    marker is associated with about 0.8 index units higher on average (monotonic-linear approximation;
    crude, unadjusted)."
    
    **Output contract (clinical-utility is a default, not an optional add-on).** Report every
    primary effect in units a clinician acts on, by default — do not leave these as prose to be
    added later:
    - **OR/HR/RR primary outcomes** → report the relative measure **and** the absolute risk at
      a stated baseline + absolute risk difference + **NNT** (or NNH = 1/ARI), baseline risk
      explicit. A relative-only headline is incomplete.
    - **Continuous outcomes** → add the IQR/clinically-anchored "Real-world translation" line
      beneath the effect size.
    - **Prediction / classification (incl. medical-AI) models** → a **decision-curve /
      net-benefit** pass at the relevant threshold is standard output, not just AUC +
      calibration. An incremental claim reports added **net benefit / NRI / IDI over the
      established clinical model**, not the new model's AUC alone. See
      `references/table-standards/table-types/incremental_value.md` and the `make-figures`
      `decision_curve` exemplar (and `render_core_figures.py` for the rendered curve).
    
    ## Error Handling
    
    - If a script fails to execute, report the error in one line, diagnose the likely cause
      (missing package, data format mismatch, wrong column name), and present a fix.
    - Do NOT retry the same script more than once without modifying it or asking the user.
    - If an R package is unavailable, suggest `install.packages()` and wait for user confirmation.
    - For prediction models: always include calibration assessment (Brier score, calibration plot,
      or calibration slope/intercept) alongside discrimination metrics. AUC alone is insufficient.
    
    ## Output Conventions
    
    ### Tables
    
    **Before generating any publication table**, load the journal profile and table type template:
    1. Load `${CLAUDE_SKILL_DIR}/references/table-standards/journal-profiles/{journal}.yaml` if a target journal is known
    2. Load `${CLAUDE_SKILL_DIR}/references/table-standards/table-types/{type}.md` for the relevant table type
    3. If no journal specified, default to AMA style (Radiology profile)
    
    **Output formats** (always generate all three):
    - CSV file (for downstream use and archival)
    - Console markdown rendering (for user review)
    - R gtsummary code (for publication-quality Word/LaTeX export)
    
    **Universal rules** (enforced regardless of journal):
    - No vertical lines — horizontal rules only (top, below header, bottom)
    - Binary variables: show only one level (e.g., Male only, not Male + Female)
    - Units in column headers, not repeated in cells
    - Consistent decimal places within each column
    - All abbreviations defined in footnotes, self-contained per table
    - Exact P values always (never "NS" or "significant")
    - Name the statistical test in footnote or general note
    - Variability measure always stated: mean (SD) or median (IQR)
    
    **Journal-specific parameters** (from loaded YAML profile):
    - Footnote markers: letters (AMA) vs symbols (NEJM/Lancet)
    - P value format: case, leading zero, italic
    - CI separator: comma (Radiology) vs "to" (JAMA/NEJM/Lancet)
    - Title format: period (AMA) vs colon (Lancet)
    - Abbreviation order: appearance (Radiology) vs alphabetical (JAMA)
    
    **Footnote placement order** (universal):
    1. General note (no marker) — e.g., "Data are mean (SD) unless noted"
    2. Abbreviations — in order per journal convention
    3. Specific notes (superscript markers) — per-cell explanations
    4. Probability notes — significance thresholds (if applicable)
    
    **gtsummary pipeline** (recommended for R table generation):
    ```r
    theme_gtsummary_journal("{journal}")  # "jama", "lancet", "nejm"
    theme_gtsummary_compact()
    # ... build table ...
    tbl %>% as_flex_table() %>% flextable::save_as_docx(path = "table.docx")
    ```
    
    **Validation checklist** (run before finalizing any table):
    - [ ] Binary variables show only one level
    - [ ] Units in headers, not cells
    - [ ] Consistent decimal places per column
    - [ ] Statistical test named (footnote or general note)
    - [ ] Effect sizes per clinically meaningful unit (per 10 years, not per 1 year)
    - [ ] Reference category stated for categorical predictors
    - [ ] No "NS" — exact P values only
    - [ ] Abbreviations defined in footnotes
    
    ### Figures
    
    - Format: PDF (vector, for journal) + PNG (300 DPI, for review)
    - Style: Use `figure_style.mplstyle` for consistent appearance
    - Font: Arial, 8-10pt
    - Colors: Colorblind-safe palette
    - Size: 3.5 inches (single column) or 7.0 inches (double column) width
    - Always include axis labels with units
    
    ### Console Output
    
    - Formatted for direct copy-paste into the Results section of a manuscript
    - Include all numbers that would appear in the text
    - Use the reporting format conventions above
    
    ## Analysis-Specific Guidelines
    
    ### Table 1 (Demographics)
    
    - Template: `references/templates/table1_demographics.py`
    - Table type guide: `references/table-standards/table-types/table1_demographics.md`
    - Continuous variables: mean +/- SD if normal, median (IQR) if skewed
    - Categorical variables: n (%)
    - Binary variables: show only one level (e.g., Male n (%), not both Male and Female)
    - Compare groups: t-test/Mann-Whitney for continuous, chi-square/Fisher for categorical
    - Report standardized mean differences (SMD) if requested (preferred over P for PS-matched studies)
    - RCTs: P values in Table 1 are usually unnecessary per CONSORT
    - gtsummary `tbl_summary()` with journal theme for R pipeline
    
    ### Diagnostic Accuracy
    
    - **Methodology guide**: `references/analysis_guides/diagnostic_accuracy.md` (**load before generating code** — every metric with a CI on a stated analysis unit; the confidence-weighted trap [unweighted-baseline AUC + monotonic-encoding check, produce-side of probe D9]; paired DeLong vs MRMC for reader-generalising claims; per-stratum admissibility [D10]; one-scale-per-comparison [D11])
    - Template: `references/templates/diagnostic_accuracy.py`
    - Always report: sensitivity, specificity, PPV, NPV, accuracy, AUC
    - CIs: Wilson score for proportions, DeLong for AUC
    - ROC curve: include diagonal reference line, AUC in legend
    - If comparing models: DeLong test for AUC comparison
    - Youden's index for optimal threshold when applicable
    - Include calibration assessment (Brier score, calibration plot) for prediction models
    - **NRI/IDI**: When comparing two models (e.g., base model vs model + AI score), report:
      - Category-based NRI (with clinically defined risk categories)
      - Continuous NRI (note: tends to be inflated — report alongside category-based)
      - IDI (Integrated Discrimination Improvement)
      - Bootstrap 95% CIs (1000+ iterations)
      - These supplement, not replace, DeLong AUC comparison
    - Table type guide (added value beyond a baseline): `references/table-standards/table-types/incremental_value.md` (paired ΔAUC + DeLong CI, continuous NRI with event/non-event split, IDI, net benefit at a prespecified threshold, same-patient/calibrated-first discipline). Pairs the decision-curve exemplar `make-figures` `references/exemplar_plots/decision_curve.md`.
    - Reader study (MRMC): `references/table-standards/table-types/reader_study.md` (per-reader + reader-averaged AUC with an Obuchowski–Rockette/DBM reader+case CI, per-patient vs per-lesion unit, superiority vs non-inferiority margin). Use an MRMC method (not a fixed-reader DeLong CI) for a claim that generalises to readers. Pairs `make-figures` `references/exemplar_plots/mrmc_roc.md`.
    
    ### Inter-rater Agreement
    
    - **Methodology guide**: `references/analysis_guides/agreement_reliability.md` (**load before generating code** — the pseudoreplication trap for clustered/repeated measurements + the pseudoreplication-safe per-subject / mixed-effects code, ICC model/type selection, agreement-vs-reliability distinction; pairs with self-review probe O18)
    - Table type guide: `references/table-standards/table-types/agreement.md` (ICC with model/type + CI, weighted κ for ordinal, Bland–Altman bias + LoA, reliability-vs-agreement distinction, common errors)
    - Template: `references/templates/agreement_analysis.py`
    - 2 raters + categorical: Cohen's kappa
    - 2+ raters + categorical: Fleiss' kappa (or Krippendorff's alpha)
    - Continuous: ICC (specify model: one-way, two-way random/mixed; type: single/average)
    - Always report interpretation labels (Landis & Koch or Cicchetti)
    - Bland-Altman plot for continuous paired measurements
    - Bootstrap CIs (1000 iterations, seed=42)
    
    ### Meta-analysis
    
    - Prefer R (meta/metafor packages) for meta-analysis
    - **Comparative**: `metabin()` for binary outcomes (OR/RR), `metagen()` for continuous
      - Use `method = "Inverse"`, `method.tau = "DL"`, `method.random.ci = "HK"`
      - Avoid deprecated args: `comb.fixed` → `common`, `hakn` → `method.random.ci`
    - **Single-arm pooled proportion**: `metaprop()` with `sm = "PLOGIT"`, `method.ci = "CP"`
      - **Small-study test branch**: do **not** use Egger's regression for a single-arm proportion meta-analysis — funnel-asymmetry tests assume an effect-size-vs-SE relationship that does not hold for raw proportions. If a small-study assessment is needed, use Peters' test or an arcsine-based variant, and only when `k >= 10` (note underpowered otherwise)
      - **Standard output**: report `tau-squared` on the logit scale and a **95% prediction interval** (`metaprop(..., prediction = TRUE)`) in addition to the pooled estimate; the PI conveys where a future study's proportion is expected to fall under the random-effects model
    - **Nested observation units**: if the proportion's unit is nested within study (e.g., per-lesion within study, per-image within patient), do **not** report a naive Wilson/binomial CI that ignores clustering — use a cluster-bootstrap or a GLMM with a random intercept per study so the CI reflects the design
    - Heterogeneity: I-squared, Q test, tau-squared, and a 95% prediction interval for the random-effects pooled estimate
    - Forest plot: individual studies + pooled estimate
    - Funnel plot + Egger's test for publication bias (comparative effect sizes only; note: underpowered k<10)
    - Sensitivity analysis: leave-one-out (`metainf()`)
    - Subgroup: `update(res, subgroup = variable)`
    
    ### DTA Meta-Analysis
    
    - Template: `references/templates/dta_meta_analysis.R`
    - Prefer R (`mada`, `meta`, `metafor` packages) for DTA meta-analysis
    - **Bivariate model** (Reitsma): `mada::reitsma()` — recommended over separate pooling of Se/Sp
      - Accounts for correlation between sensitivity and specificity
      - Produces SROC curve with confidence + prediction regions
    - **Key outputs**: Pooled Se/Sp (95% CI), positive/negative LR, DOR, SROC AUC
    - **Threshold effect**: Spearman correlation between logit(Se) and logit(FPR)
      - If significant: interpret single pooled Se/Sp with caution, emphasize SROC curve
    - **Forest plots**: Paired (sensitivity + specificity side by side)
    - **Publication bias**: Deeks' funnel plot asymmetry test (NOT standard funnel plot)
      - Standard funnel plots are inappropriate for DTA studies
      - Note: underpowered for k < 10
    - **Dual approach** (comparative + single-arm):
      - Primary: `metabin()` for comparative studies (OR/RR)
      - Secondary: `metaprop()` with `sm = "PLOGIT"` for single-arm pooled proportion
      - Use `method = "Inverse"`, `method.tau = "DL"`, `method.random.ci = "HK"`
    - **Small studies (k < 10)**: bivariate model may not converge; consider narrative synthesis
    - **Alternative**: If `mada` unavailable, use `metafor::rma.mv()` with bivariate structure
    
    ### Network Meta-Analysis
    
    - **Guide**: Load `analysis_guides/network_meta_analysis.md` before generating code
    - For ≥3 interventions via combined direct + indirect evidence (incl. component NMA); pairwise machinery (search/screening/random-effects model) via the Meta-analysis section above
    - **Assess transitivity before pooling**: compare effect-modifier distributions across comparisons (box plots / table) and/or network meta-regression — it is a clinical judgment, not a test
    - **Test consistency** globally (design-by-treatment) AND locally (node-split / back-calculation); a **star network (no closed loops) cannot be checked** — state it; investigate the source of any inconsistency (often one trial)
    - R `netmeta` (frequentist: `netsplit`, `decomp.design`, `netheat`, `netrank` P-scores, comparison-adjusted `funnel`) or Bayesian `gemtc` / `multinma` / `BUGSnet` (node-split, SUCRA, DIC)
    - Present a **network plot** (node ∝ sample size, edge ∝ #trials); report global **τ²**; **ranking (SUCRA/P-score) is not a superiority test** — report it with the league table, intervals, and certainty
    - Certainty **per estimate** via **CINeMA / GRADE-NMA** (downgrade indirect-only); component NMA assumes **additivity** (state/check it). Report against **PRISMA-NMA**; risk of bias via **RoB-NMA**. Review-side probes: NM1–NM8 in `network_meta_analysis.md`
    
    ### Health Economic Evaluation
    
    - **Guide**: Load `analysis_guides/health_economic_evaluation.md` before generating code
    - For cost-effectiveness (CEA), cost-utility (CUA, QALY), cost-benefit (CBA), cost-minimisation, or budget-impact analyses; trial-based or decision-model-based (decision tree, **Markov/state-transition**, discrete-event simulation)
    - Compute **incremental cost ΔC, incremental effect ΔE, and the ICER = ΔC/ΔE**; with ≥3 options remove **dominated / extended-dominated** strategies before sequential ICERs; prefer **net benefit (INMB = λΔE − ΔC)** for regression/probabilistic summaries
    - State and justify the **perspective, time horizon (lifetime for chronic disease), discount rate (both costs and outcomes), currency + price year**; QALYs from a named preference-based instrument + value set
    - **Uncertainty is the analytic core**: one-way / **tornado** for drivers, **probabilistic sensitivity analysis (PSA)** with justified parameter distributions (beta for probabilities/utilities, gamma/log-normal for costs) → **cost-effectiveness plane + CEAC**; scenario analyses for structural choices
    - R `heemod` / `dampack` / `hesim` / `BCEA` (state-transition + PSA + CEAC + EVPI), `flexsurv` for survival extrapolation. Report against **CHEERS 2022**; make the "cost-effective" conclusion conditional on a stated willingness-to-pay threshold. Review-side probes: HE1–HE8 in `health_economic_evaluation.md`
    
    ### Survey/Likert
    
    - Descriptive: median, IQR, frequency distribution per item
    - Internal consistency: Cronbach's alpha with item-total correlations
    - **Reverse-coding guard (run before reliability)**: a negatively-worded scale item must be recoded `(min+max) - x` before computing the scale total or Cronbach's alpha. An un-recoded reverse item produces a *negative* item-rest correlation and a negative alpha — which is a coding bug, **not** evidence of a multidimensional construct (do not defend it as such; you lose a review round). `likert_summary.py` prints the per-item item-rest correlations, flags negative ones as reverse-code suspects, warns loudly on a negative alpha, and accepts `--reverse-items E3 ...` to apply the recode before scoring. To screen at cleaning time, run `/clean-data` `scripts/check_reverse_coding.py`. See the global rule `survey-scale-reliability.md`.
    - If comparing groups: Mann-Whitney or Kruskal-Wallis (ordinal data)
    - Visualization: diverging stacked bar chart
    
    ### Survival Analysis
    
    - **Methodology guide**: `references/analysis_guides/survival.md` (**load before generating code** — competing risks first [naive 1−KM overestimates → produce the Aalen–Johansen/Fine–Gray CIF; cause-specific vs subdistribution for which question, produce-side of probe S3]; PH check → RMST when violated; reverse-KM follow-up + C-index variant [S6]; estimand provenance [S8])
    - Table type guide: `references/table-standards/table-types/survival_results.md` (Cox results table: events/person-time, reverse-KM median follow-up, univariable + adjusted HR with CI, PH-assumption footnote, EPV/sparse-stratum and RMST-when-PH-violated rules)
    - Kaplan-Meier curves with number-at-risk table
    - Log-rank test for group comparison
    - Cox proportional hazards: report HR (95% CI)
    - **Events-per-variable (EPV) gate**: check `events / n_covariates >= 10` before fitting Cox (mirror of the logistic EPV rule). Warn if violated and fall back to a Firth/penalized Cox or profile-likelihood CIs; do not report Wald CIs from a sparse-event model as if stable
    - **Nested observation units (cluster-robust CI)**: when a subject contributes more than one analysed unit (multiple lesions, both eyes, repeated episodes), pass a subject id so the HR CIs use a robust cluster-sandwich variance (`coxph(..., cluster = id)` / `robust = TRUE` in R, `cluster_col=` in lifelines, e.g. `survival_analysis.py --cluster <id>`). Treating correlated rows as independent understates the standard errors and narrows the CI artificially
    - Check proportional hazards assumption (Schoenfeld residuals)
    - **PH violation → do not report a single time-averaged HR.** If the Schoenfeld global test is significant (or a covariate's residual trends with time), a single Cox HR averages a changing effect and is misleading. Report a piecewise / time-stratified HR (split follow-up at a clinically sensible cut, or `tt()` time-transform), or switch to RMST difference at a fixed horizon, and state the violation explicitly
    - **Horizon vs follow-up.** Do not read a KM/CIF estimate at a horizon beyond the data: if a reported time point (e.g., a 15-year cumulative incidence) exceeds the reverse-KM median follow-up, either restrict the horizon to where the risk set is non-trivial or report the number-at-risk at that horizon so the reader can judge the extrapolation
    - Report median survival with 95% CI
    - **Warranty period / quantile estimands (T25 etc.)**: Time to a fixed cumulative incidence. Use `quantile()` from the KM/`survfit` object and **always emit the 95% CI** (the lower/upper from `quantile(km, conf.int=TRUE)`, or a log-transformed / bootstrap CI) alongside the events/n that define it. A quantile point estimate reported without its CI is incomplete. If the event rate is below the target quantile, report "not reached" and consider Weibull parametric extrapolation (also with an interval)
    
    ### Interval-Censored Survival
    
    When exact event times are unknown (e.g., health screening cohorts where status changes are detected at periodic visits), standard KM underestimates time-to-event. Use interval-censored methods:
    
    - **R packages**: `icenReg` (parametric/semi-parametric IC regression), `interval` (NPMLE/Turnbull), `survival` (Surv type "interval2")
    - **Turnbull estimator**: Non-parametric MLE for interval-censored data — analogous to KM but accounts for the interval between last negative and first positive observation
    - **Parametric IC models**: Weibull or log-logistic via `icenReg::ic_par()`. Report shape/scale parameters and compare AIC across distributions
    - **Mid-point imputation**: Simple approximation — event time = midpoint of (last negative, first positive). Acceptable as sensitivity analysis but NOT as primary method
    - **When to use**: Serial measurement cohorts (e.g., health screening databases), cancer screening intervals, repeated biomarker assessments
    - **Auto-trigger**: if the event date is defined by a periodic visit / scheduled re-examination (the event is detected *at* a visit, not observed exactly), interval-censoring is not optional — make an IC model the **primary** analysis, or at minimum a mandatory pre-specified sensitivity analysis, and do not present a right-censored Cox `coxph()` on visit-dated events as if the times were exact
    - **Multistate / transition models**: for repeated transitions (e.g., `msm`), account for subject-level clustering with a subject random effect or a sandwich (robust) variance, and check the time-homogeneity assumption (constant transition intensities) before trusting a single rate
    - **Reporting**: State the interval-censored nature of the data explicitly in Methods. Report both standard KM (for comparability with prior literature) and IC estimates (as primary or sensitivity)
    
    ### Competing Risks
    
    When death or other events preclude the outcome of interest, standard KM overestimates cumulative incidence (treats competing events as censored). Use competing risk methods:
    
    - **R packages**: `cmprsk` (Fine-Gray), `tidycmprsk` (tidy interface), `survival` (cause-specific Cox)
    - **Cumulative incidence function (CIF)**: `cmprsk::cuminc()` — replaces 1-KM for each event type. Gray's test for group comparison
    - **Fine-Gray subdistribution hazard**: `cmprsk::crr()` or `tidycmprsk::crr()` — reports subdistribution HR (sHR) with 95% CI. Interpretable as effect on CIF directly. **Check the subdistribution-PH assumption** the same way you check it for Cox (a time-interaction term on the subdistribution scale, or inspection of scaled-residual analogues); a constant sHR is an assumption, not a given. Report the cause-specific HR alongside it so the etiologic and prognostic readings are both visible
    - **Cause-specific Cox**: Standard Cox censoring competing events — reports cause-specific HR. Better for etiology; Fine-Gray better for prognosis/prediction
    - **When to use**: Mortality studies with multiple causes of death, cardiovascular events when non-CV death is frequent, any outcome where competing events are common (>5% of total events)
    - **Reporting**: Present CIF plots (NOT 1-KM) when competing risks exist. Report both cause-specific HR and subdistribution HR when the research question is etiologic. State which competing events were defined. When a CIF is quoted at a horizon beyond the median follow-up, report the number-at-risk at that horizon (or restrict the horizon) — a CIF extrapolated past the data is not a stable estimate
    
    ### Group Comparison
    
    - 2 independent groups: t-test or Mann-Whitney U
    - 2 paired groups: paired t-test or Wilcoxon signed-rank
    - 3+ independent groups: ANOVA or Kruskal-Wallis, with post-hoc
    - 3+ paired groups: repeated measures ANOVA or Friedman, with post-hoc
    - Always report: test statistic, degrees of freedom, p-value, effect size
    
    ### Correlation
    
    - Pearson r (if bivariate normal) or Spearman rho (if not)
    - Report: coefficient, 95% CI, p-value
    - Scatter plot with regression line and CI band
    - For multiple variables: correlation matrix heatmap
    
    ### Logistic Regression
    
    - **Guide**: Load `analysis_guides/regression.md` before generating code
    - **Template**: `references/templates/regression.py` (set `regression_type = "logistic"`)
    - Run univariable analysis first, then multivariable with clinically selected variables
    - Required outputs: OR table (univariable + multivariable), C-statistic (95% CI), and **calibration** (intercept + slope + flexible plot — **not** Hosmer–Lemeshow, which is deprecated; see the calibration guide)
    - **Prediction-model calibration guide**: `references/analysis_guides/calibration.md` (**load before generating code** for any model that outputs a risk used for a decision — the apparent slope of exactly 1.00 is the in-sample tell, so produce the **bootstrap optimism-corrected** slope/intercept; Van Calster's calibration levels; scaled Brier; why Hosmer–Lemeshow is dropped; produce-side of probe S7)
    - Check VIF < 5, EPV >= 10 (warn if violated)
    - **Nested observation units**: when rows are clustered within subjects (multiple lesions/visits per patient), use cluster-robust standard errors (`cov_type="cluster"`, `cov_kwds={"groups": id}` in statsmodels) or a mixed-effects logistic model — a naive logit CI assumes independent rows and is too narrow
    - Box-Tidwell test for continuous predictor linearity
    - Forest plot of adjusted ORs
    - NRI/IDI if comparing models (incremental value assessment)
    
    ### Linear Regression
    
    - **Guide**: Load `analysis_guides/regression.md` before generating code
    - **Template**: `references/templates/regression.py` (set `regression_type = "linear"`)
    - Required outputs: coefficient table (β, 95% CI, P), R²/adjusted R², VIF
    - Always generate 4-panel diagnostic plot (residuals vs fitted, Q-Q, scale-location, leverage)
    - Check assumptions: normality of residuals, homoscedasticity, multicollinearity
    - Report both unstandardized β (primary) and standardized β (for effect size comparison)
    
    ### Propensity Score
    
    - **Guide**: Load `analysis_guides/propensity_score.md` before generating code
    - **Template**: `references/templates/propensity_score.py`
    - Step 1: PS estimation (logistic regression)
    - Step 2: Apply method (matching with caliper = 0.2 × SD logit PS, IPTW/SIPTW with stabilized weights, or overlap weighting)
    - Step 3: Balance assessment — SMD < 0.10 for all covariates, Love plot mandatory
    - Step 4: Weighted/matched outcome analysis with robust SE
    - Step 5: Sensitivity analysis (E-value for unmeasured confounding)
    - Always state the estimand (ATE/ATT/ATO) explicitly
    - Recommend overlap weighting as default (no extreme weight issues)
    - **SIPTW**: Stabilized IPTW variant used in emulated target trial frameworks; report effective sample size
    
    ### Survey-Weighted Analysis
    
    - **Guide**: Load `analysis_guides/survey_weighted.md` before generating code
    - **Template**: `references/templates/survey_weighted_analysis.py`
    - For KNHANES/NHANES/KCHS and similar complex survey designs
    - Always declare survey design (strata, cluster/PSU, weight) before analysis
    - Use correct weight variable (interview vs exam vs nutrition)
    - R `survey` package strongly recommended over Python for publication
    - Sequential model building: Model 1 (age+sex) → Model 2 (full adjustment)
    - Report weighted odds ratios (wOR) with 95% CI
    - Cross-national: analyze each country separately, never pool
    - Subgroup analysis: exclude the stratification variable from covariates
    
    ### Mediation Analysis
    
    - **Guide**: Load `analysis_guides/mediation.md` before generating code
    - Bootstrapped product-of-coefficients (a×b) indirect effect (R `mediation` / `CMAverse` / PROCESS); ≥2000 resamples, bias-corrected percentile CI — not the Sobel test
    - **Binary outcome**: counterfactual / natural-effects decomposition (`CMAverse`, `regmedint`), not the naive OR product
    - Report total, direct, indirect effects each with a bootstrap CI; **proportion mediated only with uncertainty and only when the total effect is well-estimated** (unstable / can exceed 100% when total is near-null)
    - **Identification, not the bootstrap, is the issue**: mediation needs no unmeasured mediator–outcome confounding (sequential ignorability) → report an **E-value for the indirect effect** (or ρ-based sensitivity). A cross-sectional design cannot order X→M→Y — frame as association-level (review probe O13)
    - Report against **AGReMA**
    
    ### Interaction & Effect Modification
    
    - **Choose and state the scale.** A public-health / biological **synergy** claim is an **additive**-scale statement → report **RERI**, **AP** (attributable proportion), or **S** (synergy index), each **with a CI** — not only a multiplicative OR/HR product term. A non-significant multiplicative interaction is compatible with a large additive one (and vice versa)
    - **"Joint association" via a combined multi-level exposure** (high/high vs low/low) shows joint *categories*, not interaction — add the product term (multiplicative) and/or RERI (additive) to claim interaction
    - **Stratified-only** "stronger in A than B" is the difference-in-significance fallacy — report the formal interaction term, not two separate stratum estimates
    - R `interactionR` / `epiR` for RERI/AP/S with CIs; follow Knol & VanderWeele interaction-reporting recommendations. Review-side probe: O14 in `observational_confounding.md`
    
    ### Multiple Testing & High-Dimensional Screening
    
    - **Guide**: Load `analysis_guides/multiplicity.md` before generating code
    - For agnostic many-exposure scans (ExWAS / EWAS / MWAS / proteome-/nutrient-wide) and any "screen N predictors, report the significant ones" pass
    - **Match the correction to the claim**: FWER (Bonferroni / Holm / permutation-based study-wide threshold) for a confirmatory single hit; FDR (Benjamini–Hochberg q-value) for discovery — then frame as hypothesis-generating
    - Report the correction method **and the number of tests `m`** (the denominator), applied to the whole tested set — never shrink `m` to the winners
    - **Replication is the real safeguard**: split-half / second cohort / cross-cycle with directional concordance and a reported replication rate; a single-cohort FDR-significant scan is exploratory
    - Correlated exposures → raw Bonferroni is over-conservative (permutation or effective-number-of-tests via `poolr::meff()`); a univariate hit may be a marker for a correlated cause (consider WQS / quantile g-computation / BKMR before causal reading)
    - Report full results (all effect sizes + p/q), not only winners; complex surveys combine design-based SEs (`survey_weighted.md`) WITH the correction. Review-side probe: O17 in `observational_confounding.md`
    
    ### Mendelian Randomization
    
    - **Guide**: Load `analysis_guides/mendelian_randomization.md` before generating code
    - For genetic-instrument causal inference: two-sample summary-data MR, one-sample MR, MVMR, drug-target / cis-MR, non-linear MR
    - **State and evidence the 3 IV assumptions**: relevance (F-statistic / R²), independence (ancestry + confounder scan), exclusion restriction (no horizontal pleiotropy — the untestable one)
    - **Pre-specify the full sensitivity suite, not IVW alone**: IVW + MR-Egger (intercept = directional pleiotropy) + weighted median + weighted mode + MR-PRESSO; Cochran's Q + leave-one-out; concordance across methods is the robustness claim (R `TwoSampleMR` / `MendelianRandomization`)
    - Address **reverse causation** (Steiger / bidirectional), **sample overlap** (report fraction; overlap + weak instruments inflate type-1 error), **winner's curse** (select instruments in an independent GWAS), and ancestry matching
    - **Drug-target / cis-MR**: GLS-IVW for correlated cis variants + colocalization (`coloc`) + positive control + adverse-effect phenome scan. **Non-linear MR**: residual/doubly-ranked shapes can be artefactual → require negative/positive controls + extreme-stratum sensitivity
    - Interpret as a **lifelong genetic-proxy effect direction**, not a clinical-intervention magnitude; report against **STROBE-MR**. Review-side probes: MR1–MR8 in `mendelian_randomization.md`
    
    ### Polygenic Risk Score (PRS / PGS)
    
    - **Guide**: Load `analysis_guides/polygenic_risk_score.md` before generating code
    - For developing/validating/applying a genome-wide polygenic score as a predictor or risk-stratifier (distinct from MR: PRS is prediction, MR is causal inference)
    - **Base (discovery GWAS) and target/validation samples must be independent**; tune (P+T / LDpred2 / PRS-CS shrinkage / quantile cut) on a separate tuning set and evaluate out-of-sample (avoid overfitting / winner's curse). Tools: PRSice-2, LDpred2 (`bigsnpr`), PRS-CS / PRS-CSx, BridgePRS
    - **Ancestry portability is the central issue**: report performance **separately per target ancestry** (within-group differences can rival between-group); prefer ancestry-matched / multi-ancestry discovery + PCs; do not extend a European-derived score to other ancestries without per-ancestry validation
    - Report **OR/HR per SD** (CI) + quantile **absolute risk**; discrimination (C/AUC) **and** calibration (plot + slope/intercept) in the target population — discrimination ≠ calibration
    - **Incremental value is the clinical crux**: report PRS **on top of** the guideline clinical model (SCORE2/QRISK3/PCE/Tyrer-Cuzick) — ΔC-statistic (CI), NRI/IDI, net benefit — not PRS-alone AUC. A screening claim needs detection-rate-at-fixed-FPR / likelihood ratio, not AUC
    - Prefer prospective/incident validation (prevalent case–control overstates utility); report against **PGS-RS** / TRIPOD+AI. Review-side probes: PG1–PG8 in `polygenic_risk_score.md`
    
    ### NHIS Claims-Based Studies
    
    - **Guide**: Load `analysis_guides/nhis_icd10_mapping.md` for disease definition patterns
    - Claims-based algorithms: N-claim rule, claim+medication, look-back period
    - Always specify ICD-10 code ranges, claim count requirement, and time windows
    - Charlson comorbidity index: cite Quan 2005 adaptation
    - Anchor covariates to most recent data prior to index date
    - Sensitivity analysis: test stricter/looser disease definitions
    
    ### Burden of Disease, Decomposition & Forecasting
    
    - **Guide**: Load `analysis_guides/burden_decomposition_forecasting.md` before generating code
    - For a burden-of-disease estimate, attributable-risk (PAF / comparative risk assessment), temporal-trend (joinpoint / AAPC), decomposition (Das Gupta; Arriaga life-expectancy), or forecast (BAPC / age-period-cohort)
    - **The value-add-layer playbook** — a descriptive rate is rarely publishable alone; bolt on ONE layer: decomposition (*why* the rate changed — aging vs population growth vs epidemiological change), PAF (*how much* is modifiable), joinpoint/AAPC pre-vs-post (did a datable policy/shock bend the trend), forecast (*where* it is going), Arriaga (which ages/causes drove ΔLE)
    - **Uncertainty intervals, not CIs**: report a draw-based 95% UI (2.5th–97.5th percentile of 250–500 draws propagated end-to-end); a UI crossing the null means insufficient evidence for direction, not a non-significant test
    - **Single-center cohort adaptation**: three layers port onto existing follow-up without new data — trend-break (reslice around a datable guideline/scanner-era change), forecast (project a serial imaging trajectory), and global framing (place the individual-level effect next to the published GBD burden from GHDx — contextualization, not re-estimation, so no GATHER trigger). The ecological "UI-replaces-confounding-control" shortcut does **not** port: individual-level data still needs the DAG / E-value / negative-control toolkit
    - Report the estimate against **GATHER** (`/check-reporting`); keep burden/attribution/decomposition/forecast descriptive or associational unless a causal design (natural experiment, MR — `analysis_guides/mendelian_randomization.md`) is in place
    
    ### Repeated Measures
    
    - **Guide**: Load `analysis_guides/repeated_measures.md` before generating code
    - **Template**: `references/templates/repeated_measures.py`
    - Default method: **LMM** (handles missing data, no sphericity assumption)
    - RM ANOVA only if: no missing data AND few time points AND sphericity met
    - GEE for: population-averaged effects or non-normal outcomes
    - Always convert wide → long format first
    - **Time × Group interaction is the key result** — always report and interpret
    - Generate spaghetti plot (individual trajectories) + group mean trajectory plot
    - For LMM: report random effects structure, covariance structure (CS/AR1/UN), AIC/BIC
    - For RM ANOVA: report Mauchly's test, epsilon, correction method (Greenhouse-Geisser)
    - If missing > 5%: load `analysis_guides/missing_data.md` and apply MICE before analysis
    
    ### Covariate Pitfalls: Structural Zeros & Dose/Duration Variables
    
    Applies to any multivariable adjustment (logistic / linear / Cox / propensity-score / survey-weighted). Two coupled failure modes around a **dose/duration variable anchored to a categorical exposure** (pack-years under smoking status, grams/week under alcohol use, cessation-duration under former-smoker):
    
    - **Structural-zero guard (do not impute):** a never-smoker's `pack_years` is a *structural zero*, not missing-at-random — the value is known to be 0 by definition of the category. Feeding it to MICE/MNAR imputation as if it were missing fabricates a non-zero dose for unexposed subjects and corrupts the exposure contrast. Before imputing any dose/duration column, set the implied zero explicitly (`IF status == 'never' THEN dose = 0`) and impute only the genuinely-missing residual among the exposed. `/clean-data` flags categorical-implied-zero contradictions (a `never` row with a NULL dose) and ships `scripts/check_structural_zero.py`.
    - **Complete-case collapse warning (use status, not dose, for adjustment):** when a dose/duration variable enters a *complete-case* multivariable model, the unexposed stratum — which carries structural zeros often stored as NULL — is dropped wholesale, collapsing n (commonly 40–60%) and distorting subgroup estimates (a small stratum can shrink to a handful of subjects). For confounder adjustment use the **categorical status** variable (never/former/current); reserve the continuous **dose** for an exposed-only (e.g., ever-smoker-restricted) *secondary* analysis. Always report n before and after model fitting and confirm the denominator did not silently collapse.
    
    ### Covariate Selection: Over-adjustment in a Cross-Sectional Outcome Model
    
    Applies to any cross-sectional / single-visit outcome regression (the exposure and outcome are measured at one time point, so temporal order is not observed). The selection rule is **causal, not statistical**:
    
    - **Do not adjust for a consequence or mediator of the outcome.** A covariate that the outcome physiologically *drives* sits on or after the causal path; adjusting for it is over-adjustment / collider bias and removes part of the effect under study. The signature case is a renal-function outcome: with **eGFR** as the outcome, **serum uric acid** is renally excreted (a lower eGFR mechanically raises urate), so uric acid is an outcome-consequence, not a confounder; blood pressure and HbA1c are often similarly downstream. Classify each candidate covariate against a DAG as confounder / mediator / outcome-consequence / collider, and keep only confounders in the primary model.
    - **"It differs in Table 1" is not a confounder-selection criterion.** Baseline imbalance by exposure justifies *considering* a variable, but a mediator or outcome-consequence stays out regardless of how imbalanced it is. A kitchen-sink "adjust for everything that differs" model is over-adjusted by construction.
    - **Report the suspect-covariate sensitivity + VIF.** Make a parsimonious, history-/design-based model the primary one; report the fuller model as a sensitivity analysis that **drops** the suspect covariate (or adds it, if you start parsimonious), and show whether the headline estimate moves. Always print VIF (collinearity between an outcome-consequence and the outcome's other correlates is common) and the n actually fitted. If dropping the covariate materially changes the estimate, propagate to the abstract and conclusion.
    - **Compare adjusted-vs-unadjusted on the SAME frame (extended-adjustment missingness trap).** When an extended-adjustment model adds covariates that carry missingness, the analytic n shrinks (e.g. 84 → 49 events). Comparing that adjusted estimate to the **full-frame** unadjusted/base estimate confounds *adjustment* with *case-concentrated missingness* — it can look as if "adjustment inflated the estimate" when the drift is who-was-dropped. The fair anchor is the **unadjusted estimate refit on the reduced complete-case frame** (the same rows the adjusted model used): report unadjusted-and-adjusted **on the reduced frame** alongside the full-frame estimate, and never describe "adjustment changed the estimate" from a comparison across different frames. (Equivalently, use multiple imputation so all models share one frame.)
    
    ## Language
    
    - Code and output: English
    - Communication with user: Match user's preferred language
    - Medical terms: English only
    
    ## What This Skill Does NOT Do
    
    - Does not fabricate or simulate data to fill gaps
    - Does not choose analysis endpoints -- the user decides the research question
    - Does not interpret clinical significance -- only statistical results
    - Does not replace biostatistician review for complex designs (e.g., adaptive trials)
    
    ## Anti-Hallucination
    
    - **Never fabricate variable names, dataset column names, or variable codings.** If a variable mapping is unce
  • skill.yml 1.8 KB
    schema_version: 2
    name: analyze-stats
    layer: B
    owner_domain: statistical_analysis
    maturity: official
    
    when_to_use: "Generate reproducible Python/R analysis code with publication-ready tables and figures for a defined study design."
    when_NOT_to_use: "Sample-size planning (use calc-sample-size); figure-only generation (use make-figures)."
    
    inputs:
      - "analysis dataset (CSV)"
      - "analysis plan / variable definitions"
    outputs:
      - "analysis code (Python/R)"
      - "result tables"
      - "analysis figures"
      - "_analysis_outputs.md (bound execution record for prespecified binary predictions)"
    side_effects:
      - writes_project_artifacts
      - executes_analysis_code
    downstream_consumers:
      - write-paper
      - make-figures
      - self-review
    forbidden_actions:
      - fabricate_or_hand_type_numeric_results
      - report_estimates_without_ci_or_p
    
    # v2.1 quality card
    purpose: "Produce reproducible statistical code and publication-ready output for a specified design (DTA, agreement, survival, regression, survey, etc.)."
    safety_boundaries:
      - "All numbers come from executed code on the supplied data; never hand-typed (seed-fixed transforms)."
      - "Primary estimates report 95% CIs; planned hypothesis tests also report effect sizes and exact p-values."
    known_limitations:
      - "Correctness depends on a correct analysis plan and clean data (use design-study / clean-data first)."
      - "Does not adjudicate clinical validity of the chosen test."
      - "The bound binary workflow requires declared independent units and fixed 0/1 predictions; hashes do not establish design validity or data rights."
    validation_commands:
      - "python3 tests/test_analysis_run.py"
      - "python3 scripts/demo_analysis_run.py --out demo-project"
      - "re-run the emitted script and diff results"
      - "/self-review"
    evidence_surface: demo
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related