Source code for vtools.functions.error_detect

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import matplotlib.pyplot as plt
import dask

dask.config.set({"dataframe.query-planning": True})
import dask.dataframe as dd
import numpy as np
import numpy.ma as ma
import pandas as pd
import sys
import warnings
from scipy.signal import medfilt
from scipy.stats.mstats import mquantiles
from scipy.stats import iqr as scipy_iqr

from vtools.data.gap import *
from vtools.data.timeseries import to_dataframe


"""
    Functions to detect (and replace with NaN) any outliers or bad data
    
    norepeats: catches any repeated values. This will often occur if the sensor
    is not working properly, and since realistically the value will always be
    slightly changing, repeats are a clear sign of a malfunctioning device.
    
    Inputs: ts:         the time series
            threshold:  the minimum number of repeated values in a row that 
                        should be caught (default value is currently 20 but
                        should be changed)
            copy:       if True (default), a copy is made, leaving the original
                        series intact
    
    Output:             time series object with repeats replaced by NaNs

    
    
    med_outliers: uses a median filter on a rolling window to detect any
    outliers, making two passes. The function subtracts the median of a window
    centered on each value from the value itself, and then compares this
    difference to the interquartile range on said window. If the difference
    is too large relative to the IQR, the value is marked as an outlier. 
    The original concept comes from Basu & Meckesheimer (2007) although they 
    didn't use the interquartile range but rather expert judgment. This is
    an option here as well, if there is a known value that can be inserted,
    but otherwise the IQR is recommended.
    
    The function completes two passes, first a secondary pass on a large window
    (a couple days) and then the main pass on a window of default 7 values. The
    main pass is more accurate, but gets messed up when there are many outliers
    near each other, hence the secondary pass.
    
    Inputs: ts:             the time series
            secfilt:        boolean whether or not to make the secondary pass
                            (default True)
            level:          the number of times the IQR the distance between
                            the median and value can be before the value is
                            considered an outlier (default 3.0)
            scale:          the manual replacement for the IQR (default None)
            filt_len:       the length of the window (must be odd, default 7)
            quantiles:      a tuple of quantiles as the bounds for the IQR
                            (numbers between 0 and 100)
            seclevel:       same as level but for the larger pass
            secscale:       same as scale but for the larger pass
            secfilt_len:    same as filt_len but for the larger pass
            secquantiles:   same as quantiles but for the larger pass
            copy:           if True (default), a copy is made, leaving the
                            original series intact
            
            Output:         time series object with outliers replaced by NaNs
"""


