CellTriage / docs /02_feature_engineering.md
Sarvarbek13's picture
CellTriage QC operator console - inference only, CPU-bound classical ML
749bffa verified
|
Raw
History Blame Contribute Delete
24.3 kB
# 02 - Feature engineering
[Back to 01: Dataset and EDA](01_dataset_and_eda.md) | [README](../README.md)
Covers **Phase 4**, the scientific core: 57 features across 7 groups, built at
each of 5 diagnostic budgets under a structurally enforced no-leakage rule.
The feature tables below are **generated from the code registry**
([`outputs/reports/feature_registry.json`](../outputs/reports/feature_registry.json)),
so this document cannot drift from what is actually computed.
---
## 1. The leakage rule is enforced by construction
For budget *N*, features may use only cycles 1..*N*. That is guaranteed
structurally, not by discipline:
1. [`builder.py`](../src/features/builder.py) slices **all three** per-cell
frames to `cycle_measured <= max_cycle` **once, at the top**.
2. It **asserts** the slice maximum does not exceed the budget.
3. It passes **only those slices** to the group modules.
Group modules accept DataFrames -- never a cell id, never a path -- so they have
no route back to unsliced data. `build_cell_features` is therefore a pure
function of its frames, which is what makes the test below possible.
### The test has demonstrated teeth
[`tests/test_leakage.py`](../tests/test_leakage.py) computes a cell's features,
then **destroys every cycle beyond N** -- shuffling rows, rescaling values and
adding noise, all three together because a statistic can be invariant to any one
alone -- and demands bit-identical output.
A passing test proves nothing unless it can fail, so the suite carries two
controls:
- **Negative control.** A deliberately leaky statistic *does* change under the
corruption, confirming the corruption is effective.
- **Mutation test.** The builder's slice is deliberately weakened to `3 x N`;
**45 of 57 features change**, so the test detects the realistic regression.
**A limitation found while building this, and closed.** The invariance test can
only catch leaks that flow through the frames passed in. An early mutation
attempt that read from a captured global was **not** detected: because the
corruption produces copies, the leaky path saw identical data both times. A
group reading from disk would evade the test entirely. That vector is now closed
by a **static guard** asserting no feature module references `read_parquet`,
`open(`, `CELLS_DIR` or `load_cell_frames`.
## 2. Cycle numbering: `cycle_measured`, not the raw index
Phase 2 established that the all-zero placeholder row exists in batch 1 (46/46
cells) but not in batches 2-3 (0/89). Slicing "cycles 1..N" on the raw index
would hand batch-1 cells one *fewer* measured cycle at every budget -- a
systematic, batch-correlated bias in exactly the variable RQ4 tests for.
Every feature therefore indexes on `cycle_measured`, which counts only measured
cycles from 1, identically in every batch. It is attached to all three frames at
parse time and re-derived after the continuation join.
**One documented consequence.** The literature's "discharge capacity at cycle 2"
is a raw index, which in batch 1 is the *first* measured cycle. Here
`q_at_cycle_2` is the second *measured* cycle for every cell. That is a
one-cycle offset from a naive raw reading in batch 1 only, it is uniform across
the cohort, and it removes a batch-dependent inconsistency rather than
inheriting one. It does not affect Gate 1, which reproduced at R^2 = 0.859 using
measured cycles 10 and 100.
## 3. The plausibility mask
Physically impossible readings become `NaN` **before any feature sees them**,
using the same bounds the Phase 2 validator applied, applied identically to
every cell. Masking by *physics* rather than by cell name is what makes this
defensible: it is a stated rule about what a real measurement can be, not a
patch for an inconvenient cell. Imputation happens **inside** the CV pipeline,
on training-fold statistics only.
### What the mask caught, and why it mattered
Phase 4 found a **third** corrupted channel on b1c18, beyond the two Phase 2
identified: its *median* cycle duration reads **2236 minutes (37 hours)** against
a cohort median of **51.8 min**.
This was not cosmetic. Thermal exposure is an *integral over time*, so a broken
clock propagates straight into Group D. b1c18's `thermal_exposure` came out
**71x the cohort median**, and that one feature drove a cross-validated ridge
prediction to **-50.9** against a true value of 2.84, destroying an entire fold
(RMSE 10.75 against ~0.07 for the others).
The bound was derived from the data, not guessed:
| Measurement (cycles 1-100, 135 cells) | Value |
|---|---|
| Median cycle duration, median across cells | 51.8 min |
| 118 of 135 cells never exceed | 78.6 min |
| 16 cells with one isolated long cycle | ~460 min (plausibly a real rest) |
| **b1c18 median** | **2236 min** |
`max_cycle_duration_minutes: 300` sits ~6x the median and ~4x above the ceiling
of the 118 clean cells. It is applied **per cycle**, so a cell with one genuine
long rest keeps its other 99.
After the fix, cross-validated RMSE across budgets fell from 0.13-2.21 to
**0.075-0.16** with consistent folds. *(Sanity check only -- Phase 6 owns
modelling.)*
## 4. Corrections to the Phase 4 plan, from measurement
Two flags raised in the plan did not survive contact with the data. Recorded
rather than quietly dropped:
| Plan said | Measurement says |
|---|---|
| Group A is **most exposed** to b1c18, whose voltage spans 0.736-6.606 V | **Wrong.** b1c18's `Qdlin` spans [-0.0004, 1.0523] Ah, indistinguishable from peers, and its `log_abs_dq_var` sits at the **60th percentile**. `Qdlin` derives from the *discharge* segment; b1c18's corruption is in the charge phase. |
| The `fig04` DeltaQ(V) spike near 3.09 V is b1c18 | **Wrong.** It is **b1c41**, the only cell with a localized DeltaQ(V) spike (8.7 sigma). Corrected in [docs/01](01_dataset_and_eda.md). |
Groups C and E were confirmed **unaffected** by b1c18: its internal resistance
(0.0158-0.0199 Ohm) and charge time (9.87-9.97 min) are plausible and
peer-consistent.
### A known fragility, left honest
`dq_kurtosis` for **b1c41** is 112.9 against a cohort inter-quartile range of
[-1.10, -0.30], because its DeltaQ(V) contains one localized spike. The feature
is **correct** -- it faithfully reports a heavy-tailed curve -- and it has
**not** been clipped. Suppressing it would be fitting the data to the method.
The consequence is that *linear* models extrapolate badly from this cell.
Phase 6 must therefore use robust preprocessing or outlier-insensitive
estimators. `configs/models.yaml` already declares Huber regression and tree
ensembles alongside the linear models, and this is a concrete reason to compare
them rather than a stylistic preference.
## 5. Feature groups
| Group | Module | Features | In-line | Process recipe |
|---|---|---:|:--:|:--:|
| **A_curve** | `curve_features.py` | 12 | yes | no |
| **B_degradation** | `degradation.py` | 10 | yes | no |
| **C_resistance** | `resistance.py` | 7 | yes | no |
| **D_thermal** | `thermal.py` | 8 | NO | no |
| **E_charge_dynamics** | `charge_dynamics.py` | 6 | yes | no |
| **F_protocol** | `protocol.py` | 8 | yes | **yes** |
| **G_interactions** | `interactions.py` | 6 | yes | no |
| | **total** | **57** | | |
### Measurable in-line on a production line
45 features requiring no instrumentation beyond what a production cycler already records.
| Feature | Group | Formula | Rationale |
|---|---|---|---|
| `dq_integral` | A_curve | sum(DeltaQ(V)) over the voltage grid | Total accumulated capacity difference across the discharge window; a magnitude complement to the shape statistics above. |
| `dq_kurtosis` | A_curve | kurtosis(DeltaQ(V)) where DeltaQ(V) = Q_N(V) - Q_base(V) on the common voltage grid | Shape of the capacity-voltage difference curve. Variance is the single strongest published early-life predictor; minimum locates the largest local capacity loss; skewness and kurtosis describe whether that loss is concentrated at particular voltages, which distinguishes degradation modes. |
| `dq_mean` | A_curve | mean(DeltaQ(V)) where DeltaQ(V) = Q_N(V) - Q_base(V) on the common voltage grid | Shape of the capacity-voltage difference curve. Variance is the single strongest published early-life predictor; minimum locates the largest local capacity loss; skewness and kurtosis describe whether that loss is concentrated at particular voltages, which distinguishes degradation modes. |
| `dq_min` | A_curve | min(DeltaQ(V)) where DeltaQ(V) = Q_N(V) - Q_base(V) on the common voltage grid | Shape of the capacity-voltage difference curve. Variance is the single strongest published early-life predictor; minimum locates the largest local capacity loss; skewness and kurtosis describe whether that loss is concentrated at particular voltages, which distinguishes degradation modes. |
| `dq_skew` | A_curve | skew(DeltaQ(V)) where DeltaQ(V) = Q_N(V) - Q_base(V) on the common voltage grid | Shape of the capacity-voltage difference curve. Variance is the single strongest published early-life predictor; minimum locates the largest local capacity loss; skewness and kurtosis describe whether that loss is concentrated at particular voltages, which distinguishes degradation modes. |
| `dq_var` | A_curve | var(DeltaQ(V)) where DeltaQ(V) = Q_N(V) - Q_base(V) on the common voltage grid | Shape of the capacity-voltage difference curve. Variance is the single strongest published early-life predictor; minimum locates the largest local capacity loss; skewness and kurtosis describe whether that loss is concentrated at particular voltages, which distinguishes degradation modes. |
| `log_abs_dq_kurtosis` | A_curve | log10(\|kurtosis(DeltaQ(V))\|) | Log scale. These quantities span several orders of magnitude across cells; the source literature models them in log space and an unlogged feature lets a single high-variance cell dominate a linear fit. |
| `log_abs_dq_mean` | A_curve | log10(\|mean(DeltaQ(V))\|) | Log scale. These quantities span several orders of magnitude across cells; the source literature models them in log space and an unlogged feature lets a single high-variance cell dominate a linear fit. |
| `log_abs_dq_min` | A_curve | log10(\|min(DeltaQ(V))\|) | Log scale. These quantities span several orders of magnitude across cells; the source literature models them in log space and an unlogged feature lets a single high-variance cell dominate a linear fit. |
| `log_abs_dq_skew` | A_curve | log10(\|skew(DeltaQ(V))\|) | Log scale. These quantities span several orders of magnitude across cells; the source literature models them in log space and an unlogged feature lets a single high-variance cell dominate a linear fit. |
| `log_abs_dq_var` | A_curve | log10(\|var(DeltaQ(V))\|) | Log scale. These quantities span several orders of magnitude across cells; the source literature models them in log space and an unlogged feature lets a single high-variance cell dominate a linear fit. |
| `q_discharge_curve_area_N` | A_curve | sum(Q_N(V)) over the voltage grid | Absolute area under the interpolated discharge curve at the budget cycle, capturing overall accessible capacity independently of the difference curve. |
| `cycle_at_q_max` | B_degradation | measured cycle at which Q_discharge peaks | When the trajectory turns over. An early peak means net fade has already overtaken activation. |
| `q_at_cycle_2` | B_degradation | Q_discharge at measured cycle 2 | Early-life capacity, effectively the as-manufactured usable capacity once formation has settled. The reference point for all fade measures. |
| `q_at_cycle_N` | B_degradation | Q_discharge at the budget cycle N | Capacity at the moment the QC decision must be made. |
| `q_diff_N_2` | B_degradation | Q(N) - Q(2) | Absolute capacity change over the observation window. Small and noisy this early, which is precisely why curve-shape features outperform it. |
| `q_fade_rate` | B_degradation | (Q(N) - Q(2)) / (N - 2) | Capacity loss per cycle, normalising the above by window length so budgets are comparable. |
| `q_intercept` | B_degradation | least-squares intercept of Q(cycle) over the window | Fitted capacity at cycle 0; a noise-robust proxy for initial capacity. |
| `q_max` | B_degradation | max Q_discharge over the window | Peak capacity. Many cells gain capacity for tens of cycles as formation completes before net fade begins. |
| `q_max_minus_q2` | B_degradation | max(Q) - Q(2) | Size of that early capacity rise. A larger rise indicates continuing electrode wetting and activation. |
| `q_slope` | B_degradation | least-squares slope of Q(cycle) over the window | Trend estimate using every cycle rather than just the endpoints, so it is far less sensitive to noise in any single measurement. |
| `q_var` | B_degradation | variance of Q_discharge over the window | Trajectory roughness; elevated variance indicates unstable cycling behaviour. |
| `ir_at_cycle_2` | C_resistance | internal resistance at measured cycle 2 | Post-formation baseline resistance, set by the interphase built during formation. A high starting value indicates lithium already consumed. |
| `ir_at_cycle_N` | C_resistance | internal resistance at the budget cycle N | Resistance at the QC decision point. |
| `ir_diff_N_2` | C_resistance | IR(N) - IR(2) | Resistance growth over the window: the direct signature of continuing interphase growth and contact loss. |
| `ir_mean` | C_resistance | mean internal resistance over the window | Window-average level, robust to single-cycle measurement noise. |
| `ir_min` | C_resistance | min internal resistance over the window | Resistance floor. Cells typically fall to a minimum as wetting completes before rising; the floor is a cleaner baseline than any single cycle. |
| `ir_ratio_N_min` | C_resistance | IR(N) / min(IR) | Relative rise from the floor. Being dimensionless it is comparable across cells with different absolute resistance. |
| `ir_slope` | C_resistance | least-squares slope of IR(cycle) over the window | Rate of resistance rise, using every cycle rather than two endpoints. |
| `cc_time_fraction` | E_charge_dynamics | mean over cycles of (time in CC) / (total charge time) | Share of charging spent at constant current. As impedance rises the cell hits the voltage limit earlier and this fraction falls, making it a more specific impedance probe than total charge time. |
| `charge_time_diff_N_2` | E_charge_dynamics | charge_time(N) - charge_time(2) | Endpoint change in charge duration over the window. |
| `charge_time_mean` | E_charge_dynamics | mean charge time over measured cycles 2..N | Typical charge duration under a fixed protocol; a direct impedance proxy. |
| `charge_time_slope` | E_charge_dynamics | least-squares slope of charge time vs cycle | Whether charge time is drifting upward, the signature of growing polarization resistance. |
| `charge_time_var` | E_charge_dynamics | variance of charge time over measured cycles 2..N | Cycle-to-cycle instability in charge acceptance. |
| `cv_time_fraction` | E_charge_dynamics | mean over cycles of (time in CV) / (total charge time) | Complement of the above; the constant-voltage tail lengthens as the cell becomes harder to charge. |
| `policy_c1` | F_protocol | first-step charging C-rate | How aggressively the cell is charged at low state of charge, where lithium plating risk is highest. |
| `policy_c2` | F_protocol | second-step charging C-rate | Charging rate through the middle of the state-of-charge range. |
| `policy_c3` | F_protocol | third-step charging C-rate where the recipe defines one | Present only for multi-step recipes; NaN otherwise, and imputed inside the CV pipeline rather than filled with a fabricated rate. |
| `policy_c_max` | F_protocol | maximum C-rate used anywhere in the recipe | Peak stress the cell experiences during charging. |
| `policy_c_mean` | F_protocol | SOC-weighted mean C-rate across the recipe steps | Single summary of overall charging aggressiveness, weighting each rate by the fraction of charge delivered at it. |
| `policy_c_range` | F_protocol | policy_c_max - min C-rate in the recipe | Spread between the gentlest and harshest step; a flat recipe and a steeply stepped one can share a mean but stress the cell very differently. |
| `policy_n_steps` | F_protocol | number of steps in the recipe | Recipe complexity, distinguishing simple two-step from multi-step profiles. |
| `policy_q1_pct` | F_protocol | state of charge (%) at which the protocol switches step | Where the recipe hands over between rates; determines how much of the high-plating-risk region is traversed at the first rate. |
| `charge_time_slope_x_ir_slope` | G_interactions | charge_time_slope * ir_slope | Both are proxies for growing polarization resistance measured through different channels. Their product is large only when the two agree, suppressing the case where either drifts through measurement noise alone. |
| `ir_growth_per_cycle` | G_interactions | ir_diff_N_2 / (N - 2) | Resistance growth normalised by window length so that budgets are directly comparable; without it the raw difference grows mechanically with N. |
### NOT reliably measurable in-line - deployability caveat
12 features needing per-cell instrumentation that many lines do not have. A screening rule depending on these may not be deployable without additional hardware, which is why the distinction is tracked in the registry rather than left implicit.
| Feature | Group | Formula | Rationale |
|---|---|---|---|
| `temp_avg_slope` | D_thermal | least-squares slope of per-cycle mean temperature vs cycle | Whether the cell is progressively self-heating, which indicates rising internal impedance dissipating more energy. |
| `temp_max` | D_thermal | max of per-cycle maximum temperature over the window | Worst-case thermal excursion, which drives the fastest side reactions. |
| `temp_max_slope` | D_thermal | least-squares slope of per-cycle maximum temperature vs cycle | The same trend measured at the thermal peak, where it appears earliest. |
| `temp_mean` | D_thermal | mean of per-cycle average temperature over the window | Typical operating temperature of the cell. |
| `temp_min` | D_thermal | min of per-cycle minimum temperature over the window | Lower bound of the thermal envelope; with the maximum it gives the range. |
| `temp_range` | D_thermal | temp_max - temp_min | Thermal swing per cycle, a proxy for heat generated under load relative to the chamber setpoint. |
| `thermal_exposure` | D_thermal | sum over cycles of the integral of T dt within each cycle | Cumulative time-at-temperature, the quantity an Arrhenius rate law integrates. Computed from the within-cycle traces rather than from per-cycle averages so that cycles of differing duration are weighted correctly. |
| `thermal_exposure_per_cycle` | D_thermal | thermal_exposure / number of measured cycles | Rate rather than accumulation, so budgets are comparable. |
| `fade_per_thermal_exposure` | G_interactions | q_fade_rate / thermal_exposure | Capacity loss normalised by thermal load. A cell fading fast despite low thermal exposure is degrading for a non-thermal reason, which is diagnostically distinct. |
| `log_dq_var_x_ir_ratio` | G_interactions | log_abs_dq_var * ir_ratio_N_min | Combines loss of accessible lithium inventory (curve shape) with rising interfacial impedance, distinguishing cells losing capacity through inventory loss from those losing it through impedance rise. |
| `recipe_stress_x_thermal` | G_interactions | policy_c_max * thermal_exposure | Higher charging C-rates drive both ohmic heating and lithium plating. This links the commanded process stress to the thermal response it actually produced. PROCESS-RECIPE DERIVED: excluded from the no_recipe feature set with Group F. |
| `thermal_x_ir_growth` | G_interactions | thermal_exposure * ir_diff_N_2 | Side reactions are Arrhenius-activated AND proceed at the interphase. A cell that is both hot and building resistance is degrading by both mechanisms at once, which neither term captures alone. |
### Degenerate columns are reported, not dropped
Two columns are degenerate in this corpus: `policy_c3` is entirely `NaN` and
`policy_n_steps` is constant, because **every one of the 124 cells uses a
two-step charging recipe**. The three-step parsing path is retained because it is
correct and general, not because this dataset exercises it.
They are deliberately **left in the matrix**. Dropping a column for being
constant across the whole cohort is a decision taken with sight of every cell,
including those destined for test folds -- a mild form of fitting on all the
data. The variance filter in [`selector.py`](../src/features/selector.py)
removes them **per fold**, on training-fold statistics only, which is where that
decision belongs.
## 6. Selection is confined to CV folds - structurally
With n = 124, selecting on the full dataset is the fastest way to manufacture a
result that does not replicate. Every selector is an sklearn transformer with a
`fit`/`transform` split, composed by `build_selection_pipeline()`. As **Pipeline
steps**, scikit-learn guarantees `fit` sees only the training fold -- the
guarantee does not depend on this project remembering to arrange it. There is
deliberately **no** module-level `select_features(X, y)` helper, because that is
the API shape that invites full-data use.
`configs/features.yaml` sets `selection.fit_inside_cv_only: true`, and the schema
**rejects** the config if it is ever false.
| Step | Purpose | Supervised? |
|---|---|---|
| `SimpleImputer` | Median fill. `keep_empty_features=True` so all-NaN columns survive to the variance filter instead of being silently dropped, which would misalign every downstream feature name. | no |
| `VarianceFilter` | Remove constant and all-missing columns. | no |
| `CorrelationFilter` | Drop one of each pair above rho = 0.95. Curve features are collinear by construction, and collinearity destabilises both linear coefficients and the Phase 9 SHAP attributions that must support an auditable scrap decision. | no |
| `StabilitySelector` | Keep features chosen in at least 60% of 100 bootstrap resamples of the training fold. | **yes** |
| `MutualInfoSelector` | Rank by mutual information, capturing monotone-but-nonlinear relationships. | **yes** |
| `StandardScaler` | Scale. | no |
Both supervised selectors **raise** if fitted without labels, so they cannot be
quietly misused on unlabelled full data.
### A silent bug found and fixed here
`StabilitySelector` originally ranked features with
`pd.Series(sub[c]).corr(pd.Series(sub_y))`. **pandas correlates on the index**,
and the bootstrap subset carries the original row labels while the target gets a
fresh `RangeIndex` -- so the two were silently mis-paired.
The effect: `dq_var`, whose true |rho| is **0.908**, scored **0.023**. The single
strongest predictor in the project was demoted to noise, and selection returned
an all-*thermal* feature set -- the group Phase 3 measured as the **weakest**
(|rho| ~ 0.22).
It raised no error and produced a plausible-looking result. It was caught only
because the output contradicted a Phase 3 measurement. The statistic is now
computed on NumPy arrays via `scipy.stats.spearmanr`, and after the fix selection
returns `dq_var` first, as it should.
## 7. Outputs
| File | Contents |
|---|---|
| `data/processed/features_budget{005,010,020,050,100}.parquet` | 124 cells x 57 features per budget |
| `outputs/reports/feature_registry.json` | Every feature with formula, rationale and in-line flag |
| `outputs/reports/feature_mask_report.json` | Exactly what the plausibility mask removed, per cell per budget |
| `data/processed/features_provenance.json` | Config hash and package versions |
```bash
python -m src.features.builder # build all budgets
python -m src.features.make_docs # regenerate this page
python -m pytest tests/test_leakage.py -q
```
---
[← Dataset and EDA](01_dataset_and_eda.md) · [README](../README.md) · [Modelling →](03_modeling.md)