-
Notifications
You must be signed in to change notification settings - Fork 7
Detectors Profiler
The profiler asks whether your data is structurally sound — before anyone looks at whether the values make sense. Three functions, six issue codes.
| Function | Codes | Module |
|---|---|---|
audit_frequency |
PRF001, PRF004, PRF005 | tsauditor/profiler/frequency.py |
audit_missing |
PRF002, PRF006 | tsauditor/profiler/missing.py |
audit_stationarity |
PRF003 | tsauditor/profiler/stationarity.py |
All three raise ValueError if the DataFrame index is not a DatetimeIndex, and all three return an empty list for an empty DataFrame. All three examine numeric columns only — text columns are ignored entirely.
Problems with the time index itself: repeated timestamps, unexpectedly long gaps between rows, and gaps that arrive in clusters.
This is the only profiler check that raises a CRITICAL issue, and it runs first in the pipeline, because a broken index invalidates everything computed afterward.
audit_frequency(df: pd.DataFrame, domain: str = None) -> list| Parameter | Type | Default | What it does |
|---|---|---|---|
df |
pd.DataFrame |
required | Must have a DatetimeIndex
|
domain |
str or None
|
None |
"finance" fixes the gap threshold at 5.0 days. Anything else derives it from your data. |
Returns a list of Issue objects (possibly empty).
Step 1 — duplicates (PRF004). If any timestamp appears more than once, raise CRITICAL immediately. Then drop the duplicates internally, keeping the first, so the gap arithmetic that follows is valid.
Why CRITICAL: a duplicate timestamp does not announce itself. df.rolling("5D") silently includes both rows. df.shift(1) silently returns the wrong neighbour. df.resample() silently aggregates them together. Nothing errors; the numbers are simply wrong. It must be resolved before modeling.
Step 2 — gap sizes. Compute the difference between each pair of consecutive timestamps, converted to days.
Step 3 — pick the threshold. This is where domain matters:
domain == "finance" -> threshold = 5.0 days (fixed)
otherwise -> threshold = 3 × median gap (adaptive)
(falls back to 1.0 if the median gap is 0)
The fixed 5.0 for finance exists because markets close. A daily equity series has a 3-day gap over every weekend and longer over holidays; that is normal, not a defect. Five days lets ordinary weekends and single holidays pass while still catching a genuine multi-day outage.
For everything else the threshold adapts to your data. A sensor sampling every 10 minutes gets a threshold of 30 minutes; a monthly series gets roughly 90 days. You do not have to know your sampling rate in advance.
Step 4 — large gaps (PRF001). Any gap at or above the threshold is flagged, as a single WARNING for the column-less dataset level, listing up to five example locations.
Step 5 — gap clusters (PRF005). Run-length encoding over the "is this gap large?" boolean sequence. Two or more consecutive large gaps form a cluster.
The distinction matters. One large gap is usually a holiday. Several large gaps in a row means your feed was down for a stretch, or the source changed its sampling regime — a structural event, not a calendar quirk.
PRF004 — Duplicate timestamps. CRITICAL. column is None (dataset-level).
| Evidence key | Meaning |
|---|---|
duplicate_count |
Number of rows involved (counts all members of each duplicate set, not the excess) |
examples |
Up to 5 duplicated timestamps, as strings |
PRF001 — Large gaps. WARNING. column is None.
| Evidence key | Meaning |
|---|---|
gap_count |
How many gaps exceeded the threshold |
maximum_gap_days |
The single largest gap, in days |
locations |
Up to 5 timestamps following a large gap |
PRF005 — Clustered gaps. WARNING. column is None.
| Evidence key | Meaning |
|---|---|
cluster_count |
Number of runs of 2+ consecutive large gaps |
max_consecutive_gaps |
Longest such run |
cluster_start_locations |
Up to 5 cluster start timestamps |
- The DataFrame is empty
- There are fewer than 2 rows (no gaps exist to measure)
- All gaps fall below the threshold
import pandas as pd
from tsauditor.profiler.frequency import audit_frequency
idx = pd.DatetimeIndex([
"2024-01-01",
"2024-01-02", "2024-01-02", # duplicate
"2024-01-03",
"2024-01-20", # 17-day gap
"2024-01-21",
])
df = pd.DataFrame({"x": [1, 2, 3, 4, 5, 6]}, index=idx)
for issue in audit_frequency(df):
print(issue.code, issue.severity, issue.evidence)PRF004 critical {'duplicate_count': 2, 'examples': ['2024-01-02 00:00:00']}
PRF001 warning {'gap_count': 1, 'maximum_gap_days': 17.0, 'locations': ['2024-01-20 00:00:00']}
Read duplicate_count: 2 carefully — it means two rows participate in the duplication, not that there are two extra rows. Only one timestamp is repeated, which is why examples has a single entry.
PRF005 did not fire: there is one large gap, and a cluster requires at least two consecutive ones.
Adaptive thresholds need enough data. With only a handful of rows the median gap is unstable, so 3 × median can be far too tight or too loose. Prefer domain="finance" (or a longer series) for short frames.
The finance preset assumes daily data. The 5.0-day threshold is calibrated for a daily equity series. Intraday financial data will produce a flood of false negatives — every overnight gap passes unnoticed. For intraday data, leave domain=None and let the adaptive rule do its job.
Regular gaps are not distinguished from irregular ones. A perfectly regular weekly series has a 7-day gap between every pair of rows; with domain=None the adaptive threshold handles that correctly (21 days), but with domain="finance" every single gap would be flagged.
Two separate problems, per column: an overall missing rate that is too high, and NaNs that arrive in consecutive runs.
audit_missing(
df: pd.DataFrame,
cluster_threshold: int = None,
missing_rate_threshold: float = 0.30,
domain: str = None,
) -> list| Parameter | Type | Default | What it does |
|---|---|---|---|
df |
pd.DataFrame |
required | Must have a DatetimeIndex
|
cluster_threshold |
int or None
|
None |
Minimum consecutive NaNs to count as a cluster. None derives it from domain. |
missing_rate_threshold |
float |
0.30 |
Flag a column whose missing proportion reaches this. 0.30 = 30%. |
domain |
str or None
|
None |
"finance" sets cluster_threshold to 5; anything else sets it to 3. |
Why two checks and not one. A column that is 20% missing at random is annoying but usually survivable — interpolation works fine. A column that is 20% missing in one continuous block is a different problem entirely: something was broken for a stretch of time, interpolation across the whole block is fabrication, and the missingness itself may carry information. Splitting these into PRF006 (rate) and PRF002 (structure) lets you respond appropriately to each.
PRF006 — high missing rate. Simply missing_count / total_rows >= missing_rate_threshold.
PRF002 — clustered missing. Run-length encoding over the NaN mask, vectorized with NumPy rather than looped. Any run of length >= cluster_threshold counts as a cluster.
Why the domain thresholds differ. finance = 5, everything else = 3. A daily price series legitimately has runs of missing values across long weekends and holidays, so a tolerance of 5 avoids flagging the calendar. A sensor sampling continuously has no such excuse — three consecutive missing readings already suggests a dropout, so the tolerance is tighter.
PRF006 — High missing rate. WARNING. column is set.
| Evidence key | Meaning |
|---|---|
missing_count |
Absolute number of NaNs |
missing_percentage |
As a percentage, rounded to 2dp |
threshold_percentage |
The threshold it crossed |
PRF002 — Clustered missing values. WARNING. column is set.
| Evidence key | Meaning |
|---|---|
missing_percentage |
Overall missing rate for the column |
longest_consecutive_run |
Length of the longest NaN run |
cluster_count |
How many runs met or exceeded the threshold |
first_occurrence |
Timestamp where the first qualifying cluster starts |
cluster_threshold |
The threshold actually used |
Note that longest_consecutive_run is the longest run in the column overall, not the longest qualifying one. In practice these coincide, since the longest run is necessarily at or above the threshold whenever any run is.
- The column has zero missing values (skipped immediately)
- The column is non-numeric
- Missing rate is below
missing_rate_thresholdand no run reachescluster_threshold - The DataFrame is empty
import pandas as pd, numpy as np
from tsauditor.profiler.missing import audit_missing
idx = pd.date_range("2024-01-01", periods=20, freq="D")
values = [1.0, 2, 3, np.nan, np.nan, np.nan, np.nan, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
df = pd.DataFrame({"sensor": values}, index=idx)
for issue in audit_missing(df):
print(issue.code, issue.column, issue.evidence)PRF002 sensor {'missing_percentage': 20.0, 'longest_consecutive_run': 4, 'cluster_count': 1, 'first_occurrence': '2024-01-04 00:00:00', 'cluster_threshold': 3}
Only PRF002 fired. The column is 20% missing, which is below the 30% rate threshold, so PRF006 stayed quiet — but those four NaNs are consecutive, which exceeds the default cluster threshold of 3. This is exactly the case the two-check split exists for: the rate is acceptable, the shape is not.
The 30% rate threshold is arbitrary and not domain-adjusted. A column that is 29% missing gets no PRF006. If your tolerance differs, pass missing_rate_threshold= explicitly.
Non-numeric columns are invisible. A completely empty string column produces no issue at all.
Cluster length is measured in rows, not elapsed time. Three consecutive missing rows in an irregular series might span three minutes or three months. The check cannot tell the difference.
Columns whose statistical properties drift over time — specifically, columns that fail to reject the null hypothesis of a unit root under the Augmented Dickey-Fuller (ADF) test.
This is the most expensive check in the library. Skip it with scan(..., run_stationarity=False) when you do not need it.
A series is stationary when its mean and variance do not systematically change over time. Daily temperature is roughly stationary: it fluctuates, but around a stable level. A stock price is not: it wanders, and the level it wanders around today has nothing to do with the level five years ago.
This matters because many time-series models — ARIMA, linear regression on lagged values, most classical forecasting — assume stationarity. Fed a non-stationary series they can produce a spurious regression: an impressive R² that reflects nothing but two variables both drifting upward over time.
The usual remedy is differencing: model the change from one step to the next rather than the level. Prices are non-stationary; returns usually are not.
The ADF test formalises this. Its null hypothesis is "this series has a unit root", which loosely means "this series wanders". A p-value above 0.05 means you cannot reject that null, so the series is treated as non-stationary.
audit_stationarity(
df: pd.DataFrame,
alpha: float = 0.05,
min_obs: int = 25,
max_lag: int = None,
domain: str = None,
) -> list| Parameter | Type | Default | What it does |
|---|---|---|---|
df |
pd.DataFrame |
required | Must have a DatetimeIndex
|
alpha |
float |
0.05 |
Significance level. Flag when p_value > alpha. |
min_obs |
int |
25 |
Columns with fewer non-null observations are skipped. |
max_lag |
int or None
|
None |
Caps the ADF lag search. This is your speed knob. |
domain |
str or None
|
None |
Accepted for API consistency; does not change behaviour. |
Per numeric column:
- Drop NaNs, then replace
inf/-infwith NaN and drop those too. - Skip if fewer than
min_obsobservations remain. - Skip if the column is constant —
adfullerraises"Invalid input, x is constant"on such input, and a constant series is trivially (if degenerately) stationary anyway. - Run
adfuller(series, maxlag=max_lag, autolag="AIC"). - If it raises
ValueErrororLinAlgErroron a near-singular input, skip that column rather than aborting the whole scan. - Flag INFO if
p_value > alpha.
Why max_lag is the speed knob. With max_lag=None, statsmodels picks a maximum lag from the sample size and then fits an OLS regression at every lag from 0 up to that maximum, selecting the best by AIC. On a long series that is a great many regressions, per column. Passing max_lag=4 caps the search at five fits, which is dramatically faster at a modest cost in test precision.
Why the severity is only INFO. Non-stationarity is not a data defect. A price series is supposed to be non-stationary; that is what prices do. This is a modeling advisory, not a bug report — hence INFO, and hence why it never counts against your health score.
PRF003 — Non-stationary column. INFO. column is set.
| Evidence key | Meaning |
|---|---|
adf_statistic |
The ADF test statistic (more negative = stronger evidence of stationarity) |
p_value |
The p-value, rounded to 4dp |
n_observations |
Observations used by the test after lag adjustment |
alpha |
The significance level applied |
- Fewer than
min_obs(default 25) non-null, finite observations - The column is constant
-
adfullerraised on degenerate input -
p_value <= alpha— the column is stationary -
run_stationarity=Falsewas passed toscan()
import pandas as pd, numpy as np
from tsauditor.profiler.stationarity import audit_stationarity
rng = np.random.default_rng(0)
idx = pd.date_range("2024-01-01", periods=200, freq="D")
df = pd.DataFrame({
"random_walk": np.cumsum(rng.normal(size=200)), # non-stationary by construction
"white_noise": rng.normal(size=200), # stationary by construction
}, index=idx)
for issue in audit_stationarity(df):
print(issue.code, issue.column, issue.evidence)PRF003 random_walk {'adf_statistic': -1.6696, 'p_value': 0.4468, 'n_observations': 199, 'alpha': 0.05}
Exactly one column flagged, and it is the right one. The random walk's p-value of 0.4468 is far above 0.05 — no evidence against a unit root. white_noise produced a p-value below 0.05 and was correctly left alone.
The ADF test has low power on short series. With a few dozen points it frequently fails to reject the null even for genuinely stationary data, producing a false PRF003. Treat results on short series with suspicion.
A single trend break can look like a unit root. ADF is known to mistake a structural break in the mean for non-stationarity. If your series has a regime change, expect PRF003 whether or not it is warranted.
Flagging is expected and often correct. Price columns should be flagged. PRF003 on a price series is the tool working, not a problem to fix.
domain does nothing here. It is accepted so every detector has the same signature shape, but it has no effect on the test.