[docs] def _nrepeat(ts): """Series-only version""" mask = ts.ne(ts.shift()) counts = ts.groupby(mask.cumsum()).transform("count") return counts
[docs] def nrepeat(ts): """Return the length of consecutive runs of repeated values Parameters ---------- ts: DataFrame or series Returns ------- Like-indexed series with lengths of runs. Nans will be mapped to 0 """ if isinstance(ts, pd.Series): return _nrepeat(ts) return ts.apply(_nrepeat, axis=0)
[docs] def threshold(ts, bounds, copy=True): ts_out = ts.copy() if copy else ts warnings.filterwarnings("ignore") if bounds is not None: if bounds[0] is not None: ts_out.mask(ts_out < bounds[0], inplace=True) if bounds[1] is not None: ts_out.mask(ts_out > bounds[1], inplace=True) return ts_out
[docs] def bounds_test(ts, bounds): # Make a boolean mask with exactly the same shape as ts if isinstance(ts, pd.Series): anomaly = pd.Series(False, index=ts.index, name=ts.name, dtype=bool) else: anomaly = pd.DataFrame(False, index=ts.index, columns=ts.columns, dtype=bool) if bounds is not None: lo, hi = bounds if lo is not None: anomaly |= ts < lo if hi is not None: anomaly |= ts > hi return anomaly
[docs] def max_change_test(ts, max_change, max_plateau_len=0): """Flag interiors of excursions with excessive adjacent changes. Parameters ---------- ts : pandas.Series or pandas.DataFrame Numeric observations in sampling order. DataFrame columns are tested independently. Input values are not modified. max_change : float Positive, finite absolute-change limit per sampling interval. A change equal to the limit is accepted. This is not a rate per unit time; choose the limit for the input sampling interval. max_plateau_len : int, default 0 Maximum number of consecutive non-excessive difference intervals between excessive-change runs that may be bridged. Merge neighboring runs left-to-right only when their combined mean absolute change, including the plateau, strictly exceeds ``max_change``. Missing or non-finite differences cannot be bridged. Returns ------- pandas.Series or pandas.DataFrame Boolean anomaly mask with the input index, name, and columns. Only observations strictly inside each resulting run are flagged; its two endpoint observations are retained. An isolated excessive difference has no interior observation and flags nothing. Notes ----- Endpoint retention assumes the excursion starts and ends at good data. Persistent steps and bad endpoints require complementary tests. Absolute variation is accumulated, so changes of opposite sign do not cancel. Prefix sums and a single merge scan give linear cost per column. Merge order is deliberately left-to-right and can affect the resulting groups. """ if not isinstance(ts, (pd.Series, pd.DataFrame)): raise TypeError("max_change_test expects a pandas Series or DataFrame") if not np.isscalar(max_change) or not np.isfinite(max_change) or max_change <= 0: raise ValueError("max_change must be positive and finite") if ( isinstance(max_plateau_len, (bool, np.bool_)) or not isinstance(max_plateau_len, (int, np.integer)) or max_plateau_len < 0 ): raise ValueError("max_plateau_len must be a nonnegative integer") if isinstance(ts, pd.DataFrame): return ts.apply(_max_change_test_series, args=(max_change, max_plateau_len)) return _max_change_test_series(ts, max_change, max_plateau_len)
def _max_change_test_series(ts, max_change, max_plateau_len): anomaly = pd.Series(False, index=ts.index, name=ts.name, dtype=bool) if len(ts) < 2: return anomaly values = ts.to_numpy(dtype=float, na_value=np.nan) changes = np.abs(np.diff(values)) valid = np.isfinite(changes) excessive = valid & (changes > max_change) starts = np.flatnonzero(excessive & ~np.r_[False, excessive[:-1]]) ends = np.flatnonzero(excessive & ~np.r_[excessive[1:], False]) if len(starts) == 0: return anomaly variation = np.r_[0.0, np.cumsum(np.where(valid, changes, 0.0))] missing = np.r_[0, np.cumsum(~valid)] run_start = starts[0] run_end = ends[0] for next_start, next_end in zip(starts[1:], ends[1:]): plateau_len = next_start - run_end - 1 no_missing = missing[next_start] == missing[run_end + 1] total_change = variation[next_end + 1] - variation[run_start] interval_count = next_end - run_start + 1 if ( plateau_len <= max_plateau_len and no_missing and total_change > max_change * interval_count ): run_end = next_end else: anomaly.iloc[run_start + 1 : run_end + 1] = True run_start, run_end = next_start, next_end anomaly.iloc[run_start + 1 : run_end + 1] = True return anomaly
[docs] def median_test(ts, level=4, filt_len=7, quantiles=(0.005, 0.095), copy=True): return med_outliers( ts, level=level, filt_len=filt_len, quantiles=quantiles, copy=False, as_anomaly=True, )
[docs] def choose_npartitions( nrows, *, window, small_series_threshold=None, min_partition_size=None, target_partition_size=5000, ): if nrows <= 0: raise ValueError("nrows must be positive") if window <= 0: raise ValueError("window must be positive") if target_partition_size <= 0: raise ValueError("target_partition_size must be positive") if min_partition_size is None: min_partition_size = max(4 * window, 128) if small_series_threshold is None: small_series_threshold = min_partition_size if small_series_threshold <= 0: raise ValueError("small_series_threshold must be positive") if min_partition_size <= 0: raise ValueError("min_partition_size must be positive") if nrows <= small_series_threshold: return 1 proposed = max(1, nrows // target_partition_size) max_safe = max(1, nrows // min_partition_size) return max(1, min(proposed, max_safe))
def median_test_oneside( ts, scale=None, level=4, filt_len=6, quantiles=(0.005, 0.095), copy=True, reverse=False, ): if copy: ts = ts.copy() kappa = filt_len // 2 if reverse: original_index = ts.index vals = ts[::-1].reset_index(drop=True) else: vals = ts if isinstance(vals, pd.Series): vals = vals.to_frame(name="ts") elif isinstance(vals, pd.DataFrame): vals = vals.copy() if vals.shape[1] != 1: raise ValueError("median_test_oneside expects single-column input") vals.columns = ["ts"] else: raise TypeError("Expected pandas Series or DataFrame") vals["z"] = vals.ts.diff() min_periods = kappa * 2 - 1 window = kappa * 2 npartitions = choose_npartitions(len(vals), window=window) dds = dd.from_pandas(vals, npartitions=npartitions) dds["my"] = dds["ts"].shift().rolling(kappa * 2, min_periods=min_periods).median() dds["mz"] = dds.z.shift().rolling(kappa * 2, min_periods=min_periods).median() dds["pred"] = dds.my + kappa * dds.mz res = (dds.ts - dds.pred).compute() if scale is None: qq = res.quantile(q=quantiles) scale = qq.loc[quantiles[1]] - qq.loc[quantiles[0]] anomaly = (res.abs() > level * scale) | (res.abs() < -level * scale) if reverse: anomaly = anomaly[::-1] anomaly.index = original_index return anomaly
[docs] def med_outliers( ts, level=4.0, scale=None, filt_len=7, range=(None, None), quantiles=(0.01, 0.99), copy=True, as_anomaly=False, ): """ Detect outliers by running a median filter, subtracting it from the original series and comparing the resulting residuals to a global robust range of scale (the interquartile range). Individual time points are rejected if the residual at that time point is more than level times the range of scale. The original concept comes from Basu & Meckesheimer (2007) Automatic outlier detection for time series: an application to sensor data although they didn't use the interquartile range but rather expert judgment. To use this function effectively, you need to be thoughtful about what the interquartile range will be. For instance, for a strongly tidal flow station it is likely to level: Number of times the scale or interquantile range the data has to be to be rejected.d scale: Expert judgment of the scale of maximum variation over a time step. If None, the interquartile range will be used. Note that for a strongly tidal station the interquartile range may substantially overestimate the reasonable variation over a single time step, in which case the filter will work fine, but level should be set to a number (less than one) accordingly. filt_len: length of median filter, default is 5 quantiles : tuple of quantiles defining the measure of scale. Ignored if scale is given directly. Default is interquartile range, and this is almost always a reasonable choice. copy: if True, a copy is made leaving original series intact You can also specify rejection of values based on a simple range Returns: copy of series with outliers replaced by nan """ import warnings ts_out = ts.copy() if copy else ts warnings.filterwarnings("ignore") if range is not None: threshold(ts_out, range, copy=False) vals = ts_out.to_numpy() # if ts_out.ndim == 1: # filt = medfilt(vals,filt_len) # else: # filt = np.apply_along_axis(medfilt,0,vals,filt_len) filt = ts_out.rolling(filt_len, center=True).median() res = ts_out - filt if scale is None: qq = res.quantile(q=quantiles) scale = qq.loc[quantiles[1]] - qq.loc[quantiles[0]] anomaly = (res.abs() > level * scale) | (res.abs() < -level * scale) if as_anomaly: return anomaly # apply anomaly by setting values to nan values = np.where(anomaly, np.nan, ts_out.values) ts_out.iloc[:] = values warnings.resetwarnings() return ts_out
def median_test_twoside( ts, level=4.0, scale=None, filt_len=7, quantiles=(0.01, 0.99), copy=True, as_anomaly=True, ): """ Detect outliers by running a median filter, subtracting it from the original series and comparing the resulting residuals to a global robust range of scale (the interquartile range). Individual time points are rejected if the residual at that time point is more than level times the range of scale. The original concept comes from Basu & Meckesheimer (2007) Automatic outlier detection for time series: an application to sensor data although they didn't use the interquartile range but rather expert judgment. To use this function effectively, you need to be thoughtful about what the interquartile range will be. For instance, for a strongly tidal flow station it is likely to level: Number of times the scale or interquantile range the data has to be to be rejected.d scale: Expert judgment of the scale of maximum variation over a time step. If None, the interquartile range will be used. Note that for a strongly tidal station the interquartile range may substantially overestimate the reasonable variation over a single time step, in which case the filter will work fine, but level should be set to a number (less than one) accordingly. filt_len: length of median filter, default is 5 quantiles : tuple of quantiles defining the measure of scale. Ignored if scale is given directly. Default is interquartile range, and this is almost always a reasonable choice. copy: if True, a copy is made leaving original series intact You can also specify rejection of values based on a simple range Returns: copy of series with outliers replaced by nan """ import warnings ts_out = ts.copy() if copy else ts warnings.filterwarnings("ignore") vals = ts_out.to_numpy() def mseq(flen): halflen = flen // 2 a = np.arange(0, halflen) b = np.arange(halflen + 1, flen) return np.concatenate((a, b)) medseq = mseq(filt_len) kappa = filt_len // 2 window = kappa * 2 npartitions = choose_npartitions(len(vals), window=window) dds = dd.from_pandas(ts_out, npartitions=npartitions) filt = ( dds.rolling(filt_len, center=True) .apply(lambda x: np.nanmedian(x[medseq]), raw=True, engine="numba") .compute() ) res = ts_out - filt if scale is None: qq = res.quantile(q=quantiles) scale = qq.loc[quantiles[1]] - qq.loc[quantiles[0]] anomaly = (res.abs() > level * scale) | (res.abs() < -level * scale) if as_anomaly: return anomaly # apply anomaly by setting values to nan values = np.where(anomaly, np.nan, ts_out.values) ts_out.iloc[:] = values warnings.resetwarnings() return ts_out def gapdist_test_series(ts, smallgaplen=0): test_gap = ts.copy() gapcount = gap_count(ts) testgapnull = test_gap.isnull() is_small_gap = gapcount <= smallgaplen smallgap = testgapnull & is_small_gap test_gap.where(~smallgap, -99999999.0, inplace=True) return test_gap
[docs] def steep_then_nan( ts, level=4.0, scale=None, filt_len=11, range=(None, None), quantiles=(0.01, 0.99), copy=True, as_anomaly=True, *, gap_aggregation="any", smallgaplen=3, neargapdist=5, ): """Detect outliers via a median-filter residual test, gated by proximity to large gaps. This is a Basu/median-style residual detector (median filter + robust global scale) with an additional *gap proximity gate*: only points close to a *large* gap are eligible to be flagged. "Small gaps" are treated as ignorable by converting them to a sentinel before computing gap distance. Parameters ---------- ts : pandas.Series or pandas.DataFrame Input time series. For DataFrames, residual/outlier detection is performed per column. level : float, default 4.0 Threshold multiplier applied to the robust scale. A point is an outlier if ``abs(residual) > level * scale``. scale : float or pandas.Series, optional If None, compute a robust global scale from residual quantiles. If provided, it is used directly (scalar or per-column Series). filt_len : int, default 11 Median filter window length passed to ``scipy.signal.medfilt``. range : tuple, default (None, None) Optional (lo, hi) bounds applied to the input before residual calculation. If lo or hi is None, that side is not applied. quantiles : tuple[float, float], default (0.01, 0.99) Quantiles defining the robust range used as the scale when ``scale`` is None. copy : bool, default True If True, work on a copy of ``ts``. as_anomaly : bool, default True If True, return a boolean anomaly mask (same shape as ``ts``). If False, return a copy of ``ts`` with anomalies replaced by NaN. gap_aggregation : {"any", "all"}, default "any" How to combine gap proximity across columns when ``ts`` is a DataFrame. - "any": a time is considered near a large gap if ANY column is near a gap. - "all": a time is considered near a large gap only if ALL columns are near a gap. This only affects the near-gap gate; residual outliers are still computed per column. smallgaplen : int, default 3 Gaps of length <= ``smallgaplen`` are treated as "small" and replaced by a sentinel before gap distance is computed. neargapdist : int, default 5 A time is considered "near a large gap" if its distance-to-gap (in samples) is < ``neargapdist``. Returns ------- pandas.Series or pandas.DataFrame If ``as_anomaly=True``, returns a boolean mask with the same shape as ``ts``. If ``as_anomaly=False``, returns a copy of ``ts`` with flagged points set to NaN. Notes ----- - Gap proximity is computed using ``gapdist_test_series`` and ``gap_distance``. For DataFrames, the per-column gap distance is reduced to a *time mask* using ``gap_aggregation``. - This function is intended for offline QC where gap-adjacent behavior is considered more suspect than behavior in continuous data. """ import warnings import numpy as np import pandas as pd from scipy.signal import medfilt ts_out = ts.copy() if copy else ts warnings.filterwarnings("ignore") # Optional hard bounds (mutates ts_out) if range is not None: threshold(ts_out, range, copy=False) # ---- Gap proximity gate (time mask) ---- test_gap = gapdist_test_series(ts_out, smallgaplen=smallgaplen) gapdist = gap_distance(test_gap, disttype="count", to="bad") near = gapdist < neargapdist if isinstance(near, pd.DataFrame): if gap_aggregation == "any": near_t = near.any(axis=1) elif gap_aggregation == "all": near_t = near.all(axis=1) else: raise ValueError( f"gap_aggregation must be 'any' or 'all', got {gap_aggregation!r}" ) else: near_t = near # ---- Median filter + residuals (per column) ---- vals = ts_out.to_numpy() if ts_out.ndim == 1: filt = medfilt(vals, filt_len) filt = pd.Series(filt, index=ts_out.index, name=ts_out.name) else: filt = np.apply_along_axis(medfilt, 0, vals, filt_len) filt = pd.DataFrame(filt, index=ts_out.index, columns=ts_out.columns) res = ts_out - filt # ---- Robust scale ---- if scale is None: qq = res.quantile(q=quantiles) # Series input -> qq is Series (indexed by quantiles) # DataFrame input -> qq is DataFrame (index=quantiles, columns=ts_out.columns) scale = qq.loc[quantiles[1]] - qq.loc[quantiles[0]] # ---- Outlier mask (per column / per series) ---- outlier = res.abs() > (level * scale) # Gate by near-gap time mask if isinstance(outlier, pd.DataFrame): outlier = outlier & near_t.to_numpy()[:, None] else: outlier = outlier & near_t if as_anomaly: warnings.resetwarnings() return outlier # Apply via pandas alignment (avoids shape/broadcast pitfalls) ts_out = ts_out.mask(outlier) warnings.resetwarnings() return ts_out
[docs] def despike(arr, n1=2, n2=20, block=10, *, as_anomaly=False): """Detect and optionally remove isolated spikes using overlapping-window statistics. This is a legacy-style *spike scrubber* rather than a pure anomaly detector. It runs two passes: 1) Compute a rolling (left-aligned) window mean/std and flag values whose deviation exceeds ``n1 * std``. Those flagged values are temporarily set to NaN. 2) Recompute rolling window mean/std on the NaN-masked signal and flag values whose deviation exceeds ``n2 * std``. These flags are the final spikes. Parameters ---------- arr : array-like 1D numeric signal. n1 : float, default 2 Pass-1 threshold multiplier on the rolling standard deviation. n2 : float, default 20 Pass-2 threshold multiplier on the rolling standard deviation. block : int, default 10 Rolling window length (number of samples). Windows are **left-aligned** (i.e., window i is arr[i:i+block]). The final mean/std arrays are extended by repeating the last window's stats for the last ``block-1`` samples, matching the historical behavior. as_anomaly : bool, default False If False (default), return a cleaned array where spikes are replaced with NaN. If True, return a boolean mask (True where spikes are detected by pass-2). Returns ------- np.ndarray If ``as_anomaly`` is False: a float array (same length) with spikes set to NaN. If ``as_anomaly`` is True: a boolean array (same length) marking spikes. Notes ----- - The implementation uses NumPy's ``sliding_window_view`` instead of an external ``rolling_window`` helper to make the behavior explicit and self-contained. - Any NaNs in the input are treated as missing data via masked arrays. - This function copies the input and does **not** modify it in-place. """ import numpy as np import numpy.ma as ma from numpy.lib.stride_tricks import sliding_window_view x = np.asarray(arr, dtype=float).copy() if x.ndim != 1: raise ValueError("despike expects a 1D array") if not isinstance(block, (int, np.integer)) or block <= 0: raise ValueError("block must be a positive integer") if x.size < block: raise ValueError(f"block={block} is larger than input length {x.size}") # Offset to reduce negative values; ignore NaNs (treated as missing). offset = np.nanmin(x) if not np.isfinite(offset): raise ValueError("input contains no finite values") x0 = x - offset # offset-normalized signal def _roll_stats(sig, mult): # shape: (n-block+1, block) roll = sliding_window_view(sig, window_shape=block) roll = ma.masked_invalid(roll) std = mult * roll.std(axis=1) mean = roll.mean(axis=1) # Extend to length n by repeating last window's stats (legacy behavior). if block > 1: std = np.r_[std, np.tile(std[-1], block - 1)] mean = np.r_[mean, np.tile(mean[-1], block - 1)] return mean.filled(np.nan), std.filled(np.nan) # Pass 1: conservative flagging, then mask flagged values. mean1, std1 = _roll_stats(x0, n1) mask1 = np.abs(x0 - mean1) > std1 x1 = x0.copy() x1[mask1] = np.nan # Pass 2: aggressive flagging on the masked signal. mean2, std2 = _roll_stats(x1, n2) mask2 = np.abs(x0 - mean2) > std2 if as_anomaly: return mask2.astype(bool) out = x0.copy() out[mask2] = np.nan return out + offset
[docs] def example(): station = sys.argv[1] ts = read_cdec("cdec_download/%s.csv" % station, start=None, end=None) filt = medfilt(ts.data, kernel_size=5) ts, filt = med_outliers(ts, quantiles=[0.2, 0.8], range=[120.0, None], copy=False) plt.plot(ts.times, ts.data, label="data") plt.plot(ts.times, filt.data, label="filt") plt.plot(ts.times, ts.data - filt.data, label="res") plt.legend() plt.show()
[docs] def example2(): data = np.arange(32) * 2.0 + np.cos(np.arange(32) * 2 * np.pi / 24.0) ndx = pd.date_range(pd.Timestamp(2000, 1, 1), periods=len(data), freq="15min") df = pd.DataFrame(data=data, index=ndx) median_test_oneside(df, quantiles=(0.25, 0.75), level=2)
if __name__ == "__main__": example2() # Just an example