Chapter 7
9 min read
Section 29 of 121

Health-State Discretization (3 Classes)

Sequences, RUL Cap & Health Labels

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.

The conventions. RUL > 80 = normal; 30 < RUL ≤ 80 = degrading; RUL ≤ 30 = critical. These are baked into the paper's reference implementation.

From RUL to Discrete Health States

Given the capped RUL ycappedy_{\text{capped}} from §7.2, the health state is

yhealth={0 (normal)ycapped>801 (degrading)30<ycapped802 (critical)ycapped30y_{\text{health}} = \begin{cases} 0 \ (\text{normal}) & y_{\text{capped}} > 80 \\ 1 \ (\text{degrading}) & 30 < y_{\text{capped}} \le 80 \\ 2 \ (\text{critical}) & y_{\text{capped}} \le 30 \end{cases}

ClassLabelRUL rangeOperational meaning
normal0RUL > 80No action needed; routine monitoring
degrading130 < RUL ≤ 80Schedule maintenance window
critical2RUL ≤ 30Pull 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.

Loading health-labels viz…

Python: Apply the Discretisation

Two boolean assignments produce the 3-class label
🐍health_state_numpy.py
1import numpy as np

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.

EXECUTION STATE
numpy = Library for fast N-dimensional arrays + linear algebra + random + math.
as np = Universal alias — write np.full(), np.unique() instead of numpy.full().
4THR_DEGR = 80

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.

EXECUTION STATE
THR_DEGR = 80 — boundary between normal (0) and degrading (1)
→ why 80? = Roughly 40% of the 125-cycle capped horizon. Lowering it (e.g. 60) gives fewer 'degrading' labels but later warnings; raising it (e.g. 100) inflates the degrading class with cycles that are still healthy.
5THR_CRIT = 30

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.

EXECUTION STATE
THR_CRIT = 30 — boundary between degrading (1) and critical (2)
→ relation to THR_DEGR = Must satisfy THR_CRIT < THR_DEGR. Otherwise the assignment order on lines 13–14 produces nonsense (e.g. critical never fires).
8def to_health_state(rul_capped, thr_degr=THR_DEGR, thr_crit=THR_CRIT) → np.ndarray

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.

EXECUTION STATE
⬇ input: rul_capped (N,) = 1-D ndarray of capped RUL values, dtype int64. For our demo with N=200 cycles: [125, 125, 125, ..., 125, 124, 123, ..., 30, 29, ..., 1] ← first 76 are clamped at 125 → ← then count down to 1 →
→ rul_capped purpose = The §7.2-capped target the regression head sees. Discretising the SAME signal keeps the regressor and classifier heads consistent.
⬇ input: thr_degr = 80 = Default upper threshold (normal ↔ degrading). Overridable per call for ablations.
⬇ input: thr_crit = 30 = Default lower threshold (degrading ↔ critical). Overridable per call for ablations.
→ return type hint: np.ndarray = Tells type checkers (mypy, pyright) that the return is a NumPy array, not a Python list. Runtime is unchanged.
⬆ returns = ndarray (N,) int64 — class labels in {0, 1, 2}. Same shape as the input.
11Docstring — Map capped RUL to {0, 1, 2}

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).

12state = np.full(rul_capped.shape, 0, dtype=np.int64)

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.

EXECUTION STATE
📚 np.full(shape, fill_value, dtype) = NumPy allocator: creates an ndarray of the given shape, fills every cell with fill_value, sets the dtype. Equivalent to np.zeros + assignment, but in a single C-level call.
⬇ arg 1: rul_capped.shape = (200,) — a 1-tuple. Inherited from the input so the output mirrors it.
⬇ arg 2: 0 = Initial fill value — the class label for 'normal'. We start optimistic and demote.
⬇ arg 3: dtype=np.int64 = Force 64-bit signed integer. PyTorch's CrossEntropyLoss wants long (int64) targets — float32 or int32 would error at training time.
→ np.full vs np.zeros = np.zeros(shape) ≡ np.full(shape, 0, dtype=float64). np.full lets us pick the fill AND the dtype in one call.
⬆ result: state (200,) = [0, 0, 0, 0, ..., 0, 0] ← all 200 entries are 0
13state[rul_capped <= thr_degr] = 1

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.

