Claude Skill

clean-data

Interactive data profiling and cleaning assistant for medical research. Three-stage workflow (profile, flag, code-generate) with user approval gates at each step. Handles missing values, outliers, duplicates, and type mismatches in CSV/Excel clinical data. Does NOT auto-clean — a

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

Full trust report

Download aperivue-medsci-skills-skills_clean-data-815765c.zip · 26 KB
Part of aperivue/medsci-skills — 47 skills

Install

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

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

Skill manifest

Data Profiling and Cleaning Skill

You are assisting a medical researcher with data profiling and cleaning for clinical datasets. This is a three-stage interactive workflow. You generate code and reports -- you do NOT auto-clean data. Every cleaning decision requires explicit researcher confirmation.

Philosophy

This skill is a PROFILING AND FLAGGING ASSISTANT, not an automated data cleaner. Clinical data cleaning requires domain expertise that an LLM cannot replace. Every cleaning decision must be confirmed by the researcher.

DATA PRIVACY WARNING

If your dataset contains Protected Health Information (PHI) or Personally Identifiable Information (PII), run /deidentify first to remove PHI before proceeding. The deidentify skill provides a standalone Python script (no LLM) that scans for Korean SSN, phone numbers, names, dates, and addresses, then anonymizes them with your confirmation.

If *_deidentified.* files exist in the working directory, use those instead of raw data.

Alternatively:

  1. Provide only the data dictionary / codebook for profiling guidance
  2. Or use a local-only environment with no network access

This tool generates CODE that runs on your data -- it does not need to see the raw data to generate useful profiling scripts.

Reference Files

  • Profiling template: ${CLAUDE_SKILL_DIR}/references/profiling_template.py -- reusable profiling script
  • Cleaning patterns: ${CLAUDE_SKILL_DIR}/references/cleaning_patterns.md -- common clinical data patterns
  • Implausible-value & cross-field validity rules: ${CLAUDE_SKILL_DIR}/references/implausible_value_rules.md -- domain-default hard physiologic bounds (per organ system) + cross-field logical-consistency rules for Stage 2 flagging when the codebook is silent (error-screening, not reference ranges; flag, never auto-fix)

Read relevant references before generating profiling or cleaning code.

Three-Stage Workflow

Stage 1: Profiling

Input: CSV/Excel file path OR data dictionary/codebook

Actions:

  1. Generate a Python profiling script (pandas-based) that produces:
    • Variable count, row count, data types
    • Missing value count and percentage per variable
    • Unique value counts for categorical variables
    • Min/max/mean/median/SD for numeric variables
    • Distribution plots (histograms for numeric, bar charts for categorical)
  2. If user provides a codebook: cross-reference variable names, expected types, expected ranges
  3. Present summary table to user

Use ${CLAUDE_SKILL_DIR}/references/profiling_template.py as the base script. Adapt it to the specific dataset structure.

Gate: User reviews profiling output before proceeding. Ask:

"Here is the profiling summary. Would you like to proceed to Stage 2 (Flagging)? Are there any variables you want to exclude or focus on?"

Stage 2: Flagging

Based on profiling results, flag potential issues in these categories:

  1. Missing values: Variables with >5% missing, pattern analysis (MCAR/MAR/MNAR heuristic)

  2. Statistical outliers: IQR method (Q1 - 1.5IQR, Q3 + 1.5IQR) and Z-score (|z| > 3)

  3. Duplicates: Exact row duplicates AND near-duplicates (same patient ID, different dates)

  4. Type mismatches: Numeric stored as string, dates in inconsistent formats

  5. Implausible values: Use the codebook's valid range when provided; when the codebook is silent, apply the domain-default hard physiologic bounds in references/implausible_value_rules.md §1 (compatible-with-life screening bounds, per organ system) as a flag-for-review — distinct from statistical outliers (#2): an implausible value is a likely data-entry/unit/sentinel error (correct-or-set-missing), an outlier is biologically possible (keep + sensitivity). Check units before calling a bound violation an error. Never auto-fix. 5b. Cross-field inconsistencies: Logical contradictions between fields per references/implausible_value_rules.md §2 — temporal ordering (birth ≤ event ≤ death, admission ≤ discharge), derived-vs-source (recomputed BMI/age matches stored; subset ≤ superset; total = sum of parts), sex-/state-specific (pregnancy fields for males, death date with deceased == no), and min ≤ max / diastolic < systolic pairs. Flag with the rule that fired; High severity for a hard contradiction.

  6. Category inconsistencies: Typos in categorical values (e.g., "Male", "male", "M", "MALE")

  7. Categorical-implied zeros: When a categorical variable defines a natural zero for a dose/duration variable (smoking_status == 'never' implies pack_years == 0, alcohol_use == 'never' implies grams_per_week == 0), flag any record where the implied zero is stored as NULL/missing instead of 0. This is a contradiction, not a missing-data pattern: a never-smoker with pack_years = NULL will be silently dropped by complete-case models or, worse, imputed to a non-zero dose by MICE — corrupting the exposure contrast. Suggested action: "Set dose = 0 where category == reference level; impute only the residual missingness among the exposed." Detected by scripts/check_structural_zero.py given the category↔dose mapping; pairs with /analyze-stats "Covariate Pitfalls: Structural Zeros & Dose/Duration Variables".

  8. Reverse-coded scale items: When a multi-item Likert scale (Trust, Satisfaction, Burden, etc.) mixes positively- and negatively-worded items, every negatively-worded ("reverse") item must be recoded (min+max) - x before the scale total or Cronbach's alpha is computed. A reverse item left un-recoded correlates negatively with the rest of the scale and collapses alpha — often turning it negative. A negative alpha is almost never a real measurement phenomenon; it is a reverse-coding bug, and defending it as "multidimensional structure" loses a review round. Suggested action: "Recode reverse-worded items, then recompute reliability." Detected by scripts/check_reverse_coding.py (flags items with a negative item-rest correlation and a negative raw alpha, given the scale item columns); the recode itself is applied downstream by /analyze-stats likert_summary.py --reverse-items. Pairs with the global rule survey-scale-reliability.md.

Present the flag report as a structured table:

Variable Issue Type Count Severity Suggested Action
age Outlier (IQR) 3 Medium Review: values 150, 200, -5
sex Category inconsistency 12 Low Harmonize: Male/male/M -> "Male"
lab_date Type mismatch 45 High Parse to datetime
pack_years Categorical-implied zero 12421 High Set 0 where smoking_status=='never' (structural zero, not missing)
scale_item_4 Reverse-worded item (raw α negative) n/a High Recode (6 - x) before reliability; a negative α is a coding bug, not a finding

Severity levels:

  • High: Likely data errors that will affect analysis (type mismatches, impossible values)
  • Medium: Potential issues that need expert review (statistical outliers, moderate missingness)
  • Low: Minor inconsistencies that are easy to fix (category labels, trailing whitespace)

Gate: User reviews flags and approves/rejects each suggested action. Ask:

"Please review the flagged issues above. For each row, indicate: (A) Approve the suggested action, (R) Reject / keep as-is, or (M) Modify the action. Only approved actions will generate cleaning code."

Stage 3: Code Generation

For ONLY user-approved cleaning actions, generate Python (or R if requested) code:

  • Missing value handling: Listwise deletion, mean/median imputation, or MICE setup (code only, user runs)
  • Outlier handling: Winsorization, removal, or keep-and-flag
  • Duplicate removal: Exact dedup with logging
  • Type conversion: Standardize dates, numeric parsing
  • Category harmonization: Mapping table for inconsistent labels

All generated code MUST include:

  • Before/after row counts printed to console
  • Logging of every modification to a cleaning log DataFrame
  • Reproducibility: np.random.seed(42) and random.seed(42) where applicable
  • Output: cleaned CSV + cleaning_log.csv
  • Clear comments explaining each cleaning step

End the generated script with this notice:

"This code implements ONLY the cleaning rules you approved. Review the cleaning_log.csv output to verify all changes before proceeding to analysis."

Scope Limitations

Supported:

  • Missing values (detection, simple imputation code, MICE setup)
  • Outliers (statistical detection via IQR and Z-score)
  • Duplicates (exact and near-duplicate detection)
  • Type mismatches (numeric parsing, date standardization)
  • Category harmonization (case, abbreviation, whitespace)

NOT supported:

  • Domain-specific plausible ranges (unless codebook provided)
  • Complex imputation strategy selection (MICE setup only, user picks variables/method)
  • Natural language extraction from clinical notes
  • Image data cleaning or DICOM metadata
  • Automated decisions -- all cleaning requires researcher approval

This tool flags issues. Final cleaning decisions require your domain knowledge.

Cross-Skill Integration

  • clean-data sits BEFORE analyze-stats in the research pipeline
  • design-study can inform which variables to focus profiling on
  • manage-project tracks overall project state including data cleaning status
  • After cleaning, hand off to analyze-stats for statistical analysis

Output Format

Structure all reports using this template:

## Data Profiling Report

### Dataset Overview
- Rows: [N]
- Columns: [N]
- File size: [size]
- Date range: [if applicable]

