Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Per-feature NaN-safe Spearman/Pearson correlation across many features (genes, proteins, variants) with missing values. Covers why bulk matrix shortcuts fail, correct pairwise deletion, degenerate input filtering, and large-dataset performance. Use statistical-analysis for test choice; shap-model-explainability for interpretability.
.claude/skills/jaechang-hits-nan-safe-correlation/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 47% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 4% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 220% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 148% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 48% | 0% |
Computing correlations across many features (genes, proteins, variants) when missing values are present is error-prone. The most common mistake is using bulk matrix shortcuts that silently mishandle NaN, producing incorrect correlation values. This guide covers correct per-feature pairwise computation, degenerate input filtering, and performance optimization.
Different features have different missing value patterns across samples. Bulk methods handle this inconsistently:
| Method | Problem | |--------|---------| | DataFrame.rank() then corrwith() | rank() assigns NaN ranks; corrwith() may drop globally or per-column inconsistently | | DataFrame.corrwith(method='spearman') | Implementation varies by pandas version; may use listwise deletion | | np.corrcoef on ranked data | Propagates NaN to entire result if any value is missing |
Features that produce undefined or unstable correlations:
| Type | Description | Effect | |------|-------------|--------| | Constant features | All values identical (variance = 0) | Correlation undefined (division by zero) | | Near-constant features | Very low variance | Correlation numerically unstable | | Too few valid values | After NaN removal, fewer than min_valid pairs | Statistically unreliable | | Single-value after filtering | Only one unique value remains post-NaN removal | Correlation undefined |
Do you have missing values (NaN) in your feature matrix?
├── No NaN at all → Bulk methods are safe (corrwith, np.corrcoef)
└── Yes, NaN present
├── Same NaN pattern across all features? → Listwise deletion is acceptable
└── Different NaN patterns per feature (typical)
├── < 10,000 features → Per-feature loop with scipy.stats.spearmanr
└── > 10,000 features → Parallelized per-feature loop (joblib)| Scenario | Recommended Approach | Rationale | |----------|---------------------|-----------| | No missing data | DataFrame.corrwith() | Fast, correct when no NaN | | Sparse NaN, < 10K features | Per-feature spearmanr loop | Correct pairwise deletion, acceptable speed | | Sparse NaN, > 10K features | Parallelized per-feature loop | Same correctness, scales with cores | | Dense NaN (> 50% missing) | Per-feature loop + strict min_valid | Many features will be skipped; report skip count | | Uniform NaN pattern | Listwise deletion + bulk method | If all features share same NaN rows, pairwise = listwise |
df.rank() followed by corrwith() silently mishandles NaN, producing incorrect correlations.scipy.stats.spearmanr per feature when NaN is present.n_valid for every feature.filter_degenerate() before the correlation loop.scipy.stats.spearmanrpythonfrom scipy.stats import spearmanr import numpy as np import pandas as pd def nan_summary(df): """Print NaN summary before correlation analysis.""" print(f"Dataset shape: {df.shape}") print(f"Total NaN: {df.isna().sum().sum()}") print(f"Features with any NaN: {(df.isna().any()).sum()}") print(f"NaN per feature (mean): {df.isna().sum().mean():.1f}") print(f"NaN per feature (max): {df.isna().sum().max()}") def filter_degenerate(df, min_unique=3, min_nonnan_frac=0.5): """Remove degenerate features before correlation analysis. Args: df: DataFrame (samples x features) min_unique: Minimum number of unique non-NaN values required min_nonnan_frac: Minimum fraction of non-NaN values required Returns: Filtered DataFrame, count of removed features """ n_samples = len(df) keep = [] for col in df.columns: values = df[col].dropna() if len(values) < n_samples * min_nonnan_frac: continue if values.nunique() < min_unique: continue keep.append(col) removed = len(df.columns) - len(keep) print(f"Filtered {removed} degenerate features out of {len(df.columns)}") return df[keep], removed def pairwise_spearman(df_x, df_y, min_valid=10): """Compute per-feature Spearman correlation with pairwise NaN removal. Args: df_x: DataFrame (samples x features), aligned with df_y df_y: DataFrame (samples x features), same shape as df_x min_valid: Minimum number of valid (non-NaN) pairs required Returns: DataFrame with columns: rho, pvalue, n_valid """ nan_summary(df_x) nan_summary(df_y) results = [] for feature in df_x.columns: x = df_x[feature].values y = df_y[feature].values mask = ~(np.isnan(x) | np.isnan(y)) n_valid = mask.sum() if n_valid < min_valid: results.append({'feature': feature, 'rho': np.nan, 'pvalue': np.nan, 'n_valid': n_valid}) continue rho, pval = spearmanr(x[mask], y[mask]) results.append({'feature': feature, 'rho': rho, 'pvalue': pval, 'n_valid': n_valid}) result_df = pd.DataFrame(results).set_index('feature') skipped = result_df['rho'].isna().sum() if skipped > 0: print(f"Skipped {skipped} features with < {min_valid} valid pairs") return result_df
python# WRONG: Bulk rank-then-correlate ranked_x = df_x.rank() ranked_y = df_y.rank() corrs = ranked_x.corrwith(ranked_y) # WRONG: Bulk corrwith with method parameter corrs = df_x.corrwith(df_y, method='spearman') # WRONG: numpy corrcoef on ranked arrays (propagates NaN) corrs = np.corrcoef(df_x.rank().values.T, df_y.rank().values.T)
pythonfrom joblib import Parallel, delayed def parallel_spearman(df_x, df_y, min_valid=10, n_jobs=4): """Parallelized per-feature Spearman correlation.""" def compute_one(feature): x = df_x[feature].values y = df_y[feature].values mask = ~(np.isnan(x) | np.isnan(y)) n = mask.sum() if n < min_valid: return feature, np.nan, np.nan, n rho, pval = spearmanr(x[mask], y[mask]) return feature, rho, pval, n results = Parallel(n_jobs=n_jobs)( delayed(compute_one)(f) for f in df_x.columns ) return pd.DataFrame( results, columns=['feature', 'rho', 'pvalue', 'n_valid'] ).set_index('feature')
statistical-analysis -- General statistical test selection and assumption checkingdegenerate-input-filtering -- Broader guide on filtering uninformative data before any statistical testscikit-learn-machine-learning -- Feature selection and preprocessing pipelines| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 20,471 | 27,508 | +34% | 1 | 1 | 0% | 4,204 | 6,195 | +47% | 0 | 0 | — |
case-02 | fail→pass | 29,738 | 18,325 | -38% | 1 | 1 | 0% | 6,251 | 6,487 | +4% | 0 | 0 | — |
case-03 | pass→pass | 16,061 | 10,892 | -32% | 1 | 1 | 0% | 2,948 | 4,904 | +66% | 0 | 0 | — |
case-04 | fail→fail | 19,070 | 16,712 | -12% | 1 | 1 | 0% | 3,561 | 6,146 | +73% | 0 | 0 | — |
case-05 | fail→pass | 29,270 | 10,641 | -64% | 1 | 1 | 0% | 1,459 | 4,670 | +220% | 0 | 0 | — |
case-06 | pass→pass | 20,269 | 19,784 | -2% | 1 | 1 | 0% | 3,189 | 6,135 | +92% | 0 | 0 | — |
case-11 | fail→pass | 12,966 | 13,771 | +6% | 1 | 1 | 0% | 2,175 | 5,401 | +148% | 0 | 0 | — |
case-07 | fail→pass | 15,231 | 7,705 | -49% | 1 | 1 | 0% | 2,832 | 4,198 | +48% | 0 | 0 | — |
case-08 | pass→pass | 27,901 | 18,973 | -32% | 1 | 1 | 0% | 4,784 | 6,150 | +29% | 0 | 0 | — |
case-09 | fail→pass | 17,580 | 12,358 | -30% | 1 | 1 | 0% | 2,870 | 5,115 | +78% | 0 | 0 | — |
case-10 | pass→pass | 14,233 | 11,763 | -17% | 1 | 1 | 0% | 2,146 | 4,819 | +125% | 0 | 0 | — |
case-12 | fail→pass | 15,531 | 38,485 | +148% | 1 | 1 | 0% | 2,447 | 5,199 | +112% | 0 | 0 | — |
case-13 | fail→fail | 10,581 | 5,551 | -48% | 1 | 1 | 0% | 1,581 | 3,767 | +138% | 0 | 0 | — |
case-14 | pass→pass | 17,042 | 12,982 | -24% | 1 | 1 | 0% | 2,572 | 5,297 | +106% | 0 | 0 | — |
case-15 | fail→pass | 10,367 | 11,276 | +9% | 1 | 1 | 0% | 1,748 | 4,211 | +141% | 0 | 0 | — |
case-16 | pass→pass | 11,407 | 6,048 | -47% | 1 | 1 | 0% | 1,803 | 3,863 | +114% | 0 | 0 | — |
case-17 | fail→pass | 17,163 | 14,179 | -17% | 1 | 1 | 0% | 2,757 | 5,160 | +87% | 0 | 0 | — |
case-18 | pass→pass | 15,077 | 12,154 | -19% | 1 | 1 | 0% | 2,395 | 4,725 | +97% | 0 | 0 | — |
case-19 | fail→fail | 16,212 | 10,251 | -37% | 1 | 1 | 0% | 3,029 | 4,681 | +55% | 0 | 0 | — |
case-20 | pass→pass | 15,188 | 36,434 | +140% | 1 | 1 | 0% | 2,259 | 4,914 | +118% | 0 | 0 | — |
case-21 | pass→pass | 6,711 | 5,854 | -13% | 1 | 1 | 0% | 1,158 | 3,831 | +231% | 0 | 0 | — |
case-22 | fail→pass | 6,511 | 4,331 | -33% | 1 | 1 | 0% | 1,171 | 3,644 | +211% | 0 | 0 | — |
case-23 | pass→pass | 6,703 | 3,679 | -45% | 1 | 1 | 0% | 1,152 | 3,494 | +203% | 0 | 0 | — |
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. 23 cases were attempted, and 22 counted toward the lift figure. The other 1 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 +43 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.