EXECUTION STATE
📚 ndarray boolean indexing = When you index an ndarray with a boolean array of the same shape, NumPy selects the entries where the mask is True. Assigning to such a slice writes into those entries.
⬇ rul_capped <= thr_degr = Element-wise comparison → boolean mask of shape (200,). True wherever capped RUL is ≤ 80, False otherwise.
→ mask preview = idx 0..119: False (capped 125..81 > 80) idx 120..199: True (capped 80..1 ≤ 80) → 80 True values, 120 False values
⬇ assigned value: 1 = Class label for 'degrading'. Broadcast: scalar 1 written into every True position.
⬆ state after this line = idx 0..119 → 0 (still normal) idx 120..199 → 1 (now degrading) [0]×120 + [1]×80
14state[rul_capped <= thr_crit] = 2

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.

EXECUTION STATE
⬇ rul_capped <= thr_crit = Boolean mask, True wherever capped RUL is ≤ 30 (idx 170..199 → 30 True values).
⬇ assigned value: 2 = Class label for 'critical'. Overwrites the 1 written by line 13 in those positions.
→ why two assignments instead of np.where? = Reads top-down like prose: 'start normal; demote to degrading; demote further to critical.' A nested np.where(cond1, 0, np.where(cond2, 1, 2)) is harder to scan and easier to get wrong.
→ boundary test = At idx 170, capped_rul = 30. Mask is True (30 ≤ 30). state[170] becomes 2 ✓. At idx 169, capped_rul = 31. Mask is False. state[169] stays 1 ✓.
⬆ state after this line = idx 0..119 → 0 (normal, 120 entries) idx 120..169 → 1 (degrading, 50 entries) idx 170..199 → 2 (critical, 30 entries)
15return state

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.

EXECUTION STATE
⬆ return: state (200,) int64 = [0]×120 + [1]×50 + [2]×30 = 200 labels total
19life = 200

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).

EXECUTION STATE
life = 200 (int) — number of cycles in this synthetic engine
20raw_rul = life - np.arange(life)

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.

EXECUTION STATE
📚 np.arange(stop) = Like Python's range(), but returns an ndarray. np.arange(200) → [0, 1, 2, ..., 199] of length 200, dtype int64.
⬇ arg: life = 200 = Stop value (exclusive). Generates indices 0..199.
→ np.arange(life) = [0, 1, 2, ..., 198, 199] — shape (200,)
→ life - np.arange(life) = Scalar 200 minus ndarray (200,) — NumPy broadcasts: [200-0, 200-1, ..., 200-199]
⬆ raw_rul (200,) = [200, 199, 198, ..., 2, 1] — RUL countdown from new to failure
21capped_rul = np.minimum(raw_rul, 125)

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).

EXECUTION STATE
📚 np.minimum(x1, x2) = Element-wise min of two arrays (or array and scalar). Broadcasts. Different from np.min, which reduces to a single value along an axis.
⬇ arg 1: raw_rul (200,) = [200, 199, ..., 1]
⬇ arg 2: 125 = Scalar cap value. Broadcasts against every element of raw_rul.
→ np.minimum vs np.clip = np.minimum(x, 125) caps the upper end only. np.clip(x, 0, 125) caps both ends. Here RUL is already ≥ 1 so np.minimum suffices.
⬆ capped_rul (200,) = idx 0..75 → 125 (raw_rul 200..126 all clamped) idx 76..199 → raw (raw_rul 124..1 pass through)
22states = to_health_state(capped_rul)

Run the discretiser. Returns the 200-element label array we'll diagnose below.

EXECUTION STATE
⬇ input: capped_rul = [125]×76 + [124, 123, ..., 1]
⬆ states (200,) int64 = [0]×120 + [1]×50 + [2]×30 (normal / degrading / critical)
25unique, counts = np.unique(states, return_counts=True)

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.

