Install any skill in seconds. Free to start, no credit card required.
Get Started Free →End-to-end observational epidemiology analysis — from research question (PECO Population/Exposure/Comparator/Outcome) to publication-ready statistical report. Covers cohort/case-control/cross-sectional design, regression with confounders, propensity scoring, sensitivity analysis. Writes Python code for every step. Use for epidemiology study analysis, NHANES/UK-Biobank-style analyses.
.claude/skills/tooluniverse-epidemiological-analysis/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-08 | ✗→✓ | ▲ Improved | — | — |
| case-17 | ✗→✓ | ▲ Improved | — | — |
| case-19 | ✗→✓ | ▲ Improved | — | — |
| case-09 | ✓→✓ | = Same ✓ | — | — |
| case-22 | ✗→✗ | = Same ✗ | — | — |
Complete workflow for observational epidemiology — from research question to publication-ready report. Write and run Python code for every step. Never describe what you "would do" — do it.
Define Population, Exposure, Comparator, Outcome before touching data.
Study design check: Does the question require temporality?
If the question implies causation ("does X cause Y?") but only cross-sectional data is available, state the limitation explicitly and proceed with association language.
Use ToolUniverse to discover datasets and find what prior studies used:
python# Search for relevant datasets — use find_tools to discover what's available find_tools("dataset search") find_tools("your domain keywords") # e.g., "cancer genomics", "clinical trial", "survey health" # Search literature for study precedents — papers cite their data sources execute_tool("PubMed_search_articles", {"query": "[exposure] [outcome] [study design]", "max_results": 5}) execute_tool("EuropePMC_search_articles", {"query": "[exposure] [outcome] cohort", "limit": 5})
Evaluate dataset fitness: Does it have the exposure variable? The outcome? Key confounders (age, sex, plus domain-specific)? Adequate sample size?
Power analysis (run before committing to a dataset):
pythonfrom scipy.stats import norm import numpy as np def sample_size_logistic(p0, OR, alpha=0.05, power=0.80): """Minimum N for logistic regression detecting OR at given power.""" p1 = (p0 * OR) / (1 - p0 + p0 * OR) z_a, z_b = norm.ppf(1 - alpha/2), norm.ppf(power) n = ((z_a + z_b)**2 * (1/(p0*(1-p0)) + 1/(p1*(1-p1)))) / (np.log(OR))**2 return int(np.ceil(n)) print(f"Need N={sample_size_logistic(0.10, 1.5)} for OR=1.5 with 10% baseline prevalence")
Download data programmatically. Adapt the loading code to your data source's format.
pythonimport pandas as pd import requests, io # Generic download helper — adapt URL and format to your source def download_and_parse(url, fmt="csv"): r = requests.get(url, timeout=120) content = io.BytesIO(r.content) if fmt == "xpt": return pd.read_sas(content, format="xport") elif fmt == "csv": return pd.read_csv(content) elif fmt == "tsv": return pd.read_csv(content, sep="\t") elif fmt == "stata": return pd.read_stata(content) elif fmt == "json": return pd.read_json(content) else: return pd.read_csv(content) # default fallback # Load and merge multiple files on shared ID column df1 = download_and_parse(url1, fmt="xpt") df2 = download_and_parse(url2, fmt="xpt") df = df1.merge(df2, on="id_col", how="inner") # Filter population (inclusion/exclusion criteria) df = df[(df['age'] >= 20) & (df['age'] < 80)] # Handle missing data missing_pct = df.isnull().mean() * 100 print("Missing % per variable:\n", missing_pct[missing_pct > 0].sort_values(ascending=False)) # Decision: complete case if <5% missing; multiple imputation if 5-20%; drop variable if >20% # Variable coding (adapt to your data) df['age_group'] = pd.cut(df['age'], bins=[20,40,60,80], labels=['20-39','40-59','60-79']) df['outcome_binary'] = (df['outcome_continuous'] >= threshold).astype(int)
Survey weights: Some surveys (NHANES, BRFSS, MEPS) require sampling weights for valid inference. Check the survey documentation. For weighted regression, use statsmodels.stats.weightstats or linearmodels.
International/LMIC population health: for a PECO question outside the US, DHSProgram_search_indicators/DHSProgram_get_data covers the same kind of national survey indicators (fertility, maternal/child mortality, nutrition, immunization, HIV) across many countries, filterable by country and survey year — the NHANES/BRFSS-style source when the population isn't the US.
REST API data: For sources like GDC (TCGA), ClinicalTrials.gov, or OpenTargets, paginate through the API:
pythonall_records = [] offset = 0 while True: resp = requests.get(f"{api_url}?offset={offset}&limit=500", timeout=30) batch = resp.json().get("data", []) if not batch: break all_records.extend(batch) offset += len(batch) df = pd.DataFrame(all_records)
python# Table 1: mean +/- SD for continuous, N(%) for categorical, by exposure group continuous_vars = ['age', 'bmi'] # adapt to your variables for var in continuous_vars: print(df.groupby('exposure_group')[var].agg(['mean', 'std', 'count'])) categorical_vars = ['sex', 'race'] # adapt to your variables for var in categorical_vars: print(pd.crosstab(df['exposure_group'], df[var], normalize='index') * 100)
Check distributions: df[var].skew(), scipy.stats.shapiro(), histograms for outliers.
Sequential adjustment strategy (build evidence for confounding):
pythonimport statsmodels.formula.api as smf import numpy as np # Model 1: Unadjusted m1 = smf.logit('outcome ~ exposure', data=df).fit(disp=0) # Model 2: + demographics m2 = smf.logit('outcome ~ exposure + age + sex + race', data=df).fit(disp=0) # Model 3: + clinical factors m3 = smf.logit('outcome ~ exposure + age + sex + race + bmi + smoking + alcohol', data=df).fit(disp=0) # Report ORs with 95% CI for name, model in [('Unadjusted', m1), ('Demographics', m2), ('Fully adjusted', m3)]: or_val = np.exp(model.params['exposure']) ci = np.exp(model.conf_int().loc['exposure']) print(f"{name}: OR={or_val:.2f} (95% CI: {ci[0]:.2f}-{ci[1]:.2f}), p={model.pvalues['exposure']:.4f}")
Model selection by outcome type:
smf.ols()smf.logit()OrderedModel from statsmodelsCoxPHFitter from lifelinessmf.poisson() or smf.negativebinomial()Assumption checks:
pythonfrom statsmodels.stats.outliers_influence import variance_inflation_factor # Multicollinearity (VIF > 5 is concerning, > 10 is severe) X = df[['age', 'bmi', 'exposure']].dropna() for i, col in enumerate(X.columns): print(f"VIF {col}: {variance_inflation_factor(X.values, i):.1f}")
python# Stratified analysis (effect modification) for stratum_var in ['sex', 'age_group', 'race']: print(f"\n--- Stratified by {stratum_var} ---") for level, sub in df.groupby(stratum_var): if len(sub) < 50: continue try: m = smf.logit('outcome ~ exposure + age + bmi', data=sub).fit(disp=0) or_val = np.exp(m.params['exposure']) ci = np.exp(m.conf_int().loc['exposure']) print(f" {level}: OR={or_val:.2f} ({ci[0]:.2f}-{ci[1]:.2f}), p={m.pvalues['exposure']:.3f}, N={len(sub)}") except: print(f" {level}: model failed (N={len(sub)})") # Exclude outliers (+/- 3 SD) and re-run df_no_outliers = df[np.abs(stats.zscore(df['exposure'].dropna())) < 3] m_robust = smf.logit('outcome ~ exposure + age + sex + bmi', data=df_no_outliers).fit(disp=0) # Confounder-adjusted exposure (residual method, e.g., energy-adjusted nutrient intake) # Use when exposure correlates strongly with a confounder (total calories, body size, etc.) adj_model = smf.ols('exposure ~ confounder', data=df).fit() df['exposure_adj'] = adj_model.resid # Multiple comparisons note n_tests = 5 # number of exposure-outcome pairs tested bonferroni_threshold = 0.05 / n_tests
This is where ToolUniverse adds value beyond any statistics package. After finding a statistical association, investigate the biological plausibility. Use find_tools to discover the right tools for your domain.
python# 1. Literature evidence — search for mechanism connecting exposure to outcome execute_tool("PubMed_search_articles", {"query": "[exposure] [outcome] mechanism", "max_results": 5}) execute_tool("EuropePMC_search_articles", {"query": "[exposure] [outcome] mouse model in vivo", "limit": 5}) # 2. Pathway/molecular context — discover tools for your exposure type find_tools("[exposure type] pathway") # e.g., "nutrient pathway", "drug target", "chemical toxicology" find_tools("[outcome type] gene disease") # e.g., "diabetes gene", "cancer survival", "cardiac risk" # 3. Gene-disease evidence (if a gene/variant is involved) find_tools("gene disease association") find_tools("variant functional annotation") # 4. Drug/chemical mechanisms (if a drug or chemical is the exposure) find_tools("drug mechanism target") find_tools("chemical gene interaction")
The pattern: Exposure X → (what molecular pathway?) → (what biological process?) → Outcome Y. Use ToolUniverse tools to fill in the middle steps. This converts a statistical association into a biologically plausible hypothesis.
Key plots to produce (use matplotlib):
sns.regplot()Write the final report in this order:
Key limitations to always state:
Before finalizing any epidemiological analysis:
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 12 counted toward the lift figure. The other 10 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +14 percentage points is the difference between those two pass rates over the 12 comparable cases. 7 cases got worse with the skill loaded, and they are included in that figure.
The publisher has shipped newer versions since this run, so these numbers describe v1, not the version currently listed.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.