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
Install
npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/analyze-stats
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
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):
- If
*_deidentified.*files exist in the working directory, use those preferentially. - 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
/deidentifyskill." - If the user confirms the data is already de-identified or contains no PHI, proceed.
- 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 formattingtable-standards.md-- universal rules, AMA rules, footnote system, mistakes checklistjournal-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
- Read the data file (CSV, Excel, TSV, or other tabular format).
- 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
- 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:
Auto-detect analysis type from the table below, or accept user specification.
List specific tests to be performed.
Identify primary and secondary endpoints.
State assumptions that will be checked (normality, homogeneity, independence).
Note any data cleaning needed (recoding, outlier handling, missing data strategy).
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.
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 —
glmdoes 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 --strictCOMPLETE_SEPARATION(an empty cell) andQUASI_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
- Random seed: Always
np.random.seed(42)orset.seed(42). - 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) - Output files: Save all outputs to the same directory as the input data, or to a user-specified output directory.
- Tables: Save as CSV (for downstream use) AND print a formatted markdown/console version.
- Figures: Save as both PDF (vector) and PNG (300 DPI).
- 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 Nandsum(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-reviewcheck_cohort_arithmetic.pyPARTITION_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. Report0(or0.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 nonp.random.seed/set.seed/random_state=/default_rng. Non-reproducible.HARDCODED_DATA_LITERAL— a hand-typed, table-shaped numeric literal instead ofread_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:
- 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)"
- Table/figure captions: Draft captions referencing table/figure numbers.
- 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:
- 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.
- Confidence intervals: Always report 95% CIs for primary endpoints.
- Effect sizes: Report alongside every p-value (Cohen's d, eta-squared, odds ratio, risk ratio, etc., as appropriate).
- Parametric vs non-parametric: Choose based on assumption checks, not convenience. Report the assumption test results.
- Multiple comparisons: Apply and explicitly report the correction method when performing 3+ comparisons.
- Sample size reporting: Always state n for each group/analysis.
- Missing data: Report how many cases were excluded and why.
- Decimal places: p-values to 3 decimals, proportions to 1 decimal, means/SDs to appropriate precision for the measurement.
- 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
statsmodelsTTestIndPowerorscipy'snct); 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-reviewPhase 2.5a-2 is built to catch. - 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 inest (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
- Pick an anchored contrast on the exposure, not a 1-unit step. Default: 25th to 75th percentile (IQR). State both endpoints in native units.
- 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.
- For a rank/standardized association (Spearman's rho or a per-SD slope) under an approximately
monotonic-linear assumption:
- 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.
- 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.mdand themake-figuresdecision_curveexemplar (andrender_core_figures.pyfor 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:
- Load
${CLAUDE_SKILL_DIR}/references/table-standards/journal-profiles/{journal}.yamlif a target journal is known - Load
${CLAUDE_SKILL_DIR}/references/table-standards/table-types/{type}.mdfor the relevant table type - 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):
- General note (no marker) — e.g., "Data are mean (SD) unless noted"
- Abbreviations — in order per journal convention
- Specific notes (superscript markers) — per-cell explanations
- 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.mplstylefor 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 exemplarmake-figuresreferences/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. Pairsmake-figuresreferences/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
- Use
- Single-arm pooled proportion:
metaprop()withsm = "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-squaredon 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
- 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
- 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,metaforpackages) 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()withsm = "PLOGIT"for single-arm pooled proportion - Use
method = "Inverse",method.tau = "DL",method.random.ci = "HK"
- Primary:
- Small studies (k < 10): bivariate model may not converge; consider narrative synthesis
- Alternative: If
madaunavailable, usemetafor::rma.mv()with bivariate structure
Network Meta-Analysis
- Guide: Load
analysis_guides/network_meta_analysis.mdbefore 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,netrankP-scores, comparison-adjustedfunnel) or Bayesiangemtc/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.mdbefore 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),flexsurvfor survival extrapolation. Report against CHEERS 2022; make the "cost-effective" conclusion conditional on a stated willingness-to-pay threshold. Review-side probes: HE1–HE8 inhealth_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) - xbefore 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.pyprints 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-datascripts/check_reverse_coding.py. See the global rulesurvey-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 >= 10before 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 = TRUEin 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/survfitobject and always emit the 95% CI (the lower/upper fromquantile(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()ortidycmprsk::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.mdbefore generating code - Template:
references/templates/regression.py(setregression_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.mdbefore generating code - Template:
references/templates/regression.py(setregression_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.mdbefore 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.mdbefore 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
surveypackage 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.mdbefore 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/epiRfor RERI/AP/S with CIs; follow Knol & VanderWeele interaction-reporting recommendations. Review-side probe: O14 inobservational_confounding.md
Multiple Testing & High-Dimensional Screening
- Guide: Load
analysis_guides/multiplicity.mdbefore 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 shrinkmto 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 inobservational_confounding.md
Mendelian Randomization
- Guide: Load
analysis_guides/mendelian_randomization.mdbefore 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.mdbefore 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.mdfor 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.mdbefore 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.mdbefore 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.mdand 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_yearsis 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-dataflags categorical-implied-zero contradictions (aneverrow with a NULL dose) and shipsscripts/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.
Reviews (0)
No reviews yet.
No comments yet.