Comprehensive guide to understanding and applying OverNaN's NaN-aware oversampling methods for imbalanced classification with missing data.
- Introduction
- Algorithm Overview
- NaN Handling Strategies
- Sampling Strategies
- Parameter Tuning
- Output Interpretation
- Method Selection Guide
- Usage Examples
- Domain-Specific Considerations
- Limitations and Caveats
- References
Classification with imbalanced data and missing values presents a compound challenge:
Class Imbalance: When one class significantly outnumbers others, classifiers tend to favor the majority class, resulting in poor minority class detection. This is problematic in applications where the minority class is the primary interest (e.g., fraud detection, rare event prediction, anomaly identification).
Missing Values: Real-world datasets frequently contain missing values that carry semantic meaning:
- Sensor failures indicate equipment issues
- Unanswered survey questions reflect respondent characteristics
- Missing financial records may signal reporting problems
The Compound Problem: Standard oversampling methods (SMOTE, ADASYN) require complete data, forcing practitioners to either:
- Impute then oversample: Destroys information encoded in missingness patterns
- Delete incomplete samples: Loses valuable data and may introduce bias
- Ignore the problem: Accept degraded model performance
OverNaN implements three oversampling algorithms that natively handle missing values:
| Method | Approach | Key Advantage |
|---|---|---|
| SMOTENaN | Neighbor interpolation with NaN-aware distance | Preserves local data structure |
| ADASYNNaN | Adaptive neighbor interpolation | Focuses on difficult regions |
| ROSENaN | Kernel density perturbation | No neighbor search required |
Each method generates synthetic minority samples while respecting and preserving missing value patterns, eliminating the need for imputation. The aim is to achieve similar or better classification performance, while only using real data.
Synthetic Minority Over-sampling Technique with NaN Handling
SMOTENaN generates synthetic samples by interpolating between minority class samples and their k-nearest neighbors. The interpolation creates new samples along the line segments connecting existing samples in feature space.
- For each minority sample, find k nearest neighbors within the minority class
- Randomly select one neighbor
- Create a synthetic sample at a random point between the sample and neighbor
- Handle NaN values according to the specified strategy
SMOTENaN uses a modified Euclidean distance that:
- Computes distance using only features where both samples have valid values
- Scales the result to compensate for reduced dimensionality
- Returns infinity when no valid feature pairs exist
- General-purpose oversampling with missing data
- When local data structure should be preserved
- Datasets with moderate missingness (up to ~30%)
- When you want synthetic samples to lie between existing samples
| Parameter | Purpose | Typical Values |
|---|---|---|
neighbours |
Controls neighborhood size | 3-7 |
nan_handling |
NaN synthesis strategy | 'preserve_pattern' |
Adaptive Synthetic Sampling with NaN Handling
ADASYNNaN extends SMOTE by adaptively determining how many synthetic samples to generate for each minority sample. Samples that are harder to learn (those surrounded by more majority class neighbors) receive more synthetic samples.
- For each minority sample, compute a density ratio based on majority class neighbors
- Normalize density ratios to form a probability distribution
- Allocate synthetic samples proportionally to density ratios
- Generate samples using neighbor interpolation (like SMOTE)
The density for sample i is:
density_i = (majority neighbors of i) / (total neighbors of i)
Higher density indicates the sample is in a region dominated by the majority class, making it harder to classify correctly.
- When decision boundaries are complex or unclear
- Datasets with overlapping class distributions
- When you want to focus synthetic generation on difficult regions
- Problems where borderline cases are most important
| Parameter | Purpose | Typical Values |
|---|---|---|
neighbours |
Neighborhood size for density and interpolation | 5-10 |
beta |
Controls total synthetic sample volume | 0.5-1.5 |
learning_rate |
Exponent for density weights | 1.0-2.0 |
Random Over-Sampling Examples with NaN Handling
ROSENaN generates synthetic samples by perturbing existing minority samples with Gaussian noise. Unlike SMOTE and ADASYN, it does not require neighbor search, making it computationally simpler and often faster.
- Compute per-feature bandwidth using Silverman's rule of thumb
- For each synthetic sample needed:
- Randomly select a seed sample from the minority class
- Add Gaussian noise with the computed bandwidth to each feature
- Handle NaN values according to the specified strategy
Silverman's rule provides an optimal bandwidth for kernel density estimation:
h_j = c * n^(-1/(d+4)) * sigma_j * shrinkage
where:
c = (4/(d+2))^(1/(d+4)) (Silverman's constant)
n = number of samples
d = number of features
sigma_j = standard deviation of feature j
- High-dimensional data (many features)
- When neighbor search is computationally expensive
- Datasets with high missingness
- When you want samples that explore beyond existing data boundaries
- Faster processing is needed
| Parameter | Purpose | Typical Values |
|---|---|---|
shrinkage |
Bandwidth scaling factor | 0.5-2.0 |
nan_handling |
NaN synthesis strategy | 'preserve_pattern' |
| Aspect | SMOTENaN | ADASYNNaN | ROSENaN |
|---|---|---|---|
| Synthetic Sample Location | Between existing samples | Between existing samples | Around existing samples |
| Neighbor Search | Required | Required | Not required |
| Computational Complexity | O(n²) distance calculations | O(n²) distance calculations | O(n) per sample |
| Adaptation | Uniform across samples | Focuses on hard examples | Uniform across samples |
| Sample Diversity | Constrained to convex hull | Constrained to convex hull | Can exceed convex hull |
| High Dimensionality | Slower | Slower | Faster |
| High Missingness | May struggle with neighbors | May struggle with neighbors | Handles well |
The nan_handling parameter controls how missing values are treated when generating synthetic samples.
Most conservative strategy
A feature in the synthetic sample is NaN if either parent sample has NaN in that feature.
Parent A: [1.0, NaN, 3.0]
Parent B: [2.0, 4.0, NaN]
Synthetic: [1.5, NaN, NaN] # NaN in positions 1 and 2
- Missingness is meaningful and should not be artificially filled
- Synthetic samples should not claim to have information that was absent in originals
- Preserves the structural properties of the missingness pattern
- Missing values carry semantic meaning (e.g., sensor failure, non-response)
- Downstream models can handle NaN (e.g., XGBoost, LightGBM)
- You want to avoid introducing artificial information
- The missingness pattern itself may be predictive
- NaN percentage in output >= NaN percentage in input
- Most NaN values preserved or increased
Most aggressive strategy
- If both parents have values: interpolate
- If one parent has value, one has NaN: use the available value
- If both parents have NaN: result is NaN
Parent A: [1.0, NaN, 3.0]
Parent B: [2.0, 4.0, NaN]
Synthetic: [1.5, 4.0, 3.0] # Values filled where possible
- Maximizes information in synthetic samples
- Uses available data to fill gaps
- May improve model performance if missingness is random
- Missing values are believed to be random (MCAR)
- Downstream models struggle with NaN
- You want to minimize NaN in the resampled dataset
- Imputation is acceptable for your application
- NaN percentage in output < NaN percentage in input
- Only features where both parents have NaN remain NaN
Probabilistic strategy
- If both parents have values: interpolate
- If both parents have NaN: result is NaN
- If one has value, one has NaN: randomly choose (50% chance each)
Parent A: [1.0, NaN, 3.0]
Parent B: [2.0, 4.0, NaN]
Synthetic: [1.5, NaN, 3.0] # Position 1: randomly chose NaN
# or [1.5, 4.0, NaN] # Position 2: randomly chose NaN
- Preserves the statistical distribution of missingness
- Neither too conservative nor too aggressive
- Maintains expected NaN rate across the dataset
- You want synthetic samples to statistically resemble originals
- Missingness pattern should be preserved in aggregate but not rigidly
- Uncertainty about the missingness mechanism
- NaN percentage in output ≈ NaN percentage in input
- Stochastic variation in NaN placement
Is missingness meaningful in your domain?
├── Yes → preserve_pattern
│ (sensor failures, non-response, etc.)
│
└── No → Does your model handle NaN?
├── No → interpolate
│ (need complete data for model)
│
└── Yes → Is missingness random (MCAR)?
├── Yes → interpolate or random_pattern
└── No/Unknown → preserve_pattern or random_pattern
The sampling_strategy parameter controls the target class distribution after resampling.
Resample all minority classes to match the majority class count.
# Before: {0: 900, 1: 80, 2: 20}
# After: {0: 900, 1: 900, 2: 900}Use when: You want fully balanced classes.
Resample only the smallest class to match the majority.
# Before: {0: 900, 1: 80, 2: 20}
# After: {0: 900, 1: 80, 2: 900} # Only class 2 increasedUse when: Only the rarest class needs augmentation.
Same as 'auto'. Resample everything except the largest class.
Resample all classes except the smallest to match the majority.
# Before: {0: 900, 1: 80, 2: 20}
# After: {0: 900, 1: 900, 2: 20} # Class 2 unchangedUse when: You want to preserve the rarest class as-is.
Resample all classes (including majority) to match the largest count.
# Before: {0: 900, 1: 80, 2: 20}
# After: {0: 900, 1: 900, 2: 900} # Same as 'auto' in this caseUse when: Rarely needed; same effect as 'auto' in binary classification.
Pass a float to specify the target minority-to-majority ratio.
# sampling_strategy=0.5
# Before: {0: 1000, 1: 100}
# After: {0: 1000, 1: 500} # 500/1000 = 0.5 ratioUse when: Full balance is not desired; partial oversampling preferred.
Pass a dictionary specifying exact target counts per class.
# sampling_strategy={0: 1000, 1: 400, 2: 300}
# After: {0: 1000, 1: 400, 2: 300} # Exact countsUse when: You need precise control over final class sizes.
| Goal | Strategy |
|---|---|
| Fully balanced classes | 'auto' or 'not majority' |
| Partial balance (reduce ratio) | 0.5, 0.8, etc. |
| Exact class sizes | {0: n0, 1: n1, ...} |
| Only augment rarest class | 'minority' |
| Preserve natural imbalance somewhat | 0.3 to 0.7 |
Applies to: SMOTENaN, ADASYNNaN
| Value | Effect |
|---|---|
| Small (1-3) | Synthetic samples very close to originals; risk of overfitting |
| Medium (5-7) | Balanced exploration; typical default |
| Large (10+) | More diverse samples; may cross class boundaries |
- Start with default (
neighbours=5) - Reduce if: Few minority samples, overfitting observed, or many neighbors have infinite distance (high NaN)
- Increase if: Large minority class, underfitting observed, want more sample diversity
neighbours must be less than the number of minority samples:
n_minority = np.sum(y == minority_class)
assert neighbours < n_minority, "Not enough minority samples"Controls the total volume of synthetic samples generated.
| Value | Effect |
|---|---|
beta < 1.0 |
Generate fewer samples than needed for full balance |
beta = 1.0 |
Generate enough for full balance (default) |
beta > 1.0 |
Generate more than needed (over-balance) |
# beta=0.5: Generate half the samples needed for balance
# If 100 samples needed for balance, generate ~50
# beta=1.5: Generate 50% more than balance
# If 100 samples needed for balance, generate ~150Tuning: Start with 1.0; reduce if overfitting, increase if underfitting on minority.
Controls how much to focus on hard-to-learn samples.
| Value | Effect |
|---|---|
learning_rate < 1.0 |
Reduce focus on hard samples |
learning_rate = 1.0 |
Linear weighting (default) |
learning_rate > 1.0 |
Amplify focus on hard samples |
# learning_rate=2.0: Square the density weights
# Hard samples get proportionally more synthetic samplesTuning: Increase if model struggles with borderline cases; decrease if synthetic samples are too concentrated.
Controls the bandwidth (spread) of the Gaussian noise added to samples.
| Value | Effect |
|---|---|
shrinkage = 0 |
No noise; exact copies of seeds (random oversampling) |
shrinkage = 0.5 |
Samples stay close to seeds |
shrinkage = 1.0 |
Standard Silverman bandwidth (default) |
shrinkage > 1.0 |
Samples spread further from seeds |
shrinkage = 0.5: shrinkage = 1.0: shrinkage = 2.0:
* * *
*** ***** *******
***** ******* *********
*** ***** *******
* * *
(tight) (normal) (spread)
Tuning:
- Increase if model underfits (more exploration needed)
- Decrease if synthetic samples seem unrealistic or cause noise
- Use 0 to diagnose whether oversampling itself helps (vs. just random copies)
The fit_resample() method returns:
X_resampled, y_resampled = oversampler.fit_resample(X, y)| Output | Type | Description |
|---|---|---|
X_resampled |
np.ndarray or pd.DataFrame |
Features including original and synthetic samples |
y_resampled |
np.ndarray or pd.Series |
Labels including original and synthetic labels |
# Original samples come first, synthetic samples appended
n_original = len(X)
n_resampled = len(X_resampled)
n_synthetic = n_resampled - n_original
X_original = X_resampled[:n_original] # Original data
X_synthetic = X_resampled[n_original:] # Synthetic data# Overall NaN percentage
nan_pct_original = np.isnan(X).sum() / X.size * 100
nan_pct_resampled = np.isnan(X_resampled).sum() / X_resampled.size * 100
print(f"Original NaN: {nan_pct_original:.2f}%")
print(f"Resampled NaN: {nan_pct_resampled:.2f}%")
# Per-feature NaN counts
nan_per_feature = np.isnan(X_resampled).sum(axis=0)| Strategy | Expected Output NaN % |
|---|---|
'preserve_pattern' |
>= Input NaN % |
'interpolate' |
< Input NaN % |
'random_pattern' |
≈ Input NaN % |
from collections import Counter
print("Original:", Counter(y))
print("Resampled:", Counter(y_resampled))
# Verify balance
unique, counts = np.unique(y_resampled, return_counts=True)
imbalance_ratio = max(counts) / min(counts)
print(f"Imbalance ratio: {imbalance_ratio:.2f}") # Should be ~1.0 if balanced# Examine a few synthetic samples
n_original = len(X)
synthetic_samples = X_resampled[n_original:n_original+5]
print("Sample synthetic rows:")
print(synthetic_samples)
# Check they're not exact copies
for i, syn in enumerate(synthetic_samples):
is_copy = any(np.allclose(syn, orig, equal_nan=True) for orig in X)
print(f"Synthetic {i} is exact copy: {is_copy}")START
│
▼
Do you have high-dimensional data (>50 features)?
│
├── Yes ──► ROSENaN (no neighbor search)
│
└── No
│
▼
Is the minority class distribution complex?
(overlapping with majority, irregular boundaries)
│
├── Yes ──► ADASYNNaN (adaptive focus on hard regions)
│
└── No ──► SMOTENaN (uniform interpolation)
| Scenario | Recommended Method |
|---|---|
| General purpose, moderate dimensions | SMOTENaN |
| Complex decision boundaries | ADASYNNaN |
| High-dimensional data (>50 features) | ROSENaN |
| High missingness (>30%) | ROSENaN |
| Need fast processing | ROSENaN |
| Want samples within data boundaries | SMOTENaN or ADASYNNaN |
| Want exploration beyond boundaries | ROSENaN |
| Borderline samples most important | ADASYNNaN |
SMOTENaN
- Well-separated classes
- Clear cluster structure in minority class
- Moderate number of features and samples
ADASYNNaN
- Overlapping class distributions
- Noisy decision boundaries
- When recall on difficult cases matters most
ROSENaN
- High-dimensional feature spaces
- Datasets with substantial missingness
- When computational efficiency matters
- When standard SMOTE overfits
from overnan import OverNaN
import numpy as np
# Sample data with NaN
X = np.array([
[1.0, 2.0, np.nan],
[2.0, np.nan, 3.0],
[3.0, 4.0, 5.0],
[4.0, 5.0, 6.0],
[10.0, 11.0, 12.0] # Minority sample
])
y = np.array([0, 0, 0, 0, 1])
# Basic SMOTE
oversampler = OverNaN(method='SMOTE', neighbours=2, random_state=42)
X_res, y_res = oversampler.fit_resample(X, y)
print(f"Original: {X.shape}, Resampled: {X_res.shape}")
print(f"Class distribution: {dict(zip(*np.unique(y_res, return_counts=True)))}")from overnan import OverNaN
import numpy as np
# High-dimensional data (100 features)
np.random.seed(42)
X = np.random.randn(1000, 100)
X[np.random.random(X.shape) < 0.1] = np.nan # 10% NaN
y = np.array([0] * 900 + [1] * 100) # 9:1 imbalance
# Use ROSE for efficiency with high dimensions
oversampler = OverNaN(
method='ROSE',
shrinkage=1.0,
nan_handling='preserve_pattern',
random_state=42
)
X_res, y_res = oversampler.fit_resample(X, y)
print(f"Features: {X.shape[1]}")
print(f"Resampled shape: {X_res.shape}")from overnan import OverNaN
import numpy as np
# Data where NaN is meaningful (e.g., sensor readings)
X = np.array([
[1.0, np.nan, 3.0], # Sensor 2 failed
[2.0, 4.0, np.nan], # Sensor 3 failed
[3.0, 5.0, 6.0],
[4.0, 6.0, 7.0],
[10.0, np.nan, 12.0] # Minority: Sensor 2 failed
])
y = np.array([0, 0, 0, 0, 1])
# Preserve the failure pattern
oversampler = OverNaN(
method='SMOTE',
neighbours=2,
nan_handling='preserve_pattern', # Critical for meaningful NaN
random_state=42
)
X_res, y_res = oversampler.fit_resample(X, y)
# Verify NaN pattern preserved
print("Synthetic samples NaN pattern:")
print(np.isnan(X_res[len(X):]))from overnan import OverNaN
import numpy as np
# Data where NaN should be filled if possible
X = np.array([
[1.0, np.nan, 3.0],
[2.0, 4.0, np.nan],
[3.0, 5.0, 6.0],
[4.0, 6.0, 7.0],
[10.0, 11.0, 12.0]
])
y = np.array([0, 0, 0, 0, 1])
# Minimize NaN in output
oversampler = OverNaN(
method='SMOTE',
neighbours=2,
nan_handling='interpolate', # Fill NaN where possible
random_state=42
)
X_res, y_res = oversampler.fit_resample(X, y)
nan_before = np.isnan(X).sum() / X.size * 100
nan_after = np.isnan(X_res).sum() / X_res.size * 100
print(f"NaN reduced from {nan_before:.1f}% to {nan_after:.1f}%")from overnan import OverNaN
import numpy as np
# Severe imbalance: 1000:50
np.random.seed(42)
X = np.random.randn(1050, 10)
y = np.array([0] * 1000 + [1] * 50)
# Don't fully balance; use 0.5 ratio
oversampler = OverNaN(
method='SMOTE',
sampling_strategy=0.5, # Target 50% of majority
random_state=42
)
X_res, y_res = oversampler.fit_resample(X, y)
counts = dict(zip(*np.unique(y_res, return_counts=True)))
print(f"Final distribution: {counts}")
print(f"Ratio: {counts[1]/counts[0]:.2f}") # Should be ~0.5from overnan import OverNaN
import numpy as np
# Three-class problem with varying imbalance
np.random.seed(42)
X = np.random.randn(600, 10)
y = np.array([0] * 400 + [1] * 150 + [2] * 50) # 400:150:50
# Balance all classes
oversampler = OverNaN(
method='ADASYN',
sampling_strategy='auto', # All minorities to majority level
random_state=42
)
X_res, y_res = oversampler.fit_resample(X, y)
print("Before:", dict(zip(*np.unique(y, return_counts=True))))
print("After:", dict(zip(*np.unique(y_res, return_counts=True))))from overnan import OverNaN
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import xgboost as xgb
# Generate imbalanced data with NaN
np.random.seed(42)
X = np.random.randn(1000, 20)
X[np.random.random(X.shape) < 0.15] = np.nan
y = np.array([0] * 900 + [1] * 100)
# Split BEFORE oversampling (critical!)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, stratify=y, random_state=42
)
# Oversample training data only
oversampler = OverNaN(method='SMOTE', random_state=42)
X_train_res, y_train_res = oversampler.fit_resample(X_train, y_train)
# Train on resampled data
model = xgb.XGBClassifier(random_state=42, eval_metric='logloss')
model.fit(X_train_res, y_train_res)
# Evaluate on original (untouched) test data
y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred, target_names=['Majority', 'Minority']))from overnan import OverNaN
import numpy as np
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import balanced_accuracy_score
import xgboost as xgb
np.random.seed(42)
X = np.random.randn(500, 15)
X[np.random.random(X.shape) < 0.2] = np.nan
y = np.array([0] * 400 + [1] * 100)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = []
for fold, (train_idx, test_idx) in enumerate(cv.split(X, y)):
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
# Oversample within the fold
oversampler = OverNaN(method='ROSE', random_state=42 + fold)
X_train_res, y_train_res = oversampler.fit_resample(X_train, y_train)
# Train and evaluate
model = xgb.XGBClassifier(random_state=42, eval_metric='logloss', verbosity=0)
model.fit(X_train_res, y_train_res)
y_pred = model.predict(X_test)
score = balanced_accuracy_score(y_test, y_pred)
scores.append(score)
print(f"Fold {fold+1}: {score:.3f}")
print(f"\nMean: {np.mean(scores):.3f} ± {np.std(scores):.3f}")- Missing values often indicate data availability issues or reporting patterns
- Use
'preserve_pattern'to maintain these signals - Consider
ADASYNNaNfor fraud detection (focus on borderline cases)
- NaN typically means sensor failure or communication loss
- These patterns are highly informative; always use
'preserve_pattern' ROSENaNworks well with high-dimensional sensor arrays
- Non-response is informative about respondent characteristics
- Use
'preserve_pattern'or'random_pattern' - Avoid
'interpolate'which would fabricate responses
- Lagged features may have NaN at boundaries
- Consider whether boundary NaN is structural vs. informative
'interpolate'may be appropriate for structural boundary NaN
- Not a replacement for good data: Oversampling cannot create information that does not exist
- Overfitting risk: Synthetic samples derived from limited originals may cause overfitting
- Evaluation must use original data: Never evaluate on synthetic samples
- Very high missingness: When >50% of values are NaN, neighbor-based methods struggle
- All-NaN samples: Samples with no valid features cannot participate in neighbor search
- Systematic missingness: If missingness is related to the target, oversampling may amplify bias
- When imputation is domain-appropriate and well-understood
- Very small minority classes (fewer than
neighbourssamples) - When interpretability of individual samples matters (synthetic samples are artificial)
-
SMOTE: Chawla, N.V., Bowyer, K.W., Hall, L.O., Kegelmeyer, W.P. (2002). SMOTE: Synthetic Minority Over-sampling Technique. Journal of Artificial Intelligence Research, 16, 321-357. DOI: 10.1613/jair.953
-
ADASYN: He, H., Bai, Y., Garcia, E.A., Li, S. (2008). ADASYN: Adaptive Synthetic Sampling Approach for Imbalanced Learning. IEEE International Joint Conference on Neural Networks, 1322-1328. DOI: 10.1109/IJCNN.2008.4633969
-
ROSE: Menardi, G. and Torelli, N. (2014). Training and assessing classification rules with imbalanced data. Data Mining and Knowledge Discovery, 28, 92-122. DOI: 10.1007/s10618-012-0295-5
-
Imbalanced Learning Survey: He, H. and Garcia, E.A. (2009). Learning from Imbalanced Data. IEEE Transactions on Knowledge and Data Engineering, 21(9), 1263-1284. DOI: 10.1109/TKDE.2008.239
-
Missing Data: Little, R.J.A. and Rubin, D.B. (2019). Statistical Analysis with Missing Data, 3rd Edition. Wiley. DOI: 10.1002/9781119482260
OverNaN: Oversampling for Imbalanced Learning with Missing Values
Repository: https://github.com/amaxiom/OverNaN