EXECUTION STATE
📚 np.unique(arr, return_counts=False) = Returns the sorted unique values in arr. With return_counts=True, also returns a same-length array of how many times each unique value appears.
⬇ arg 1: states (200,) = Class labels — values in {0, 1, 2}
⬇ arg 2: return_counts=True = Toggle the second return value (the counts array). Without it you only get the unique values — useless for distribution diagnostics.
→ mini example = np.unique([2,0,1,0,2,1,0], return_counts=True) → (array([0,1,2]), array([3,2,2]))
⬆ unique = array([0, 1, 2]) — the sorted distinct labels present
⬆ counts = array([120, 50, 30]) — how many of each
26print("class distribution :", dict(zip(unique.tolist(), counts.tolist())))

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), ...}.

EXECUTION STATE
📚 ndarray.tolist() = Converts an ndarray to a nested Python list with native Python ints/floats. Useful for printing, JSON serialisation, or comparison with plain Python.
📚 zip(a, b) = Pairs up two iterables element-wise: zip([0,1,2],[120,50,30]) → [(0,120),(1,50),(2,30)].
📚 dict(pairs) = Builds a dict from an iterable of (key, value) pairs. Combined with zip: dict(zip(keys, values)) is the standard Python idiom.
⬆ Output = class distribution : {0: 120, 1: 50, 2: 30}
→ imbalanced! = 60% normal, 25% degrading, 15% critical. Cross-entropy will under-predict the rare 'critical' class — Section 11.3 covers focal loss as a remedy.
29print("first 'degrading' cycle:", np.argmax(states == 1))

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).

EXECUTION STATE
📚 np.argmax(arr) = Returns the index of the maximum value. With ties (e.g. all-bool arrays where True is the max), it returns the FIRST occurrence — exactly what we want for 'first index where ___'.
⬇ states == 1 = Element-wise equality → boolean mask. False before idx 120, True from 120..169, False after.
→ mini example = np.argmax([F,F,F,T,T,F]) → 3 (first True). Concise alternative to np.where(mask)[0][0].
⬆ Output = first 'degrading' cycle: 120
→ sanity = At idx 120: capped_rul = 200 - 120 = 80 ≤ THR_DEGR ✓.
30print("first 'critical' cycle:", np.argmax(states == 2))

Same trick, applied to the critical mask. First True position is the first cycle where capped RUL drops to or below 30.

EXECUTION STATE
⬇ states == 2 = Boolean mask. False up to idx 169, True from 170..199.
⬆ Output = first 'critical' cycle: 170
→ sanity = At idx 170: capped_rul = 200 - 170 = 30 ≤ THR_CRIT ✓.
17 lines without explanation
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: 170
The class distribution is imbalanced. On C-MAPSS FD001, normal makes up ~60% of training cycles; degrading 25%; critical 15%. Plain cross-entropy slightly under-predicts critical - focal loss (Chapter 11.3, in the original AMNL pipeline) is one way to compensate.

PyTorch: As a Tensor Op in the Dataset

torch.bincount + boolean masking on long tensors
🐍health_state_torch.py
1import torch

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).

EXECUTION STATE
torch = PyTorch core. Exposes torch.Tensor, torch.zeros_like, torch.arange, torch.clamp, torch.bincount and the random-seeding helpers used here.
3THR_DEGR, THR_CRIT = 80, 30

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.

EXECUTION STATE
THR_DEGR = 80 — normal ↔ degrading boundary
THR_CRIT = 30 — degrading ↔ critical boundary
6def to_health_state(rul_capped, thr_degr=THR_DEGR, thr_crit=THR_CRIT) → torch.Tensor

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.

EXECUTION STATE
⬇ input: rul_capped (200,) = 1-D float32 tensor of capped RUL values: [125., 125., ..., 125., 124., 123., ..., 1.]
→ why float32 input? = It came from torch.arange(...).float() upstream. The discretiser doesn't care about the input dtype — the comparison rul_capped <= 80 broadcasts the integer threshold to float for the comparison.
⬇ input: thr_degr = 80 = Default upper threshold. Python int — broadcasts fine in tensor comparisons.
⬇ input: thr_crit = 30 = Default lower threshold.
→ return type hint: torch.Tensor = Documents that the return is a Tensor (long-dtype) for the type checker.
⬆ returns = torch.Tensor (200,) dtype=torch.long — class labels in {0, 1, 2}.
9Docstring — Map capped RUL tensor → 3-class health state tensor (long)

