---
title: "dataprep: design philosophy and preprocessing methodology"
output: rmarkdown::html_vignette
vignette: >
  %\documentclass{article}
  %\VignetteIndexEntry{dataprep: design philosophy and preprocessing methodology}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse  = TRUE,
  comment   = "#>",
  fig.align = "center",
  fig.width = 6,
  fig.height = 5.5,
  out.width = "75%",
  fig.retina = 2
)
```

```{r}
library(dataprep)
```

## The problem this package solves

Preprocessing high-resolution instrument data is not a pipeline you
can assemble from a grab-bag of one-shot functions. The order in
which steps are applied, and the physical constraints each step
enforces, determine whether the final table is meaningful. A
percentile filter applied before gap detection will delete a
legitimate extreme before anyone notices it sits in a 6-hour hole.
A linear interpolation run before observation deletion will
silently blend two physically distinct regimes into a value that
does not exist in nature.

`dataprep` is built around a specific claim: **the reliability of a
substituted value is bounded by the time distance to its nearest
observed anchor.** Every design decision below follows from that
claim.

## The four-step pipeline

```{r, echo = FALSE, out.width = "70%"}
knitr::include_graphics("figures/fig1_pipeline.png")
```

The pipeline processes every numeric channel in this fixed order:

1. **Variable deletion** (`varidele`) — drop columns that cannot
   be reliably interpolated.
2. **Observation deletion** (`obsedele`) — drop rows whose
   remaining gaps are too long to bridge.
3. **Conditional extremum outlier removal** (`condextr`) — remove
   individual points that lie outside the local distribution.
4. **Short-period grouping interpolation** (`shorvalu`) — fill the
   remaining gaps, but only within short segments.

All four steps are wrapped by `dataprep()` for one-call use:

```text
varidele()  →  obsedele()  →  condextr()  →  shorvalu()
```

Steps 2 and 3 are re-applied in alternating rounds (`interval`
rounds of marking, then one round of deletion, repeated `times`
times). The reasoning behind the ordering is in the sections
below.

## Variable deletion: three principles

The variable-deletion step is not a simple "drop columns with too
many `NA`s" filter. Three criteria must be reconciled:

### 1. Signal-to-noise ratio as the primary threshold

A size bin carries information only if it has enough observations
to support a reliable signal-to-noise estimate. A column that is
missing more than `fraction` of the time cannot support such an
estimate, and `varidele()` removes it before downstream steps have
to waste effort on it. The default `fraction = 0.25` reflects the
empirical observation that a channel missing a quarter of its
samples has a noise floor comparable to its signal.

### 2. Physical relevance

Not every channel that passes the `NA` filter is worth keeping. In
size-resolved aerosol data, a channel dominated by inlet
transmission cutoffs or near-zero counts contributes noise, not
signal. `varidele()` handles the missing-fraction case; the
companion functions `filter_low_var()`, `filter_high_cor()`, and
`phys_filter()` handle the physical and statistical cases that
`varidele()` cannot. Together they cover variable selection as a
whole.

### 3. Missing-run length, not just missing fraction

A channel with 5% missing data scattered as isolated points is
perfectly usable. A channel with 5% missing data clustered into one
four-hour gap is not — after deletion and interpolation, that
channel contributes one long unanchored stretch to the pipeline.
`varidele()` measures the missing fraction, but the actual decision
is enforced by `obsedele()` on *run length* rather than on
*fraction*. Variable deletion exists so that a column that cannot
pass the run-length criterion does not silently lower the quality
of the interpolation step.

## Observation deletion: two principles

`obsedele()` removes rows, but the physical motivation is the same
as for variable deletion: a value can only be reliably
interpolated if there is an observed anchor close enough to
support it.

### 1. Detect the pattern, don't filter the points

The step does not ask "does this value look like an outlier?" It
asks "does this row have a trustworthy anchor within `half` minutes
on at least one side, in *every* selected column?" A row fails this
test if *any* selected column has a missing run longer than
`half` minutes on both sides. This is a structural check, not a
value-based one — outliers are handled by the next step.

### 2. The half-hour window is the physical mixing time

Why `half = 30` minutes? For well-mixed aerosol near the surface, a
half-hour is short enough that the surrounding observations still
describe the same air mass; longer than that, and two sides of the
gap can belong to different emissions regimes or different boundary
layer states. The window is implemented **symmetrically**: both
sides of the gap must be longer than `half` minutes for the row to
be deleted. If a valid anchor exists on either side within `half`,
the row is retained.

## How it is implemented: anchor-based scan

The 0.1.8 C++ backend (`obsedele_cpp`) implements the criterion
directly, with an anchor-based scan. The algorithm for one
**subset** (a group, or a time period between two gaps) and one
column is:

1. Walk the subset in chronological order. Record the position of
   every non-missing value; these are the anchors.

2. For every missing value, compute two numbers:

   * `dl` — the time distance to the nearest anchor on the left,
   * `dr` — the time distance to the nearest anchor on the right.

3. Delete the row only if `dl > half_seconds` **and**
   `dr > half_seconds`.

If a side has no anchor at all (the run touches the subset
boundary), the corresponding distance is `+Inf`, so a row at the
series boundary is deleted only when the surviving side is also
beyond `half`.

This is mathematically identical to the running-mean criterion used
in earlier releases, but the algorithm has three practical
advantages:

* **`O(n)` time, `O(1)` extra allocation per column.** The 0.1.5 grid
  expansion allocated an `L`-point vector per subset, where `L` is
  the number of grid points, not the number of observations. For
  10-minute sampling on a 1-minute grid, `L` is roughly 10× the
  observation count. The anchor scan never materialises a grid.

* **Runs at full memory bandwidth.** Both the anchor list and the
  per-row distance computation are simple sequential scans over a
  single column. This is what allows the 0.1.8 implementation to
  process the full year of SMEAR I Varrio data (49,422 rows × 61
  channels) in 0.05 s on Ubuntu 25.10, against 11.6 s in
  0.1.5, and in 0.035 s against 22.5 s on Windows 11 Pro
  for Workstations.

* **Scales linearly with column count.** Each column is processed
  independently, so the loop parallelises over columns with
  OpenMP without any shared mutable state.

## Implementation evolution

The retention criterion has been stable across releases, but the
implementation has improved steadily. The table below summarises
the three generations:

| Release | Strategy | Complexity |
|---|---|---|
| 0.1.0 | Borrowed running mean: expand the series onto a regular grid with `tidyr::complete()`, compute a 59-minute centred moving average on a temporary column, and use its emptiness pattern to flag long runs. | `O(grid length)` per subset |
| 0.1.5 | Run-length encoding: use `data.table::rleid()` and `rowid()` to collapse consecutive `NA`s into runs, then compare each run length to the number of grid points covered by `half` minutes. | `O(n)` time, `O(n)` temporary storage |
| 0.1.8 | Anchor scan: for each missing value, look up the nearest non-missing anchor on each side and compare the two time distances directly. No grid, no run-length state. | `O(n)` time, `O(1)` extra allocation per column |

Each generation produces the same deletion decision on the same
input, but the constant factors shrink. The 0.1.8 anchor scan is
the first version that is fast enough to run interactively on
full-year data.

## Why "both sides" and not "one side"?

A one-sided criterion would delete rows at the boundary of every
run, even when the surviving side has a valid anchor right next to
the row. The 0.1.5 implementation effectively did this by merging
columns before computing runs, which over-deleted boundary rows.
The 0.1.8 rewrite scans each column independently and requires
*both* distances to exceed `half` before deletion. See
`vignette("dataprep-migration")` for the full upgrade guide.

## Subsets: group and period

The anchor scan is applied per **subset**. A subset is one of two
things, depending on the input:

* If a `group` column is provided, each group is a subset.
* Otherwise, the timeline is split into periods separated by
  gaps longer than `threshold_sec` (default `half × 60` seconds).
  Repeated timestamps stay in the same period.

Subsets matter because the retention criterion is local: a row at
the end of one period must not be evaluated against an anchor that
lies on the other side of a long intra-day gap.

## Re-applied after outlier deletion

Observation deletion is applied **twice** in the pipeline: once
before outlier removal and once after. This is not a bug. Removing
outliers converts values to `NA`, which can create new long `NA` runs
that did not exist in the original data. The second pass catches
these. Without the re-application, the interpolation step would
silently extend across gaps that only appeared after outlier
removal.

## Outlier removal: two principles

```{r, echo = FALSE, out.width = "70%"}
knitr::include_graphics("figures/Outlier_Comparison.png")
```

### 1. Per-point decisions, not one-shot thresholds

The default `condextr` method does not delete every value above the
99.5th percentile. It judges each candidate in context: the
current extreme point is compared against a threshold built from a
high percentile, an **error margin** (`top.error`, `bottom.error`), and
a **magnitude margin** (`top.magnitude`, `bottom.magnitude`). A value
that is a global maximum but sits well within the local
distribution of a two-modal series is retained; a value that is
locally extreme is removed even if it is not globally extreme.

This is closer to a semi-supervised decision rule than to an
unsupervised cutoff. The comparison against `percoutl` — the
traditional percentile method — is shown in the figure above. The
traditional rule clips legitimate values at both ends of the
distribution and then produces new outliers at the boundary
between observed and interpolated points. The conditional-extremum
rule removes only the extreme point of each window and leaves no
artificial outlier behind.

### 2. Outlier deletion inherits the gap-length constraint

Every value removed by `condextr` becomes an `NA`. If a column had
a short `NA` run before outlier removal, and outlier removal punches
holes through the interior of the series, the resulting `NA` run can
be much longer than the original. This is why `obsedele` is
re-applied immediately after `condextr` in every round of the
`condextr` loop.

The loop structure is:

```text
for round in 1..times:
    for i in 1..interval:
        mark outliers in every column
    delete observations whose NA runs now exceed half