### Variable Summary
| Variable | Type | Missing N (%) | Unique | Min | Max | Mean | SD |
|----------|------|---------------|--------|-----|-----|------|-----|
| ...      | ...  | ...           | ...    | ... | ... | ...  | ... |

### Flags
| Variable | Issue | Count | Severity | Suggested Action |
|----------|-------|-------|----------|-----------------|
| ...      | ...   | ...   | ...      | ...             |

### Cleaning Code
[Python/R script -- only for approved actions]

### Cleaning Log
[What was changed, how many rows affected, before/after counts]

Anti-Hallucination

  • Never fabricate variable names, dataset column names, or variable codings. If a variable mapping is uncertain, output [VERIFY: variable_name] and ask the user to confirm against the data dictionary.
  • Never fabricate statistical results — no invented p-values, effect sizes, confidence intervals, or sample sizes. All numbers must come from executed code output.
  • Never generate references from memory. Use /search-lit for all citations.
  • If a function, package, or API does not exist or you are unsure, say so explicitly rather than guessing.
Files (medsci-skills)
  • references
    • cleaning_patterns.md 11.6 KB
      # Common Clinical Data Cleaning Patterns
      
      Reference document for the clean-data skill. Covers recurring data quality issues
      in electronic health records, registries, and research databases.
      
      ---
      
      ## 1. Missing Data Patterns
      
      ### Classification
      
      - **MCAR (Missing Completely At Random)**: Missingness is unrelated to any variable.
        Example: random equipment failure during lab measurement.
        Test: Little's MCAR test (chi-square). If p > 0.05, MCAR is plausible.
      
      - **MAR (Missing At Random)**: Missingness depends on observed variables but not the
        missing value itself. Example: younger patients less likely to have bone density measured.
        Cannot be directly tested; inferred from associations between missingness indicators
        and observed covariates.
      
      - **MNAR (Missing Not At Random)**: Missingness depends on the unobserved value itself.
        Example: severely ill patients too sick to complete follow-up surveys.
        Cannot be tested from the data alone; requires domain knowledge.
      
      ### Heuristic Assessment
      
      1. Compute missing percentage per variable.
      2. Create missingness indicator (0/1) for each variable with >5% missing.
      3. Correlate missingness indicators with observed variables (chi-square, t-test).
      4. If strong correlations exist: likely MAR. If none: plausible MCAR. If clinical
         reasoning suggests the value itself drives missingness: suspect MNAR.
      
      ### When to Use Each Imputation Method
      
      | Method | When appropriate | Caution |
      |--------|-----------------|---------|
      | Listwise deletion (complete case) | MCAR, low % missing (<5%), large sample | Biased if MAR/MNAR; reduces power |
      | Mean/median imputation | Quick exploratory analysis only | Underestimates variance; distorts distributions |
      | Last observation carried forward | Longitudinal data, slow-changing variables | Biased if trajectory is changing |
      | Multiple imputation (MICE) | MAR, moderate missing (5-40%), multivariate | Requires careful model specification |
      | Maximum likelihood (FIML) | MAR, SEM or regression contexts | Needs software support |
      | Sensitivity analysis | Always for MNAR suspicion | Report results under multiple assumptions |
      
      ### Key Rule
      
      Never impute the outcome variable in the primary analysis without explicit justification.
      Report the missing data mechanism assumption in the methods section.
      
      ---
      
      ## 2. Outlier Detection
      
      ### Statistical Methods
      
      **IQR Method (Tukey Fences)**:
      - Lower fence: Q1 - 1.5 * IQR
      - Upper fence: Q3 + 1.5 * IQR
      - Robust to non-normal distributions
      - Preferred for clinical data where normality is rarely guaranteed
      
      **Z-Score Method**:
      - Flag values with |z| > 3 (or |z| > 2.5 for smaller samples)
      - Assumes approximate normality
      - Sensitive to the outliers themselves (mean and SD are affected)
      
      **Modified Z-Score (MAD-based)**:
      - Uses median and Median Absolute Deviation instead of mean/SD
      - More robust than standard Z-score
      - Formula: M_i = 0.6745 * (x_i - median) / MAD
      
      ### Decision Framework
      
      | Scenario | Recommended action |
      |----------|--------------------|
      | Data entry error (clearly impossible) | Correct if source available; else set to missing |
      | Measurement error (instrument fault) | Set to missing; document in cleaning log |
      | True extreme value (biologically plausible) | Keep in dataset; consider sensitivity analysis with/without |
      | Ambiguous | Flag for domain expert review; do not remove without justification |
      
      ### Clinical Context Matters
      
      A BMI of 50 is an outlier statistically but clinically real. An age of 200 is impossible.
      A creatinine of 15 mg/dL is extreme but occurs in dialysis patients. Always consult the
      codebook and clinical context before removing outliers.
      
      For the **biologically-impossible** side of this distinction — hard compatible-with-life bounds
      per organ system, plus cross-field logical-consistency rules (date ordering, derived-vs-source,
      sex-specific) — see `implausible_value_rules.md`. That handles likely data-entry/unit errors
      (correct-or-set-missing); this section handles statistically extreme but possible values (keep +
      sensitivity).
      
      ---
      
      ## 3. Duplicate Detection
      
      ### Exact Duplicates
      
      - Identical across ALL columns.
      - Usually safe to remove (keep first occurrence).
      - Common cause: accidental double-submission or ETL errors.
      
      ### Near-Duplicates
      
      - Same patient identifier, different records.
      - May be legitimate (multiple visits) or errors (same visit entered twice with typos).
      
      ### Detection Strategy
      
      1. Check for exact row duplicates: `df.duplicated().sum()`
      2. Check for duplicate patient IDs: `df['patient_id'].duplicated().sum()`
      3. For near-duplicates: group by patient ID, sort by date, check for records within
         a suspiciously short time window (e.g., same day for what should be annual visits).
      4. Fuzzy matching: consider Levenshtein distance on name fields if no unique ID exists.
      
      ### Resolution
      
      - Exact duplicates: drop duplicates, log count.
      - Same-patient near-duplicates: present to researcher for manual review.
      - Never auto-merge patient records without explicit approval.
      
      ---
      
      ## 4. Date Handling
      
      ### Common Date Formats in Clinical Data
      
      | Format | Example | Source |
      |--------|---------|--------|
      | YYYY-MM-DD | 2024-03-15 | ISO 8601, most databases |
      | MM/DD/YYYY | 03/15/2024 | US clinical systems |
      | DD/MM/YYYY | 15/03/2024 | European systems |
      | YYYYMMDD | 20240315 | DICOM, HL7 |
      | DD-Mon-YYYY | 15-Mar-2024 | Some EMR exports |
      | Excel serial | 45366 | Excel numeric date |
      
      ### Common Issues
      
      - **Ambiguous dates**: Is 03/04/2024 March 4th or April 3rd? Check the data source locale.
        Look for values >12 in the first or second position to disambiguate.
      - **Impossible dates**: February 30, month 13, year 0001.
      - **Future dates**: Dates after the data extraction date (except for scheduled appointments).
      - **Timezone issues**: Rarely relevant for clinical research dates, but critical for timestamps
        in multi-site studies across time zones.
      - **Two-digit years**: 24 could be 1924 or 2024. Use a pivot year (e.g., 30: <=30 means 2000s,
        >30 means 1900s) or infer from context.
      
      ### Standardization
      
      1. Parse all date columns to datetime using `pd.to_datetime(col, format=..., errors='coerce')`.
      2. Check for NaT (failed parses) and investigate.
      3. Standardize to ISO 8601 (YYYY-MM-DD) for storage.
      4. Calculate derived variables (age at event, follow-up duration) from standardized dates.
      
      ---
      
      ## 5. Category Harmonization
      
      ### Common Inconsistencies
      
      | Raw values | Harmonized |
      |-----------|-----------|
      | "Male", "male", "M", "MALE", " Male " | "Male" |
      | "Y", "Yes", "yes", "YES", "1", "True" | 1 or "Yes" |
      | "Right", "Rt", "R", "right", "RT" | "Right" |
      | "Non-small cell", "NSCLC", "non small cell" | "NSCLC" |
      
      ### Harmonization Steps
      
      1. Strip whitespace: `series.str.strip()`
      2. Normalize case: `series.str.lower()` or `series.str.title()`
      3. Build a mapping dictionary for known synonyms.
      4. Review unmapped values manually.
      5. Apply mapping: `series.map(mapping_dict).fillna(series)`
      
      ### Encoding Standards
      
      - **ICD-10**: Diagnosis codes. Watch for version differences (ICD-10-CM vs ICD-10-PCS).
      - **SNOMED CT**: Clinical terminology. More granular than ICD-10.
      - **LOINC**: Laboratory observations. Use for standardizing lab test names.
      - **CPT/HCPCS**: Procedure codes.
      
      When possible, map free-text categories to standard coding systems. Document the mapping
      table and include it in supplementary materials.
      
      ---
      
      ## 6. Common Clinical Data Pitfalls
      
      ### Lab Values with Inequality Prefixes
      
      Values like "<0.01", ">10000", "<=5" are common for lab results at detection limits.
      
      **Handling options**:
      - Replace with the limit value: "<0.01" -> 0.01 (conservative)
      - Replace with half the limit: "<0.01" -> 0.005 (common in environmental studies)
      - Replace with limit / sqrt(2): "<0.01" -> 0.00707 (EPA method)
      - Keep as censored data and use appropriate statistical methods (Tobit regression)
      
      Document the chosen method in the statistical analysis plan.
      
      ### Mixed Units
      
      Common in multi-site studies or data merged from different systems.
      
      | Analyte | Unit A | Unit B | Conversion |
      |---------|--------|--------|-----------|
      | Glucose | mg/dL | mmol/L | mg/dL = mmol/L * 18.018 |
      | Creatinine | mg/dL | umol/L | mg/dL = umol/L / 88.4 |
      | Hemoglobin | g/dL | g/L | g/dL = g/L / 10 |
      | Calcium | mg/dL | mmol/L | mg/dL = mmol/L * 4.008 |
      
      **Detection**: look for bimodal distributions in lab values -- one mode per unit system.
      
      ### Sentinel Values
      
      Values used as placeholders for missing data in legacy systems:
      
      | Sentinel | Meaning |
      |----------|---------|
      | 999, 9999, 99999 | Missing / not recorded |
      | -1, -9, -99 | Missing / not applicable |
      | 0 | Could be true zero OR missing -- context-dependent |
      | 88, 77 | "Not applicable" or "Refused" in survey data |
      | 8888 | "Not applicable" or "Missing" in health screening/institutional databases |
      | 01/01/1900 | Default/missing date |
      
      **Action**: Replace sentinel values with `NaN` BEFORE computing any statistics.
      Document which values were treated as sentinel.
      
      ### Excel Date Corruption
      
      Excel auto-converts certain strings to dates:
      - Gene names: SEPT1 -> Sep-1, MARCH1 -> Mar-1, DEC1 -> Dec-1
      - Sample IDs: 1-3 -> Jan-3, 2/4 -> Feb-4
      
      **Prevention**: Open CSV in a text editor first to verify. Import with explicit dtypes
      in pandas: `pd.read_csv(path, dtype={'gene': str})`.
      
      **Detection**: Look for datetime values in columns that should contain gene names or
      sample identifiers.
      
      ### Numeric Precision
      
      - Floating point: 0.1 + 0.2 != 0.3. Use `np.isclose()` for comparisons.
      - Rounding: Be consistent. Define rounding rules before analysis.
      - Integer overflow: Rare in Python, but watch for 32-bit integer limits in R or
        database imports (max 2,147,483,647).
      
      ---
      
      ## 7. Recommended Workflow
      
      The recommended end-to-end data cleaning workflow:
      
      1. **Profile**: Run the profiling script. Understand what you have.
      2. **Flag**: Identify potential issues. Categorize by type and severity.
      3. **Review**: Present flags to the domain expert (you, the researcher).
      4. **Approve**: Decide which flags to act on. Document rationale for each decision.
      5. **Clean**: Generate and run cleaning code for approved actions only.
      6. **Verify**: Compare before/after summaries. Check that cleaning did not introduce
         new problems.
      7. **Document**: Save the cleaning log, mapping tables, and decision rationale.
         Include in supplementary materials or methods section.
      
      ### Documentation Checklist
      
      - [ ] Number of rows before and after cleaning
      - [ ] Number and percentage of missing values per variable (before/after)
      - [ ] Outlier handling decisions with justification
      - [ ] Duplicate removal count
      - [ ] Category mapping tables
      - [ ] Imputation method and variables imputed
      - [ ] Any variables excluded from analysis and why
      
      ---
      
      ## 8. Key References
      
      1. Van den Broeck J, Cunningham SA,"; R,"; AB. Data cleaning: detecting,
         diagnosing, and editing data abnormalities. *PLoS Med*. 2005;2(10):e267.
         DOI: 10.1371/journal.pmed.0020267
      
      2. Kang H. The prevention and handling of the missing data.
         *Korean J Anesthesiol*. 2013;64(5):402-406.
         DOI: 10.4097/kjae.2013.64.5.402
      
      3. Sterne JAC, White IR, Carlin JB, et al. Multiple imputation for missing data
         in epidemiological and clinical research: potential and pitfalls.
         *BMJ*. 2009;338:b2393.
         DOI: 10.1136/bmj.b2393
      
      4. Altman DG, Bland JM. Missing data. *BMJ*. 2007;334(7590):424.
         DOI: 10.1136/bmj.38977.682025.2C
      
      5. White IR, Royston P, Wood AM. Multiple imputation using chained equations:
         Issues and guidance for practice. *Stat Med*. 2011;30(4):377-399.
         DOI: 10.1002/sim.4067
      
      6. Ziemann M, Eren Y, El-Osta A. Gene name errors are widespread in the
         scientific literature. *Genome Biol*. 2016;17(1):177.
         DOI: 10.1186/s13059-016-1044-7
      
      ---
      
      *This reference is part of the clean-data skill for the medical-research-skills package.*
      
    • implausible_value_rules.md 6.8 KB
      # Implausible-Value & Cross-Field Validity Rules
      
      Reference for the clean-data skill, used in **Stage 2 (Flagging)** item 5 ("Implausible values").
      It supplies domain-default **hard plausibility bounds** and **cross-field logical-consistency
      rules** so the skill can flag likely data-entry errors *even when the codebook does not state a
      valid range* — while never auto-correcting them.
      
      This complements `cleaning_patterns.md` §2 (statistical outlier detection): that section finds
      values that are *statistically extreme*; this one finds values that are *biologically impossible
      or internally contradictory*. The two are different and must not be conflated (§ "Implausible vs
      outlier" below).
      
      ---
      
      ## Implausible vs outlier (the distinction that drives the action)
      
      - **Implausible (compatible-with-life violation / impossible)** — outside the range a living
        human can take, or an impossible code (age 200, SBP 0 in a live patient, BMI 4, % > 100,
        negative count, future birth date). Almost always a **data-entry / unit / sentinel error**.
        Action: correct from source if available, else set to missing and log — do **not** keep it in
        the analytic distribution. (A frank sentinel like 999/9999 is handled in `cleaning_patterns.md`
        §6; treat it as missing *before* range-checking.)
      - **Outlier (statistically extreme but biologically possible)** — a BMI of 55, creatinine 12
        mg/dL in dialysis, WBC 300 in leukemia. Action: **keep**; consider a with/without sensitivity
        analysis (`cleaning_patterns.md` §2). Removing a real extreme value as if it were an error
        biases the result.
      - The same variable has **both** a hard implausibility bound and a soft outlier fence; apply the
        hard bound first (error removal), then assess outliers among the survivors.
      
      ## Population & codebook first
      
      These are **screening bounds to catch errors, not reference/normal ranges, and not analysis
      cut-points.** Always reconcile against the data dictionary (dictionary-first) and the **study
      population**: neonatal/pediatric, pregnancy, dialysis, ICU, and post-surgical cohorts legitimately
      exceed several adult bounds. When the codebook states a valid range, the codebook wins; use this
      table only to fill silence, and present every hit as a **flag for review**, never an auto-fix.
      
      ## 1. Single-field hard plausibility bounds (adult human, screening)
      
      > Units matter — a bound violation is often a **unit error** (e.g., height in cm stored as m,
      > temperature in °F stored as °C, glucose mg/dL vs mmol/L). Check units before treating as an
      > error (`cleaning_patterns.md` §6 Mixed Units).
      
      | Domain | Variable (unit) | Implausible if outside | Notes / common error |
      |---|---|---|---|
      | Demographics | Age (years) | 0–120 | >120 entry error; negative impossible; check DOB-derived age |
      | Demographics | Body weight (kg) | 1–400 | <1 = unit error (g?); adult typically 30–250 |
      | Demographics | Height (cm) | 30–250 | values 1.5–2.5 = metres stored as cm; >250 entry error |
      | Demographics | BMI (kg/m²) | 8–80 | derive and cross-check vs height/weight (§2) |
      | Vitals | Systolic BP (mmHg) | 40–300 | 0 in a live patient = error; >300 entry error |
      | Vitals | Diastolic BP (mmHg) | 20–200 | must be < systolic (§2) |
      | Vitals | Heart rate (bpm) | 20–300 | 0 in a live patient = error |
      | Vitals | Respiratory rate (/min) | 4–80 | |
      | Vitals | Temperature (°C) | 30–45 | 95–105 = °F stored as °C; outside 30–45 incompatible with life |
      | Vitals | SpO₂ (%) | 50–100 | >100 impossible; <50 rarely survivable, suspect error |
      | Labs | Blood pH | 6.6–7.8 | outside is incompatible with life |
      | Labs | Glucose (mg/dL) | 10–2000 | ÷18 for mmol/L unit error; <10/>2000 suspect |
      | Labs | Sodium (mmol/L) | 100–190 | |
      | Labs | Potassium (mmol/L) | 1.5–9.5 | |
      | Labs | Creatinine (mg/dL) | 0.1–25 | high real in renal failure (outlier, keep) |
      | Labs | Hemoglobin (g/dL) | 2–25 | g/L stored as g/dL = ×10 unit error |
      | Labs | WBC (×10⁹/L) | 0–500 | high real in leukemia (outlier) |
      | Labs | Platelets (×10⁹/L) | 0–3000 | |
      | General | Any percentage / proportion | 0–100 (or 0–1) | >100% or >1 impossible; mixed 0–1 vs 0–100 scales |
      | General | Any count / duration / dose | ≥ 0 | negative impossible (except intentionally signed change scores) |
      
      (Extend per dataset from the codebook; keep the table version-controlled with the project.)
      
      ## 2. Cross-field logical-consistency rules
      
      Single-field bounds miss contradictions *between* fields. Flag a record when:
      
      - **Temporal ordering**: birth date ≤ index/event date ≤ death/last-seen date; admission ≤
        discharge; surgery date ≤ complication date; first ≤ last visit. Any future date beyond the
        extraction date (except scheduled appointments) is suspect (`cleaning_patterns.md` §4).
      - **Derived-vs-source**: recomputed BMI from height/weight matches the stored BMI (±rounding);
        age matches (event date − DOB); eGFR consistent with creatinine/age/sex if both stored;
        a "total" equals the sum of its parts; a subset count ≤ its superset count.
      - **Sex-specific / state-specific**: pregnancy, parity, or gestational fields present for
        `sex == male`; PSA recorded for females; menopause age < current age; pediatric-only or
        adult-only fields outside the eligible age.
      - **Mutually exclusive / dependency**: `deceased == no` with a death date populated;
        `smoking_status == never` with `pack_years > 0` (vs the structural-zero case where it is NULL —
        see SKILL Stage 2 item 7); treatment-stop date before treatment-start date.
      - **Range pair**: a "min" field exceeding its paired "max"; diastolic ≥ systolic.
      
      ## 3. Flagging discipline (no auto-fix)
      
      - Emit each violation as a **flag** in the Stage 2 table (`Issue Type = Implausible value` or
        `Cross-field inconsistency`), with the rule that fired, the count, and a **suggested** action —
        never an automatic edit (clean-data philosophy: researcher approves each action).
      - Severity: a compatible-with-life violation or a hard cross-field contradiction is **High**; a
        bound exceeded only under a special population (possible unit issue) is **Medium** pending unit
        confirmation.
      - Prefer **"unit-error" correction over deletion** when a unit mistake is the likely cause (a
        height of 1.75 in a cm column → ×100), but only after confirming the unit; otherwise set to
        missing and log. Record every decision in the cleaning log (`cleaning_patterns.md` §7).
      
      ## 4. Key reference
      
      - Van den Broeck J, et al. Data cleaning: detecting, diagnosing, and editing data abnormalities.
        *PLoS Med* 2005;2(10):e267. DOI: 10.1371/journal.pmed.0020267 (the diagnose-vs-edit framework
        this section operationalizes).
      
      ---
      
      *This reference is part of the clean-data skill for the medical-research-skills package. The
      bounds are error-screening defaults, not clinical reference ranges — verify against the codebook
      and study population.*
      
    • profiling_template.py 10.6 KB
      #!/usr/bin/env python3
      """
      Data Profiling Template for Clinical Research Datasets
      ======================================================
      
      Generates a structured profile of a CSV or Excel dataset.
      Outputs a summary table to the console and saves it as CSV.
      
      Usage:
          python profiling_template.py <file_path> [--output <output_dir>]
      
      Requirements:
          - pandas
          - numpy
          - matplotlib, seaborn (optional, for plots)
      
      This script does NOT modify the input data. It is read-only.
      """
      
      import argparse
      import os
      import sys
      import random
      from pathlib import Path
      
      import numpy as np
      import pandas as pd
      
      # Reproducibility
      np.random.seed(42)
      random.seed(42)
      
      
      # ---------------------------------------------------------------------------
      # 1. Data Loading
      # ---------------------------------------------------------------------------
      
      def load_data(file_path: str) -> pd.DataFrame:
          """Auto-detect CSV vs Excel and load into a DataFrame."""
          path = Path(file_path)
          ext = path.suffix.lower()
      
          if ext in (".csv", ".tsv"):
              sep = "\t" if ext == ".tsv" else ","
              df = pd.read_csv(path, sep=sep, low_memory=False)
          elif ext in (".xls", ".xlsx", ".xlsm"):
              df = pd.read_excel(path, engine="openpyxl")
          else:
              raise ValueError(f"Unsupported file format: {ext}. Use CSV, TSV, or Excel.")
      
          print(f"Loaded {len(df)} rows x {len(df.columns)} columns from {path.name}")
          return df
      
      
      # ---------------------------------------------------------------------------
      # 2. Variable Summary
      # ---------------------------------------------------------------------------
      
      def build_variable_summary(df: pd.DataFrame) -> pd.DataFrame:
          """Build a per-variable summary with type, missingness, and descriptive stats."""
          records = []
      
          for col in df.columns:
              series = df[col]
              n_missing = int(series.isna().sum())
              pct_missing = round(100 * n_missing / len(df), 2) if len(df) > 0 else 0.0
              n_unique = int(series.nunique(dropna=True))
              inferred_type = _infer_variable_type(series)
      
              rec = {
                  "variable": col,
                  "dtype": str(series.dtype),
                  "inferred_type": inferred_type,
                  "n_total": len(df),
                  "n_missing": n_missing,
                  "pct_missing": pct_missing,
                  "n_unique": n_unique,
                  "min": None,
                  "max": None,
                  "mean": None,
                  "median": None,
                  "sd": None,
              }
      
              # Numeric descriptive statistics
              if inferred_type == "numeric":
                  numeric = pd.to_numeric(series, errors="coerce")
                  rec["min"] = round(float(numeric.min()), 4) if numeric.notna().any() else None
                  rec["max"] = round(float(numeric.max()), 4) if numeric.notna().any() else None
                  rec["mean"] = round(float(numeric.mean()), 4) if numeric.notna().any() else None
                  rec["median"] = round(float(numeric.median()), 4) if numeric.notna().any() else None
                  rec["sd"] = round(float(numeric.std()), 4) if numeric.notna().any() else None
      
              records.append(rec)
      
          summary = pd.DataFrame(records)
          return summary
      
      
      def _infer_variable_type(series: pd.Series) -> str:
          """Heuristic type inference: numeric, categorical, datetime, or text."""
          if pd.api.types.is_numeric_dtype(series):
              return "numeric"
          if pd.api.types.is_datetime64_any_dtype(series):
              return "datetime"
      
          # Try to parse as numeric (catches numeric-stored-as-string)
          coerced = pd.to_numeric(series.dropna(), errors="coerce")
          if coerced.notna().sum() > 0.8 * series.dropna().shape[0]:
              return "numeric"
      
          # Try to parse as datetime
          try:
              parsed = pd.to_datetime(series.dropna(), infer_datetime_format=True, errors="coerce")
              if parsed.notna().sum() > 0.8 * series.dropna().shape[0]:
                  return "datetime"
          except Exception:
              pass
      
          # Categorical vs free text heuristic
          n_unique = series.nunique(dropna=True)
          n_rows = len(series.dropna())
          if n_rows > 0 and n_unique / n_rows < 0.05:
              return "categorical"
          if n_unique <= 20:
              return "categorical"
      
          return "text"
      
      
      # ---------------------------------------------------------------------------
      # 3. Flag Detection
      # ---------------------------------------------------------------------------
      
      def flag_missing(summary: pd.DataFrame, threshold: float = 5.0) -> pd.DataFrame:
          """Flag variables with missing percentage above the threshold."""
          flagged = summary[summary["pct_missing"] > threshold].copy()
          flagged["issue"] = "Missing > " + str(threshold) + "%"
          flagged["severity"] = flagged["pct_missing"].apply(
              lambda x: "High" if x > 30 else ("Medium" if x > 10 else "Low")
          )
          return flagged[["variable", "issue", "n_missing", "pct_missing", "severity"]]
      
      
      def flag_outliers_iqr(df: pd.DataFrame, summary: pd.DataFrame) -> pd.DataFrame:
          """Flag numeric variables with outliers using the IQR method."""
          results = []
          numeric_vars = summary[summary["inferred_type"] == "numeric"]["variable"].tolist()
      
          for col in numeric_vars:
              numeric = pd.to_numeric(df[col], errors="coerce").dropna()
              if len(numeric) < 10:
                  continue
              q1 = numeric.quantile(0.25)
              q3 = numeric.quantile(0.75)
              iqr = q3 - q1
              if iqr == 0:
                  continue
              lower = q1 - 1.5 * iqr
              upper = q3 + 1.5 * iqr
              outliers = numeric[(numeric < lower) | (numeric > upper)]
              if len(outliers) > 0:
                  results.append({
                      "variable": col,
                      "issue": f"Outlier (IQR): {len(outliers)} values outside [{lower:.2f}, {upper:.2f}]",
                      "count": len(outliers),
                      "severity": "Medium",
                  })
      
          return pd.DataFrame(results) if results else pd.DataFrame(
              columns=["variable", "issue", "count", "severity"]
          )
      
      
      # ---------------------------------------------------------------------------
      # 4. Distribution Plots (optional)
      # ---------------------------------------------------------------------------
      
      def plot_distributions(df: pd.DataFrame, summary: pd.DataFrame, output_dir: str):
          """Generate histograms for numeric and bar charts for categorical variables."""
          try:
              import matplotlib
              matplotlib.use("Agg")
              import matplotlib.pyplot as plt
              import seaborn as sns
          except ImportError:
              print("[INFO] matplotlib/seaborn not installed. Skipping distribution plots.")
              return
      
          plot_dir = os.path.join(output_dir, "profile_plots")
          os.makedirs(plot_dir, exist_ok=True)
      
          # Numeric histograms
          numeric_vars = summary[summary["inferred_type"] == "numeric"]["variable"].tolist()
          for col in numeric_vars[:20]:  # Limit to first 20 to avoid excessive plots
              fig, ax = plt.subplots(figsize=(6, 4))
              numeric = pd.to_numeric(df[col], errors="coerce").dropna()
              if len(numeric) == 0:
                  plt.close(fig)
                  continue
              ax.hist(numeric, bins=30, edgecolor="black", alpha=0.7)
              ax.set_title(f"Distribution: {col}")
              ax.set_xlabel(col)
              ax.set_ylabel("Frequency")
              fig.tight_layout()
              fig.savefig(os.path.join(plot_dir, f"hist_{col}.png"), dpi=100)
              plt.close(fig)
      
          # Categorical bar charts
          cat_vars = summary[summary["inferred_type"] == "categorical"]["variable"].tolist()
          for col in cat_vars[:20]:
              fig, ax = plt.subplots(figsize=(6, 4))
              counts = df[col].value_counts().head(15)
              counts.plot(kind="barh", ax=ax, color="steelblue", edgecolor="black")
              ax.set_title(f"Categories: {col}")
              ax.set_xlabel("Count")
              fig.tight_layout()
              fig.savefig(os.path.join(plot_dir, f"bar_{col}.png"), dpi=100)
              plt.close(fig)
      
          print(f"[INFO] Distribution plots saved to {plot_dir}/")
      
      
      # ---------------------------------------------------------------------------
      # 5. Report Output
      # ---------------------------------------------------------------------------
      
      def print_summary(summary: pd.DataFrame):
          """Print a formatted summary table to the console."""
          display_cols = [
              "variable", "inferred_type", "n_missing", "pct_missing",
              "n_unique", "min", "max", "mean", "median", "sd"
          ]
          print("\n" + "=" * 80)
          print("VARIABLE SUMMARY")
          print("=" * 80)
          print(summary[display_cols].to_string(index=False))
          print("=" * 80)
      
      
      def save_outputs(summary: pd.DataFrame, flags_missing: pd.DataFrame,
                       flags_outlier: pd.DataFrame, output_dir: str):
          """Save profiling results as CSV files."""
          os.makedirs(output_dir, exist_ok=True)
      
          summary_path = os.path.join(output_dir, "variable_summary.csv")
          summary.to_csv(summary_path, index=False)
          print(f"[SAVED] Variable summary -> {summary_path}")
      
          if len(flags_missing) > 0:
              missing_path = os.path.join(output_dir, "flags_missing.csv")
              flags_missing.to_csv(missing_path, index=False)
              print(f"[SAVED] Missing flags -> {missing_path}")
      
          if len(flags_outlier) > 0:
              outlier_path = os.path.join(output_dir, "flags_outliers.csv")
              flags_outlier.to_csv(outlier_path, index=False)
              print(f"[SAVED] Outlier flags -> {outlier_path}")
      
      
      # ---------------------------------------------------------------------------
      # Main
      # ---------------------------------------------------------------------------
      
      def main():
          parser = argparse.ArgumentParser(description="Profile a clinical research dataset.")
          parser.add_argument("file_path", help="Path to CSV or Excel file")
          parser.add_argument("--output", default="./data_profile",
                              help="Output directory for profiling results (default: ./data_profile)")
          parser.add_argument("--no-plots", action="store_true",
                              help="Skip distribution plots")
          args = parser.parse_args()
      
          # Load
          df = load_data(args.file_path)
      
          # Profile
          summary = build_variable_summary(df)
          print_summary(summary)
      
          # Flag
          flags_missing = flag_missing(summary, threshold=5.0)
          flags_outlier = flag_outliers_iqr(df, summary)
      
          if len(flags_missing) > 0:
              print("\n[FLAGS] Variables with >5% missing:")
              print(flags_missing.to_string(index=False))
      
          if len(flags_outlier) > 0:
              print("\n[FLAGS] Variables with IQR outliers:")
              print(flags_outlier.to_string(index=False))
      
          # Save
          save_outputs(summary, flags_missing, flags_outlier, args.output)
      
          # Plot (optional)
          if not args.no_plots:
              plot_distributions(df, summary, args.output)
      
          print("\n[DONE] Profiling complete. Review outputs before proceeding to cleaning.")
      
      
      if __name__ == "__main__":
          main()
      
  • scripts
    • check_reverse_coding.py 7.6 KB
      #!/usr/bin/env python3
      """Reverse-coded-item detector for multi-item scale reliability (clean-data Stage 2).
      
      A multi-item scale (Likert Trust/Satisfaction/Burden, etc.) mixes positively- and
      negatively-worded items. A negatively-worded ("reverse") item must be recoded
      `(min+max) - x` before the scale total or Cronbach's alpha is computed. When that
      recoding is skipped, the reverse item correlates negatively with the rest of the
      scale and Cronbach's alpha collapses — often turning *negative*. A negative alpha
      is almost never a real measurement phenomenon: it is a reverse-coding bug. Authors
      who instead defend it as "multidimensional structure" lose a review round (the
      motivating incident: a Trust scale shipped at alpha = -0.57 until one item was
      recoded, after which alpha = 0.58).
      
      This script flags reverse-code suspects *before* alpha is reported, so they are
      recoded at cleaning time rather than mis-explained at revision time.
      
      INPUTS
        --data        CSV with one row per respondent.
        --items       scale item columns (repeatable / space-separated). >= 2 required.
        --min         lowest point of the response scale (default 1).
        --max         highest point of the response scale (default: inferred per item
                      from the observed maximum across all items).
        --threshold   item-rest correlation at or below which an item is a reverse-code
                      suspect (default 0.0 — i.e. a negative item-rest correlation).
      
      OUTPUT  (per scale)
        - alpha_raw            Cronbach's alpha on the items AS-GIVEN (un-recoded)
        - per item: item_rest_r (corrected item-total correlation)
        - suspects             items with item_rest_r <= threshold
        - verdict:
            REVERSE_CODING_LIKELY   alpha_raw < 0           (recode then re-run)
            REVERSE_CODING_SUSPECT  suspects present, alpha_raw >= 0
            OK                      no suspects, alpha_raw >= 0
        Exit 1 (with --strict) if verdict != OK.
      
      Stdlib-only (csv / json / argparse / math / statistics). Exit codes: 0 clean
      (or report-only), 1 reverse-coding flagged (with --strict), 2 input/usage error.
      """
      
      from __future__ import annotations
      
      import argparse
      import csv
      import json
      import math
      import sys
      from pathlib import Path
      
      NULLISH = {"", "na", "n/a", "nan", "null", "none", ".", "missing"}
      
      
      def _to_float(raw: str):
          if raw is None:
              return None
          s = raw.strip()
          if s.lower() in NULLISH:
              return None
          try:
              return float(s)
          except ValueError:
              return None
      
      
      def _pearson(xs: list[float], ys: list[float]):
          """Pearson r over paired complete observations. None if undefined."""
          n = len(xs)
          if n < 2:
              return None
          mx = sum(xs) / n
          my = sum(ys) / n
          sxx = sum((x - mx) ** 2 for x in xs)
          syy = sum((y - my) ** 2 for y in ys)
          if sxx <= 0 or syy <= 0:        # a constant column has no correlation
              return None
          sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
          return sxy / math.sqrt(sxx * syy)
      
      
      def _variance(vals: list[float]):
          n = len(vals)
          if n < 2:
              return 0.0
          m = sum(vals) / n
          return sum((v - m) ** 2 for v in vals) / (n - 1)
      
      
      def cronbach_alpha(rows: list[list[float]]):
          """rows = list of complete item vectors (one per respondent). None if undefined."""
          if not rows:
              return None
          k = len(rows[0])
          if k < 2:
              return None
          item_vars = [_variance([r[j] for r in rows]) for j in range(k)]
          totals = [sum(r) for r in rows]
          total_var = _variance(totals)
          if total_var <= 0:
              return None
          return (k / (k - 1.0)) * (1.0 - sum(item_vars) / total_var)
      
      
      def analyze(data_path: Path, items: list[str], scale_min: float,
                  scale_max, threshold: float) -> dict:
          with data_path.open(newline="", encoding="utf-8-sig") as fh:
              reader = csv.DictReader(fh)
              header = reader.fieldnames or []
              missing = [c for c in items if c not in header]
              if missing:
                  raise SystemExit(f"USAGE-ERR: item columns not in CSV: {missing}")
              raw_rows = list(reader)
      
          # Listwise-complete matrix (drop any respondent missing >=1 item) — matches
          # the default complete-case alpha so the flag mirrors the reported alpha.
          complete: list[list[float]] = []
          n_total = len(raw_rows)
          for row in raw_rows:
              vec = [_to_float(row.get(c)) for c in items]
              if all(v is not None for v in vec):
                  complete.append(vec)  # type: ignore[arg-type]
      
          n_used = len(complete)
          alpha_raw = cronbach_alpha(complete) if n_used >= 2 else None
      
          # Per-item corrected item-total (item-rest) correlation.
          per_item = []
          suspects = []
          k = len(items)
          for j, name in enumerate(items):
              item_rest_r = None
              if n_used >= 2 and k >= 2:
                  xs = [r[j] for r in complete]
                  rest = [sum(r[t] for t in range(k) if t != j) for r in complete]
                  item_rest_r = _pearson(xs, rest)
              flagged = item_rest_r is not None and item_rest_r <= threshold
              if flagged:
                  suspects.append(name)
              per_item.append({
                  "item": name,
                  "item_rest_r": None if item_rest_r is None else round(item_rest_r, 3),
                  "reverse_suspect": bool(flagged),
              })
      
          if alpha_raw is not None and alpha_raw < 0:
              verdict = "REVERSE_CODING_LIKELY"
          elif suspects:
              verdict = "REVERSE_CODING_SUSPECT"
          else:
              verdict = "OK"
      
          return {
              "items": items,
              "n_total": n_total,
              "n_complete": n_used,
              "scale_min": scale_min,
              "scale_max": scale_max,  # echoed only; flag is correlation-based
              "threshold": threshold,
              "alpha_raw": None if alpha_raw is None else round(alpha_raw, 3),
              "per_item": per_item,
              "suspects": suspects,
              "verdict": verdict,
              "recode_hint": (
                  None if verdict == "OK"
                  else "Recode suspect items as (min+max)-x, then recompute alpha "
                       "(see ~/.claude/rules/survey-scale-reliability.md)."
              ),
          }
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Reverse-coded-item / negative-alpha detector.")
          ap.add_argument("--data", required=True)
          ap.add_argument("--items", nargs="+", required=True)
          ap.add_argument("--min", type=float, default=1.0, dest="scale_min")
          ap.add_argument("--max", type=float, default=None, dest="scale_max")
          ap.add_argument("--threshold", type=float, default=0.0)
          ap.add_argument("--out", default=None, help="write JSON report to this path")
          ap.add_argument("--strict", action="store_true",
                          help="exit 1 if any reverse-coding is flagged")
          args = ap.parse_args()
      
          if len(args.items) < 2:
              print("USAGE-ERR: need >= 2 scale items", file=sys.stderr)
              return 2
      
          data_path = Path(args.data)
          if not data_path.is_file():
              print(f"USAGE-ERR: data file not found: {data_path}", file=sys.stderr)
              return 2
      
          report = analyze(data_path, args.items, args.scale_min, args.scale_max, args.threshold)
      
          if args.out:
              Path(args.out).write_text(json.dumps({"detector": "check_reverse_coding", **report}, indent=2), encoding="utf-8")
      
          a = report["alpha_raw"]
          print(f"Scale items: {', '.join(report['items'])}  (n={report['n_complete']} complete)")
          print(f"Cronbach's alpha (as-given): {a}")
          for it in report["per_item"]:
              mark = "  <-- reverse-code suspect" if it["reverse_suspect"] else ""
              print(f"  item-rest r  {it['item']:<16} {it['item_rest_r']}{mark}")
          print(f"VERDICT: {report['verdict']}")
          if report["recode_hint"]:
              print(f"  {report['recode_hint']}")
      
          if args.strict and report["verdict"] != "OK":
              return 1
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • check_structural_zero.py 6.5 KB
      #!/usr/bin/env python3
      """Categorical-implied-zero (structural-zero) detector for clean-data Stage 2.
      
      A dose/duration variable anchored to a categorical exposure has a known zero at
      the reference level: a never-smoker's pack-years is 0 *by definition*, not
      missing. When that implied zero is instead stored as NULL/blank, two downstream
      failures follow — a complete-case model silently drops the whole unexposed
      stratum, or MICE imputes a non-zero dose for people who have no exposure — both
      of which corrupt the exposure contrast. This script finds those contradiction
      rows so they are fixed at cleaning time (set 0 at the reference level; impute
      only the residual missingness among the exposed).
      
      INPUTS
        --data            CSV with one row per subject.
        --category-col    categorical exposure column (e.g. smoking_status).
        --reference-level value of the category that implies a zero dose (e.g. never).
        --dose-col        dose/duration column that should be 0 at the reference level
                          (e.g. pack_years). Repeatable for several dose columns.
      
      OUTPUT
        Per (reference-level, dose) pair: counts of
          - implied_zero_missing : category == reference AND dose is NULL/blank   (FIX)
          - implied_zero_nonzero : category == reference AND dose > 0  (mislabeled — review)
          - implied_zero_ok      : category == reference AND dose == 0
        Exit 1 (with --strict) if any implied_zero_missing rows exist.
      
      Stdlib-only (csv / json / argparse). Exit codes: 0 clean (or report-only),
      1 contradiction rows found (with --strict), 2 input/usage error.
      """
      
      from __future__ import annotations
      
      import argparse
      import csv
      import json
      import sys
      from pathlib import Path
      
      NULLISH = {"", "na", "n/a", "nan", "null", "none", ".", "missing"}
      
      
      def _is_null(v: str) -> bool:
          return v.strip().lower() in NULLISH
      
      
      def _to_float(v: str):
          try:
              return float(v.strip())
          except (ValueError, AttributeError):
              return None
      
      
      def _norm(s: str) -> str:
          return s.strip().lower()
      
      
      def analyze(data: str, category_col: str, reference: str, dose_cols: list[str]) -> dict:
          p = Path(data)
          if not p.is_file():
              sys.stderr.write(f"ERROR: data file not found: {data}\n")
              sys.exit(2)
          with p.open(encoding="utf-8-sig", newline="") as f:
              reader = csv.DictReader(f)
              if reader.fieldnames is None:
                  sys.stderr.write("ERROR: empty CSV\n")
                  sys.exit(2)
              fields = {_norm(c): c for c in reader.fieldnames}
              cat_key = fields.get(_norm(category_col))
              if cat_key is None:
                  sys.stderr.write(f"ERROR: category column '{category_col}' not in {reader.fieldnames}\n")
                  sys.exit(2)
              dose_keys = {}
              for d in dose_cols:
                  k = fields.get(_norm(d))
                  if k is None:
                      sys.stderr.write(f"ERROR: dose column '{d}' not in {reader.fieldnames}\n")
                      sys.exit(2)
                  dose_keys[d] = k
              rows = list(reader)
      
          ref = _norm(reference)
          results = []
          total_missing = 0
          for d, dkey in dose_keys.items():
              miss = nonzero = ok = ref_n = 0
              for r in rows:
                  if _norm(r.get(cat_key, "")) != ref:
                      continue
                  ref_n += 1
                  raw = r.get(dkey, "")
                  if _is_null(raw):
                      miss += 1
                  else:
                      val = _to_float(raw)
                      if val is None:
                          continue            # non-numeric, not our concern here
                      if val > 0:
                          nonzero += 1
                      else:
                          ok += 1
              total_missing += miss
              results.append({
                  "dose_col": d,
                  "reference_level": reference,
                  "reference_n": ref_n,
                  "implied_zero_missing": miss,
                  "implied_zero_nonzero": nonzero,
                  "implied_zero_ok": ok,
                  "verdict": "FIX_STRUCTURAL_ZERO" if miss else ("REVIEW_MISLABEL" if nonzero else "OK"),
              })
      
          return {
              "data": str(p),
              "category_col": category_col,
              "n_rows": len(rows),
              "results": results,
              "total_implied_zero_missing": total_missing,
              "suggested_fix": (
                  f"Set dose = 0 where {category_col} == '{reference}' (structural zero), "
                  "then impute only the residual missingness among the exposed."
              ) if total_missing else None,
          }
      
      
      def render(result: dict) -> str:
          lines = [
              "| Dose col | ref n | implied-zero MISSING | nonzero (mislabel) | zero (ok) | Verdict |",
              "|---|---|---|---|---|---|",
          ]
          for r in result["results"]:
              mark = {"FIX_STRUCTURAL_ZERO": "✗ Fix", "REVIEW_MISLABEL": "△ Review", "OK": "✓"}[r["verdict"]]
              lines.append(
                  f"| {r['dose_col']} | {r['reference_n']} | {r['implied_zero_missing']} | "
                  f"{r['implied_zero_nonzero']} | {r['implied_zero_ok']} | {mark} |"
              )
          return "\n".join(lines)
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Categorical-implied-zero (structural-zero) detector.")
          ap.add_argument("--data", required=True, help="subject-level CSV")
          ap.add_argument("--category-col", required=True, help="categorical exposure column")
          ap.add_argument("--reference-level", required=True, help="category value implying a zero dose")
          ap.add_argument("--dose-col", required=True, action="append",
                          help="dose/duration column (repeatable)")
          ap.add_argument("--out", help="write JSON artifact to this path")
          ap.add_argument("--strict", action="store_true", help="exit 1 if any implied-zero-missing rows")
          args = ap.parse_args()
      
          result = analyze(args.data, args.category_col, args.reference_level, args.dose_col)
      
          print("=" * 41)
          print(" Categorical-Implied-Zero (structural zero)")
          print("=" * 41)
          print(f"category: {args.category_col} == '{args.reference_level}'")
          print(render(result))
          print()
          if result["total_implied_zero_missing"]:
              print(f"FIX: {result['total_implied_zero_missing']} reference-level row(s) store the implied "
                    f"zero as missing.")
              print(result["suggested_fix"])
          else:
              print("OK: no categorical-implied-zero stored as missing.")
      
          if args.out:
              Path(args.out).parent.mkdir(parents=True, exist_ok=True)
              Path(args.out).write_text(json.dumps({"detector": "check_structural_zero", **result}, indent=2), encoding="utf-8")
              print(f"\nwrote {args.out}")
      
          return 1 if (args.strict and result["total_implied_zero_missing"]) else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • tests
    • fixtures
      • scale_reverse.csv 133 B · in bundle
      • smoking.csv 103 B · in bundle
    • test_reverse_coding.sh 2.4 KB
      #!/usr/bin/env bash
      # Regression test for the reverse-coded-item / negative-alpha detector.
      # Synthetic fixture: a 3-item scale where E3 is reverse-worded (stored 6-x) so the
      # raw alpha is negative and E3 has a negative item-rest correlation; plus a clean
      # 3-item scale (G1-G3) that should pass. Stdlib-only (python3).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/check_reverse_coding.py"
      FIXTURE="$HERE/fixtures/scale_reverse.csv"
      OUT="$(mktemp -t rc_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
      }
      
      [[ -f "$SCRIPT" ]]  || { echo "ENV-ERR: script missing"  >&2; exit 2; }
      [[ -f "$FIXTURE" ]] || { echo "ENV-ERR: fixture missing" >&2; exit 2; }
      
      # Reverse-coded scale: must flag under --strict (exit 1) and write the report.
      python3 "$SCRIPT" --data "$FIXTURE" --items E1 E2 E3 --out "$OUT" --strict >/dev/null 2>&1
      check "exit 1 under --strict (reverse coding present)" test "$?" -eq 1
      check "JSON artifact written" test -s "$OUT"
      
      check "verdict REVERSE_CODING_LIKELY" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['verdict']=='REVERSE_CODING_LIKELY', d['verdict']"
      check "alpha_raw is negative" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['alpha_raw'] is not None and d['alpha_raw']<0, d['alpha_raw']"
      check "E3 is the sole suspect" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['suspects']==['E3'], d['suspects']"
      check "E3 item-rest r is negative" python3 -c "
      import json; d=json.load(open('$OUT'))
      r={it['item']:it['item_rest_r'] for it in d['per_item']}
      assert r['E3']<0 and r['E1']>0 and r['E2']>0, r"
      check "n_complete == 8" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['n_complete']==8, d['n_complete']"
      
      # Clean scale: no reverse item -> verdict OK, exit 0 under --strict.
      python3 "$SCRIPT" --data "$FIXTURE" --items G1 G2 G3 --out "$OUT" --strict >/dev/null 2>&1
      check "exit 0 on a clean (aligned) scale" test "$?" -eq 0
      check "verdict OK on clean scale" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['verdict']=='OK' and not d['suspects'], d"
      
      # Usage guard: a single item is not a scale.
      python3 "$SCRIPT" --data "$FIXTURE" --items E1 >/dev/null 2>&1
      check "exit 2 on <2 items (usage error)" test "$?" -eq 2
      
      echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
      exit "$fail"
      
    • test_structural_zero.sh 2.1 KB
      #!/usr/bin/env bash
      # Regression test for the categorical-implied-zero (structural-zero) detector.
      # Synthetic fixture: never-smokers with NULL pack-years (the bug), one with an
      # explicit 0 (ok), one mislabeled with a positive dose. Stdlib-only (python3).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/check_structural_zero.py"
      FIXTURE="$HERE/fixtures/smoking.csv"
      OUT="$(mktemp -t sz_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
      }
      
      [[ -f "$SCRIPT" ]]  || { echo "ENV-ERR: script missing"  >&2; exit 2; }
      [[ -f "$FIXTURE" ]] || { echo "ENV-ERR: fixture missing" >&2; exit 2; }
      
      python3 "$SCRIPT" --data "$FIXTURE" --category-col smoking_status \
          --reference-level never --dose-col pack_years --out "$OUT" --strict >/dev/null 2>&1
      check "exit 1 under --strict (implied-zero missing present)" test "$?" -eq 1
      check "JSON artifact written" test -s "$OUT"
      
      check "implied_zero_missing == 2" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['total_implied_zero_missing']==2, d['total_implied_zero_missing']"
      check "implied_zero_nonzero (mislabel) == 1" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['results'][0]['implied_zero_nonzero']==1"
      check "implied_zero_ok == 1" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['results'][0]['implied_zero_ok']==1"
      check "reference_n == 4 (never-smokers)" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['results'][0]['reference_n']==4"
      check "verdict FIX_STRUCTURAL_ZERO" python3 -c "
      import json; d=json.load(open('$OUT'))
      assert d['results'][0]['verdict']=='FIX_STRUCTURAL_ZERO'"
      
      # Clean case: a reference level with no missing dose -> exit 0 under --strict.
      python3 "$SCRIPT" --data "$FIXTURE" --category-col smoking_status \
          --reference-level former --dose-col pack_years --strict >/dev/null 2>&1
      check "exit 0 when reference level has no missing dose" test "$?" -eq 0
      
      echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
      exit "$fail"
      
  • SKILL.md 11.1 KB
    ---
    name: clean-data
    description: Interactive data profiling and cleaning assistant for medical research. Three-stage workflow (profile, flag, code-generate) with user approval gates at each step. Handles missing values, outliers, duplicates, and type mismatches in CSV/Excel clinical data. Does NOT auto-clean — all decisions require researcher confirmation.
    triggers: clean data, data cleaning, data preprocessing, data profiling, missing values, outliers, check my data, data quality
    tools: Read, Write, Edit, Bash, Grep, Glob
    model: inherit
    ---
    
    # Data Profiling and Cleaning Skill
    
    You are assisting a medical researcher with data profiling and cleaning for clinical datasets.
    This is a three-stage interactive workflow. You generate code and reports -- you do NOT
    auto-clean data. Every cleaning decision requires explicit researcher confirmation.
    
    ## Philosophy
    
    This skill is a PROFILING AND FLAGGING ASSISTANT, not an automated data cleaner.
    Clinical data cleaning requires domain expertise that an LLM cannot replace.
    Every cleaning decision must be confirmed by the researcher.
    
    **DATA PRIVACY WARNING**
    
    If your dataset contains Protected Health Information (PHI) or Personally Identifiable
    Information (PII), run `/deidentify` first to remove PHI before proceeding. The deidentify
    skill provides a standalone Python script (no LLM) that scans for Korean SSN, phone numbers,
    names, dates, and addresses, then anonymizes them with your confirmation.
    
    If `*_deidentified.*` files exist in the working directory, use those instead of raw data.
    
    Alternatively:
    1. Provide only the data dictionary / codebook for profiling guidance
    2. Or use a local-only environment with no network access
    
    This tool generates CODE that runs on your data -- it does not need to see the raw data
    to generate useful profiling scripts.
    
    ## Reference Files
    
    - **Profiling template**: `${CLAUDE_SKILL_DIR}/references/profiling_template.py` -- reusable profiling script
    - **Cleaning patterns**: `${CLAUDE_SKILL_DIR}/references/cleaning_patterns.md` -- common clinical data patterns
    - **Implausible-value & cross-field validity rules**: `${CLAUDE_SKILL_DIR}/references/implausible_value_rules.md` -- domain-default hard physiologic bounds (per organ system) + cross-field logical-consistency rules for Stage 2 flagging when the codebook is silent (error-screening, not reference ranges; flag, never auto-fix)
    
    Read relevant references before generating profiling or cleaning code.
    
    ## Three-Stage Workflow
    
    ### Stage 1: Profiling
    
    **Input**: CSV/Excel file path OR data dictionary/codebook
    
    **Actions**:
    
    1. Generate a Python profiling script (pandas-based) that produces:
       - Variable count, row count, data types
       - Missing value count and percentage per variable
       - Unique value counts for categorical variables
       - Min/max/mean/median/SD for numeric variables
       - Distribution plots (histograms for numeric, bar charts for categorical)
    2. If user provides a codebook: cross-reference variable names, expected types, expected ranges
    3. Present summary table to user
    
    Use `${CLAUDE_SKILL_DIR}/references/profiling_template.py` as the base script. Adapt it to
    the specific dataset structure.
    
    **Gate**: User reviews profiling output before proceeding. Ask:
    > "Here is the profiling summary. Would you like to proceed to Stage 2 (Flagging)?
    > Are there any variables you want to exclude or focus on?"
    
    ### Stage 2: Flagging
    
    Based on profiling results, flag potential issues in these categories:
    
    1. **Missing values**: Variables with >5% missing, pattern analysis (MCAR/MAR/MNAR heuristic)
    2. **Statistical outliers**: IQR method (Q1 - 1.5*IQR, Q3 + 1.5*IQR) and Z-score (|z| > 3)
    3. **Duplicates**: Exact row duplicates AND near-duplicates (same patient ID, different dates)
    4. **Type mismatches**: Numeric stored as string, dates in inconsistent formats
    5. **Implausible values**: Use the codebook's valid range when provided; when the codebook is silent, apply the domain-default hard physiologic bounds in `references/implausible_value_rules.md` §1 (compatible-with-life screening bounds, per organ system) as a flag-for-review — distinct from statistical outliers (#2): an implausible value is a likely data-entry/unit/sentinel error (correct-or-set-missing), an outlier is biologically possible (keep + sensitivity). Check units before calling a bound violation an error. Never auto-fix.
    5b. **Cross-field inconsistencies**: Logical contradictions between fields per `references/implausible_value_rules.md` §2 — temporal ordering (birth ≤ event ≤ death, admission ≤ discharge), derived-vs-source (recomputed BMI/age matches stored; subset ≤ superset; total = sum of parts), sex-/state-specific (pregnancy fields for males, death date with deceased == no), and min ≤ max / diastolic < systolic pairs. Flag with the rule that fired; High severity for a hard contradiction.
    6. **Category inconsistencies**: Typos in categorical values (e.g., "Male", "male", "M", "MALE")
    7. **Categorical-implied zeros**: When a categorical variable defines a natural zero for a dose/duration variable (`smoking_status == 'never'` implies `pack_years == 0`, `alcohol_use == 'never'` implies `grams_per_week == 0`), flag any record where the implied zero is stored as NULL/missing instead of 0. This is a *contradiction*, not a missing-data pattern: a never-smoker with `pack_years = NULL` will be silently dropped by complete-case models or, worse, imputed to a non-zero dose by MICE — corrupting the exposure contrast. Suggested action: "Set dose = 0 where category == reference level; impute only the residual missingness among the exposed." Detected by `scripts/check_structural_zero.py` given the category↔dose mapping; pairs with `/analyze-stats` "Covariate Pitfalls: Structural Zeros & Dose/Duration Variables".
    
    8. **Reverse-coded scale items**: When a multi-item Likert scale (Trust, Satisfaction, Burden, etc.) mixes positively- and negatively-worded items, every negatively-worded ("reverse") item must be recoded `(min+max) - x` *before* the scale total or Cronbach's alpha is computed. A reverse item left un-recoded correlates negatively with the rest of the scale and collapses alpha — often turning it **negative**. A negative alpha is almost never a real measurement phenomenon; it is a reverse-coding bug, and defending it as "multidimensional structure" loses a review round. Suggested action: "Recode reverse-worded items, then recompute reliability." Detected by `scripts/check_reverse_coding.py` (flags items with a negative item-rest correlation and a negative raw alpha, given the scale item columns); the recode itself is applied downstream by `/analyze-stats` `likert_summary.py --reverse-items`. Pairs with the global rule `survey-scale-reliability.md`.
    
    Present the flag report as a structured table:
    
    | Variable | Issue Type | Count | Severity | Suggested Action |
    |----------|-----------|-------|----------|-----------------|
    | age | Outlier (IQR) | 3 | Medium | Review: values 150, 200, -5 |
    | sex | Category inconsistency | 12 | Low | Harmonize: Male/male/M -> "Male" |
    | lab_date | Type mismatch | 45 | High | Parse to datetime |
    | pack_years | Categorical-implied zero | 12421 | High | Set 0 where smoking_status=='never' (structural zero, not missing) |
    | scale_item_4 | Reverse-worded item (raw α negative) | n/a | High | Recode (6 - x) before reliability; a negative α is a coding bug, not a finding |
    
    Severity levels:
    - **High**: Likely data errors that will affect analysis (type mismatches, impossible values)
    - **Medium**: Potential issues that need expert review (statistical outliers, moderate missingness)
    - **Low**: Minor inconsistencies that are easy to fix (category labels, trailing whitespace)
    
    **Gate**: User reviews flags and approves/rejects each suggested action. Ask:
    > "Please review the flagged issues above. For each row, indicate:
    > (A) Approve the suggested action, (R) Reject / keep as-is, or (M) Modify the action.
    > Only approved actions will generate cleaning code."
    
    ### Stage 3: Code Generation
    
    For ONLY user-approved cleaning actions, generate Python (or R if requested) code:
    
    - **Missing value handling**: Listwise deletion, mean/median imputation, or MICE setup (code only, user runs)
    - **Outlier handling**: Winsorization, removal, or keep-and-flag
    - **Duplicate removal**: Exact dedup with logging
    - **Type conversion**: Standardize dates, numeric parsing
    - **Category harmonization**: Mapping table for inconsistent labels
    
    All generated code MUST include:
    - Before/after row counts printed to console
    - Logging of every modification to a cleaning log DataFrame
    - Reproducibility: `np.random.seed(42)` and `random.seed(42)` where applicable
    - Output: cleaned CSV + `cleaning_log.csv`
    - Clear comments explaining each cleaning step
    
    End the generated script with this notice:
    > "This code implements ONLY the cleaning rules you approved. Review the cleaning_log.csv
    > output to verify all changes before proceeding to analysis."
    
    ## Scope Limitations
    
    **Supported**:
    - Missing values (detection, simple imputation code, MICE setup)
    - Outliers (statistical detection via IQR and Z-score)
    - Duplicates (exact and near-duplicate detection)
    - Type mismatches (numeric parsing, date standardization)
    - Category harmonization (case, abbreviation, whitespace)
    
    **NOT supported**:
    - Domain-specific plausible ranges (unless codebook provided)
    - Complex imputation strategy selection (MICE setup only, user picks variables/method)
    - Natural language extraction from clinical notes
    - Image data cleaning or DICOM metadata
    - Automated decisions -- all cleaning requires researcher approval
    
    > This tool flags issues. Final cleaning decisions require your domain knowledge.
    
    ## Cross-Skill Integration
    
    - **clean-data** sits BEFORE `analyze-stats` in the research pipeline
    - `design-study` can inform which variables to focus profiling on
    - `manage-project` tracks overall project state including data cleaning status
    - After cleaning, hand off to `analyze-stats` for statistical analysis
    
    ## Output Format
    
    Structure all reports using this template:
    
    ```
    ## Data Profiling Report
    
    ### Dataset Overview
    - Rows: [N]
    - Columns: [N]
    - File size: [size]
    - Date range: [if applicable]
    
    ### Variable Summary
    | Variable | Type | Missing N (%) | Unique | Min | Max | Mean | SD |
    |----------|------|---------------|--------|-----|-----|------|-----|
    | ...      | ...  | ...           | ...    | ... | ... | ...  | ... |
    
    ### Flags
    | Variable | Issue | Count | Severity | Suggested Action |
    |----------|-------|-------|----------|-----------------|
    | ...      | ...   | ...   | ...      | ...             |
    
    ### Cleaning Code
    [Python/R script -- only for approved actions]
    
    ### Cleaning Log
    [What was changed, how many rows affected, before/after counts]
    ```
    
    ## Anti-Hallucination
    
    - **Never fabricate variable names, dataset column names, or variable codings.** If a variable mapping is uncertain, output `[VERIFY: variable_name]` and ask the user to confirm against the data dictionary.
    - **Never fabricate statistical results** — no invented p-values, effect sizes, confidence intervals, or sample sizes. All numbers must come from executed code output.
    - **Never generate references from memory.** Use `/search-lit` for all citations.
    - If a function, package, or API does not exist or you are unsure, say so explicitly rather than guessing.
    
  • skill.yml 1.3 KB
    schema_version: 2
    name: clean-data
    layer: B
    owner_domain: data_preparation
    maturity: official
    
    when_to_use: "Profile and clean clinical CSV/Excel data through a three-stage workflow with user approval at each gate."
    when_NOT_to_use: "Removing PHI (use deidentify); statistical analysis (use analyze-stats)."
    
    inputs:
      - "raw analysis dataset (CSV/Excel)"
    outputs:
      - "data profile report"
      - "cleaning code"
      - "cleaned dataset"
    side_effects:
      - writes_project_artifacts
    downstream_consumers:
      - analyze-stats
      - version-dataset
    forbidden_actions:
      - auto_clean_without_user_approval
      - drop_rows_or_impute_silently
    
    # v2.1 quality card
    purpose: "Profile, flag, and code-generate cleaning steps for clinical tabular data, with a researcher approval gate at every stage."
    safety_boundaries:
      - "Never auto-cleans; every decision (missing values, outliers, dups, types) requires user confirmation."
      - "All transforms are emitted as reviewable code, not applied silently."
    known_limitations:
      - "Heuristic flags need clinical judgement; the skill does not decide what is a true outlier."
      - "No standalone demo; correctness depends on user approvals."
    validation_commands:
      - "re-run the emitted cleaning code and re-profile"
      - "/version-dataset for a manifest"
    evidence_surface: manual_workflow
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related