Single-line summary. The (long) is the critical detail — F.cross_entropy will reject anything else as a target tensor.

10state = torch.zeros_like(rul_capped, dtype=torch.long)

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.

EXECUTION STATE
📚 torch.zeros_like(input, dtype=None) = Returns a tensor filled with 0, with the same shape AND device as input. The dtype defaults to input's dtype but can be overridden. Critical: device-preserving — no manual .to(device) needed.
⬇ arg 1: rul_capped (200,) float32 = The reference tensor. Only its shape and device are inherited.
⬇ arg 2: dtype=torch.long = Override the dtype to int64 (PyTorch's 'long'). Required for F.cross_entropy targets — passing a float tensor as the target raises 'expected scalar type Long but found Float'.
→ zeros_like vs torch.zeros = torch.zeros((200,), dtype=torch.long, device='cuda') works too, but you have to pass shape AND device explicitly. zeros_like(x) inherits both — less to get wrong.
⬆ result: state (200,) long = tensor([0, 0, 0, ..., 0, 0]) — all 200 entries 0
11state[rul_capped <= thr_degr] = 1

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.

EXECUTION STATE
📚 Tensor boolean indexing = When you index a tensor with a bool tensor of the same shape, PyTorch selects the True positions. Assignment writes the RHS into those positions in-place.
⬇ rul_capped <= thr_degr = Element-wise comparison → bool tensor (200,). True wherever capped RUL ≤ 80 → idx 120..199 (80 entries).
⬇ assigned value: 1 = Class label for 'degrading'. Scalar broadcast to all True positions.
⬆ state after this line = tensor([0]×120 + [1]×80) — 120 normal, 80 'degrading-or-critical' (will be split next line)
12state[rul_capped <= thr_crit] = 2

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.

EXECUTION STATE
⬇ rul_capped <= thr_crit = Bool tensor — True for idx 170..199 (30 entries where capped RUL ≤ 30).
⬇ assigned value: 2 = Class label for 'critical'.
→ in-place write, no autograd = state has no requires_grad — it's an integer label tensor, not part of the differentiable graph. The masked assignment is just a memory write.
⬆ state after this line = tensor([0]×120 + [1]×50 + [2]×30) — final label distribution
13return state

Hand back the long tensor. Caller passes this directly to F.cross_entropy(logits, state) inside the training loop.

EXECUTION STATE
⬆ return: state (200,) long = tensor([0, 0, ..., 0, 1, 1, ..., 1, 2, 2, ..., 2])
17torch.manual_seed(0)

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.

EXECUTION STATE
📚 torch.manual_seed(seed) = Sets the seed for the global PyTorch CPU random generator. For CUDA, also call torch.cuda.manual_seed_all(seed). Returns the generator object (we ignore it).
⬇ arg: 0 = Seed value. Any int works; 0 is conventional for examples.
18raw_rul = torch.arange(200, 0, -1).float()

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.

EXECUTION STATE
📚 torch.arange(start, end, step) = Like Python's range(): generates [start, start+step, start+2*step, ...] up to but excluding end. Negative step counts down. Returns a 1-D tensor.
⬇ arg 1: 200 (start) = First value (inclusive).
⬇ arg 2: 0 (end) = Stop value (EXCLUSIVE) — generates down to but not including 0, so the last element is 1.
⬇ arg 3: -1 (step) = Decrement by 1 each step. → [200, 199, ..., 1]
📚 .float() = Tensor method: alias for .to(torch.float32). Casts the dtype in-place-like (returns a new tensor). Without it, arange returns int64.
⬆ raw_rul (200,) float32 = tensor([200., 199., 198., ..., 2., 1.])
19capped_rul = torch.clamp(raw_rul, max=125)

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.

EXECUTION STATE
📚 torch.clamp(input, min=None, max=None) = Element-wise clamp: returns a tensor where each element is clipped to [min, max]. Either bound is optional — pass only one to clip just one side. Differentiable in PyTorch.
⬇ arg 1: raw_rul (200,) = tensor([200., 199., ..., 1.])
⬇ arg 2: max=125 = Upper cap (keyword-only here). Anything above 125 is squashed to 125. min is omitted, so values below stay untouched.
→ clamp(max=125) vs minimum(x, 125) = torch.minimum(raw_rul, torch.tensor(125.)) is equivalent but verbose. clamp is the idiomatic one-sided form.
⬆ capped_rul (200,) = tensor([125.]×76 + [124., 123., ..., 1.]) ← idx 0..75 clamped → ← idx 76..199 pass through →
20states = to_health_state(capped_rul)

Run the discretiser on the capped tensor. Returns the long-tensor labels we'll inspect below.

EXECUTION STATE
⬇ input: capped_rul = tensor([125.]×76 + [124., ..., 1.])
⬆ states (200,) long = tensor([0]×120 + [1]×50 + [2]×30)
23counts = torch.bincount(states, minlength=3)

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).

