Green / Yellow / Red
A traffic light has three states for a reason: human reaction time is slow enough that “continue normally” (green), “prepare to stop” (yellow), and “stop now” (red) are the meaningful operational states. The same logic produces the three-class health label every model in this book consumes.
The auxiliary classification head (Chapter 11) predicts which of three states an engine is in: normal, degrading, critical. The labels are derived directly from RUL via two threshold cuts. The classifier head's gradients then flow back through the shared backbone alongside the regressor's - that is the multi-task setup we built in Chapter 4.
From RUL to Discrete Health States
Given the capped RUL ycapped from §7.2, the health state is
yhealth=⎩⎨⎧0 (normal)1 (degrading)2 (critical)ycapped>8030<ycapped≤80ycapped≤30
| Class | Label | RUL range | Operational meaning |
|---|---|---|---|
| normal | 0 | RUL > 80 | No action needed; routine monitoring |
| degrading | 1 | 30 < RUL ≤ 80 | Schedule maintenance window |
| critical | 2 | RUL ≤ 30 | Pull engine; high failure risk |
Interactive: Slide the Thresholds
Watch the green / yellow / red bands move as you slide each threshold. The defaults (80, 30) are the convention; some applications use stricter cuts (e.g., 60, 20) for higher safety margins.
Python: Apply the Discretisation
NumPy is the numerical-computing library at the heart of the Python data stack. It provides ndarray (N-dimensional array) — a memory-contiguous, typed array whose math runs as compiled C code, not slow Python loops. We will use np.full (allocate an array), np.minimum (element-wise cap), np.arange (range as array), np.unique (class-count diagnostic) and np.argmax (first-True locator) below.
Upper threshold: any cycle with RUL > 80 is labelled normal (class 0). 80 is the C-MAPSS reference convention — it leaves a ~80-cycle warning runway before failure, long enough to schedule a maintenance window without causing alert fatigue from too-early warnings.
Lower threshold: any cycle with capped RUL ≤ 30 is labelled critical (class 2). 30 cycles is the standard 'pull-now' window — short enough that the model is confident in failure, long enough for the maintenance crew to react.
Maps a vector of capped RUL values to a vector of discrete class labels {0, 1, 2}. Two boolean assignments do the work: pre-fill with 0 (normal), then demote to 1 (degrading) where RUL ≤ 80, then to 2 (critical) where RUL ≤ 30. Order matters — the second assignment overwrites the first for the lowest-RUL cycles.
Three-line summary: the function takes a capped RUL array and returns the same shape with class labels. Class 0 = normal, 1 = degrading, 2 = critical. Convention used everywhere downstream (loss, metrics, viz).
Pre-allocate the output array as all zeros (everyone starts 'normal'). We'll demote some entries on lines 13–14. dtype=int64 is mandatory: PyTorch's F.cross_entropy refuses anything else for the target tensor.
Boolean (mask) indexing: build a True/False vector from the comparison rul_capped ≤ 80, then assign 1 to every position where the mask is True. NumPy does this in vectorized C — no Python loop.
Same masking pattern, but with the tighter threshold (30). Entries where capped RUL ≤ 30 get OVERWRITTEN from 1 → 2. This is why the assignment order on lines 13 → 14 matters: critical must come AFTER degrading or it would be silently undone.
Hand back the 1-D int64 label array — same shape as the input rul_capped, ready to be tensorised and fed to the auxiliary classifier head.
Synthetic engine life: 200 cycles from new to failure. Picked to match a typical FD001 unit so the resulting class counts match the convention table (120/50/30).
Construct the un-capped RUL trajectory: 200 at cycle 0, 199 at cycle 1, …, 1 at cycle 199. Vectorised: np.arange creates [0..199], subtraction broadcasts.
Apply the §7.2 piecewise-linear cap. Anything above 125 is flattened to 125 (the early-life regime where degradation is invisible to the model).
Run the discretiser. Returns the 200-element label array we'll diagnose below.
Compute the class distribution in a single pass — the canonical NumPy idiom for label diagnostics. Returns two parallel arrays: the sorted distinct values and their occurrence counts.
Pretty-print as a Python dict. The .tolist() conversions strip NumPy's int64 wrapper so the dict prints as {0: 120, ...} instead of {np.int64(0): np.int64(120), ...}.
Locate the first cycle where state turns 1. The trick: compare states == 1 to get a boolean mask, then np.argmax returns the index of the FIRST True (because True coerces to 1 and argmax returns the first occurrence of the max).
Same trick, applied to the critical mask. First True position is the first cycle where capped RUL drops to or below 30.
1import numpy as np
2
3# Default thresholds — health state is determined by capped RUL
4THR_DEGR = 80 # RUL > 80 → normal
5THR_CRIT = 30 # RUL ≤ 30 → critical
6
7
8def to_health_state(rul_capped: np.ndarray,
9 thr_degr: int = THR_DEGR,
10 thr_crit: int = THR_CRIT) -> np.ndarray:
11 """Map capped RUL to {0: normal, 1: degrading, 2: critical}."""
12 state = np.full(rul_capped.shape, 0, dtype=np.int64) # all normal
13 state[rul_capped <= thr_degr] = 1 # degrading
14 state[rul_capped <= thr_crit] = 2 # critical
15 return state
16
17
18# ----- Verify on a single engine -----
19life = 200
20raw_rul = life - np.arange(life) # [200, 199, ..., 1]
21capped_rul = np.minimum(raw_rul, 125) # cap at 125
22states = to_health_state(capped_rul)
23
24# Per-state count
25unique, counts = np.unique(states, return_counts=True)
26print("class distribution :", dict(zip(unique.tolist(), counts.tolist())))
27
28# Show transition points
29print("first 'degrading' cycle:", np.argmax(states == 1)) # cycle 120 (RUL = 80)
30print("first 'critical' cycle:", np.argmax(states == 2)) # cycle 170 (RUL = 30)
31
32# class distribution : {0: 120, 1: 50, 2: 30}
33# first 'degrading' cycle: 120
34# first 'critical' cycle: 170PyTorch: As a Tensor Op in the Dataset
Top-level PyTorch package. Provides Tensor (the GPU-aware analogue of NumPy's ndarray), automatic differentiation (autograd), and model-building primitives. The discretiser below uses Tensor allocation (zeros_like), element-wise comparison, boolean masked assignment, and class counting (bincount).
Tuple unpacking — assigns 80 to THR_DEGR and 30 to THR_CRIT in one statement. Same thresholds as the NumPy version so the labels match across the two implementations.
Tensor-native version of the discretiser. Same logic as the NumPy version (zero-fill, then two boolean assignments) but operates on torch.Tensors so the work can run on a GPU and stay inside the dataloader pipeline.
Single-line summary. The (long) is the critical detail — F.cross_entropy will reject anything else as a target tensor.
Allocate the output tensor: same shape and same device as rul_capped (CPU or CUDA), but force the dtype to long (int64). This is the PyTorch analogue of np.full(shape, 0, dtype=int64) — but zeros_like preserves device automatically, which matters when rul_capped lives on the GPU.
Boolean masked assignment — identical syntax to NumPy. The comparison produces a boolean tensor; indexing the long tensor with that mask selects the True positions; the scalar 1 is broadcast into all of them.
Demote the lowest-RUL cycles further to class 2 (critical). Overwrites the 1 written by line 11 in those 30 positions. Order is essential — flipping lines 11 and 12 would leave critical cycles labelled as degrading.
Hand back the long tensor. Caller passes this directly to F.cross_entropy(logits, state) inside the training loop.
Seed PyTorch's CPU RNG so any random op below is reproducible. Not strictly needed here (no randomness in this demo) — kept as the standard 'reproducible script' opener.
Build the RUL countdown directly: arange with step −1 walks from 200 down to (but not including) 0. .float() casts the int64 result to float32, which matches the dtype the regression head will see.
Apply the §7.2 cap as a tensor op. clamp with only `max=` set leaves the lower end alone — equivalent to torch.minimum(raw_rul, 125) but reads more naturally for one-sided clipping.
Run the discretiser on the capped tensor. Returns the long-tensor labels we'll inspect below.
Class-distribution diagnostic. bincount counts how many times each non-negative integer appears in the input tensor — perfect for class-label arrays. minlength=3 guarantees a length-3 output even if some class is absent (otherwise the output would be truncated to the largest observed value + 1).
.tolist() converts the tensor to a Python list of native ints so the print is clean ([120, 50, 30] instead of tensor([120, 50, 30])).
Spot-check the first degrading cycle. states[120] is a 0-D tensor; .item() pulls out the underlying Python int.
Spot-check the first critical cycle. Same .item() extraction.
1import torch
2
3THR_DEGR, THR_CRIT = 80, 30
4
5
6def to_health_state(rul_capped: torch.Tensor,
7 thr_degr: int = THR_DEGR,
8 thr_crit: int = THR_CRIT) -> torch.Tensor:
9 """Map capped RUL tensor → 3-class health state tensor (long)."""
10 state = torch.zeros_like(rul_capped, dtype=torch.long)
11 state[rul_capped <= thr_degr] = 1
12 state[rul_capped <= thr_crit] = 2
13 return state
14
15
16# Demo
17torch.manual_seed(0)
18raw_rul = torch.arange(200, 0, -1).float()
19capped_rul = torch.clamp(raw_rul, max=125)
20states = to_health_state(capped_rul)
21
22# Per-class count via bincount (only works on long tensors)
23counts = torch.bincount(states, minlength=3)
24print("class counts:", counts.tolist()) # [120, 50, 30]
25print("targets[120]:", states[120].item()) # 1 (degrading)
26print("targets[170]:", states[170].item()) # 2 (critical)Discrete Stages Elsewhere
| Domain | States | Threshold mechanism |
|---|---|---|
| RUL (this book) | Normal / Degrading / Critical | RUL thresholds at 80, 30 |
| Cancer staging | Stage I / II / III / IV | Tumour size / lymph involvement |
| Climate emissions targets | Below baseline / on track / off track / critical | Cumulative emissions thresholds |
| Battery state of health | 100% / >80% / >60% / <60% | Capacity-loss thresholds |
| Pavement-condition rating | Good / Fair / Poor / Failed | Surface defect index |
| Air-quality index | Good / Moderate / Unhealthy / Hazardous | Pollutant concentration |
Three Discretisation Pitfalls
state[rul < 80] = 1; state[rul < 30] = 2 assumes the second assignment overrides the first. If you accidentally use <= 80 in BOTH (instead of one with strict <), the boundary cycle (RUL=30) might end up in either class depending on the ordering. Be explicit; test the boundary.F.cross_entropy expects long (int64) targets. torch.zeros_like(rul, dtype=torch.long) is the right idiom.The point. Three classes from one scalar via two thresholds. The discretisation lives at the data boundary, not inside the model. The auxiliary classification head learns from these labels alongside the RUL regressor.
Takeaway
- Three classes: 0 normal, 1 degrading, 2 critical. Defaults: thresholds at 80 and 30 cycles.
- Two boolean assignments, in order, do the work. The second overrides the first for the critical regime.
- Class distribution is imbalanced. ~60% normal, 25% degrading, 15% critical. Plan for it in the loss design.
- Always int64 / long. F.cross_entropy demands it.