Mark outliers as NaN in a new frame, using Isolation Forest.
Pure: data is never modified. cleaned is a NEW frame built with :meth:pandas.DataFrame.mask; the input frame survives the call untouched, so a caller no longer needs a defensive .copy().
Columns to fit. None (default) fits every column of data. When given, only the named columns are fitted; columns outside columns are passed through cleaned untouched and are all-False in mask.
tuple[pd.DataFrame, pd.DataFrame]: (cleaned, mask). cleaned is a new frame in which every cell mask marks True is NaN. mask is the full-width boolean frame from :func:outlier_mask (same index and columns as data, True == outlier); per-column counts are mask[col].sum().
import numpy as npimport pandas as pdfrom spotforecast2_safe.preprocessing.outlier import mark_outliersrng = np.random.default_rng(0)# 50 normal values plus two clear outliers (1000, -1000)values = np.concatenate([rng.normal(loc=10.0, scale=1.0, size=50), [1000.0, -1000.0]])data = pd.DataFrame({"load": values})cleaned, mask = mark_outliers( data, contamination=0.05, random_state=42, verbose=True)n_nan = cleaned["load"].isna().sum()print(f"Outliers marked as NaN: {n_nan}")assert n_nan >=2, "Expected at least the two injected extreme outliers to be marked"assert n_nan ==int(mask["load"].sum())assert data["load"].notna().all(), "The input frame is never modified"# columns= restricts fitting to a subset; the rest passes through.two_col = pd.DataFrame({"load": values, "other": np.zeros_like(values)})cleaned_subset, mask_subset = mark_outliers( two_col, contamination=0.05, random_state=42, columns=["load"])assertnot mask_subset["other"].any()assert cleaned_subset["other"].equals(two_col["other"])
Column 'load': Marked 5.7692% of data points as outliers.
Outliers marked as NaN: 3