EXECUTION STATE
📚 torch.bincount(input, weights=None, minlength=0) = For a 1-D int tensor, returns a tensor where the i-th element is the count of value i in input. Input MUST be a non-negative int / long tensor — passing float or negative values raises.
⬇ arg 1: states (200,) long = Label tensor with values in {0, 1, 2}. dtype must be int — that's why we used torch.long earlier.
⬇ arg 2: minlength=3 = Guarantee output length ≥ 3. Prevents bugs when a small batch happens to have zero 'critical' samples — without minlength, the output would be length 2 and indexing counts[2] would crash.
→ mini example = torch.bincount(torch.tensor([0,2,0,1,0]), minlength=4) → tensor([3, 1, 1, 0]) ← three 0s, one 1, one 2, zero 3s
⬆ counts (3,) long = tensor([120, 50, 30]) ← [#normal, #degrading, #critical]
24print("class counts:", counts.tolist())

.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])).

EXECUTION STATE
📚 Tensor.tolist() = Returns the tensor as a nested Python list with native Python scalars. For a 1-D tensor, returns a flat list. Shape and nesting are preserved for higher-D tensors.
⬆ Output = class counts: [120, 50, 30]
25print("targets[120]:", states[120].item())

Spot-check the first degrading cycle. states[120] is a 0-D tensor; .item() pulls out the underlying Python int.

EXECUTION STATE
📚 Tensor.item() = Returns the value of a 0-D tensor as a native Python scalar (int or float). Raises if the tensor has more than one element. The standard way to extract a single value for printing or comparison.
⬇ states[120] = tensor(1) — a 0-D long tensor (scalar tensor)
→ .item() vs int(...) = .item() is the canonical PyTorch idiom; int(tensor) also works for 0-D ints but is less explicit. For floats, .item() preserves precision.
⬆ Output = targets[120]: 1 (first degrading cycle, capped RUL = 80)
26print("targets[170]:", states[170].item())

Spot-check the first critical cycle. Same .item() extraction.

EXECUTION STATE
⬇ states[170] = tensor(2) — 0-D long tensor
⬆ Output = targets[170]: 2 (first critical cycle, capped RUL = 30)
10 lines without explanation
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

DomainStatesThreshold mechanism
RUL (this book)Normal / Degrading / CriticalRUL thresholds at 80, 30
Cancer stagingStage I / II / III / IVTumour size / lymph involvement
Climate emissions targetsBelow baseline / on track / off track / criticalCumulative emissions thresholds
Battery state of health100% / >80% / >60% / <60%Capacity-loss thresholds
Pavement-condition ratingGood / Fair / Poor / FailedSurface defect index
Air-quality indexGood / Moderate / Unhealthy / HazardousPollutant concentration

Three Discretisation Pitfalls

Pitfall 1: Comparing RAW RUL. If you discretise the RAW RUL (not capped), early-life cycles with RUL=200 fall in “normal” correctly - but the boundaries depend on whether you also capped. Always discretise the SAME target the regression sees (capped).
Pitfall 2: Overlapping conditions. 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.
Pitfall 3: dtype mismatch. 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.
Loading comments...