```

`interval` controls how aggressively `condextr` marks points
between two observation-deletion rounds; `times` controls how many
rounds the loop runs. Both are exposed as arguments in
`dataprep()` and can be tuned by `optisolu()` when a
ground-truth-like reference (`percoutl`) is available.

## Missing-value interpolation: two principles

```{r, echo = FALSE, out.width = "70%"}
knitr::include_graphics("figures/Time_Series_Interpolation_Final.png")
```

### 1. Vertical, not horizontal

The most important design decision in the interpolation step is
that `shorvalu()` interpolates **within a column along time**,
never across columns at the same time point.

The reason is physical. Size-resolved aerosol measurements are
strongly non-stationary in the size dimension: a nucleation burst
shifts the entire distribution toward small diameters for an hour or
two, then decays. Interpolating a missing value in the 7.94 nm
channel from its neighbours in the 6.31 and 8.91 nm channels
silently assumes that the *shape* of the distribution is constant
over the gap — an assumption that fails exactly when the
measurement matters. Horizontal interpolation therefore tends to
create new outliers at the segment boundaries, which are then
flagged as outliers by the next round and deleted, in a cycle that
silently erodes the sample size.

Vertical interpolation uses only observations of the same physical
quantity. It has no hidden stationarity assumption beyond the local
one enforced by the half-hour window.

### 2. Segment the series before interpolating

Naive time-series interpolation runs a continuous interpolator
across the entire gap, no matter how long it is. This silently
mixes two physically distinct regimes and produces values that do
not exist in nature — a smoothed average of two different air
masses, attributed to a time point that belonged to neither.

`shorvalu()` splits each series into short segments separated by
gaps longer than `intervals` minutes, and interpolates within each
segment independently. The right panel of the figure above
shows the result: the interpolated values (orange) are drawn from
the segment's own anchors, not from across the gap. The middle
panel shows the naive alternative, where the interpolation
contaminates the whole gap.

This internal segmentation does not depend on the upstream
cleaning step. Even if `obsedele()` were skipped entirely,
`shorvalu()` would still cut the series at every long gap and
interpolate within each segment only. The two mechanisms are
complementary, not redundant:

* **Upstream cleaning (`obsedele`) is a preventive measure.** It
  removes rows with long `NA` runs, which reduces the number of
  segments `shorvalu()` has to handle and preserves more sample
  rows on the "keep" side of the trade-off.

* **Internal segmentation (`shorvalu`) is a defensive measure.**
  It guarantees correctness at the function level, regardless of
  what the caller did before.

This is what "short-period grouping interpolation" means:
interpolation is applied only where the value can be read from a
nearby observation in the same segment.

## Why this order, and not any other

| Order | Consequence |
|---|---|
| `shorvalu` before `obsedele` | Interpolation crosses long gaps; new outliers appear at segment boundaries. |
| `condextr` before `varidele` | Outliers are detected within columns that will later be dropped; wasted effort, and parameters tuned against the wrong sample. |
| `obsedele` before `condextr` | **Correct**: anchors are guaranteed before outlier decisions, so `condextr` judges each candidate against a meaningful local distribution. |
| One pass of `obsedele` only | `NA` runs created by outlier removal are not detected; `shorvalu` silently extends across gaps that did not exist in the input. |
| Horizontal interpolation | Creates new outliers at every gap; the next round of outlier detection deletes them, eroding the sample. |

The fixed order is `varidele` → (`obsedele` → `condextr`) ×
`times` → `shorvalu`. Every wrapper in the package respects this
order:

* `prep_fit()` and `prep_transform()` enforce it across train/test
  splits.
* `dataprep()` chains all four steps for one-call use.
* `dry_run()` reports the effect of each step without modifying the
  caller's data.

## Evaluation metrics

The pipeline is a constraint-satisfaction procedure with four
measurable outcomes. Every parameter choice in `dataprep()` and
`optisolu()` is judged against these:

### Number of values deleted

The total count of values set to `NA` by `varidele`, `obsedele`,
and `condextr`, in units of original observations. `dry_run()`
reports `na_before`, `na_after`, and the difference for each step.

### Sample retention

The number of rows that survive the cleaning pipeline, divided by
the input row count. On the SMEAR I Varrio 2025 full-year dataset
this is 46,063 / 49,422 ≈ 93.2% after the default settings.

### Residual outliers

Values that survive the pipeline and are still outliers by any
external criterion. `optisolu()` uses `percoutl` as the reference:
a candidate parameter combination is considered "better" only if it
produces strictly fewer residual outliers than the traditional
method at the same outlier-removal ratio.

### Newly created outliers

Values that did not exist in the input but appear in the output as
a direct consequence of an earlier step (interpolation,
imputation, or scaling). This is the metric on which horizontal
interpolation fails most visibly; it is one of the reasons
`shorvalu` is restricted to vertical, short-period interpolation.

### Uncertainty analysis

For each output column, the standard deviation of the cleaned
series is compared against (a) the input series, and (b) the output
of the previous release. A large change in standard deviation
indicates that a step has shifted the distribution rather than
merely denoising it, and is a signal to re-examine the parameter
choices. `optisolu()` exposes this comparison through the `snr`
column of its return value.

## The package beyond the four steps

The four-step pipeline is the core of `dataprep`, but the package
ships a wider toolkit. Every function operates on a data frame (or
matrix, or plain vector where it makes sense), follows a consistent
argument convention (`data`, `cols`, `group`, `verbose`), and is
implemented either in C++ or in R on top of a small set of shared
helpers (`resolve_numeric_cols()`, `to_numeric_matrix()`,
`check_numeric_cols()`). This is what makes the package usable as
a general-purpose data-frame preprocessing layer, not just a
specialist tool for one dataset.

The helper families are:

* **Cleaning** — `varidele`, `obsedele`, `condextr`, `percoutl`,
  `detect_outliers`, `winsorize`, `phys_filter`, `filter_high_cor`,
  `filter_low_var`, `deduplicate`, `validate_data`,
  `balance_panel`.

* **Missing values** — `na_diagnose`, `impute_missing`, `shorvalu`.

* **Transformation** — `transform_data`, `log_returns`,
  `bin_data`, `encode_categorical`, `zerona`.

* **Time series** — `create_lags`, `roll_apply`, `resample_time`,
  `detrend_ts`, `remove_diurnal_cycle`, `decompose_ts`,
  `drift_detect`, `day_night_flag`, `season_flag`.

* **Reshaping** — `melt`, `dcast`.

* **Reporting and workflow** — `descdata`, `descplot`, `percdata`,
  `percplot`, `data_report`, `dry_run`, `prep_fit`,
  `prep_transform`, `sample_data`.

Each function is documented on its own help page. This vignette
explains *why* the pipeline has the shape it does; the help pages
explain *how* each function is called.

## Related documentation

* **Cleaning pipeline walkthrough** — `vignette("dataprep-cleaning")`.
  Step-by-step execution on a real dataset.

* **Performance and cross-engine consistency** —
  `vignette("dataprep-performance")`. Benchmark tables and
  8-engine consistency checks.

* **Upgrading from 0.1.5 to 0.1.8** —
  `vignette("dataprep-migration")`. Behaviour changes and the
  migration checklist.

* **Leakage-free workflow** — `vignette("dataprep-workflow")`.
  `prep_fit()` / `prep_transform()` in practice.

* **Fast reshaping** — `vignette("dataprep-melt-dcast")`.

## Funding

This work was supported by the National Natural Science Foundation
of China (No. 12301674).

## Session info

```{r}
sessionInfo()
```
