feat: much better architecture

I understand at least some of this.
This commit is contained in:
2026-08-02 15:01:22 +02:00
parent ec06e92f0d
commit 06af4b9cc4
+69 -165
View File
@@ -3,6 +3,7 @@
import sys import sys
from argparse import ArgumentParser from argparse import ArgumentParser
from argparse import Namespace from argparse import Namespace
from collections import Counter
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
@@ -17,125 +18,39 @@ from torch.optim import SGD
from sleep_detection.sleep_epoch_classifer import SleepEpochClassifier from sleep_detection.sleep_epoch_classifer import SleepEpochClassifier
SLEEP_STAGE_LABELS = [0, 1, 2]
def normalize_sleep_stage(stage) -> int:
"""Map raw sleep stages to the 3-class target space.
Raw labels:
0 -> 0
1, 2, 3, 4 -> 1
5, 6 -> 2
Other values are treated as unknown.
"""
if pandas.isna(stage):
return int(numpy.nan)
value = int(stage)
if value == 0:
return 0
if 1 <= value <= 4:
return 1
if 5 <= value <= 6:
return 2
return int(numpy.nan)
@logger.catch @logger.catch
def main() -> None: def main() -> None:
"""The Main entrypoint function.""" """The Main entrypoint function."""
cli = parse_args() cli = parse_args()
logger.info("welcome") session = load_session(cli.a, cli.H, cli.l)
load_session(cli.a, cli.H, cli.l)
raise ValueError(f"Unknown command: {cli.command}") inputs = torch.tensor(session[["timestamp", "heartrate", "motion-avg"]].to_numpy(), dtype=torch.float32)
mean = inputs.mean(dim=0, keepdim=True) # shape (1, n_features)
std = inputs.std(dim=0, keepdim=True) + 1e-8
normalized_inputs = (inputs - mean) / std
logger.info(f"inputs={normalized_inputs}")
targets = torch.tensor(session["stage"].to_numpy(), dtype=torch.long)
logger.info(f"targets={targets}")
net = SleepEpochClassifier(n_features=3, hidden_size=16, n_classes=3)
train(normalized_inputs, targets, net)
torch.save(net.state_dict(), cli.output)
def parse_args() -> Namespace: def parse_args() -> Namespace:
"""Parse commandline arguments."""
parser = ArgumentParser(description="Training program.") parser = ArgumentParser(description="Training program.")
parser.add_argument("-v", "--verbose", help="Increase verbosity.", action="store_true") parser.add_argument("-v", "--verbose", help="Increase verbosity.", action="store_true")
parser.add_argument("-V", "--version", action="store_true", help="Show version") parser.add_argument("-V", "--version", action="store_true", help="Show version")
parser.add_argument("-a", type=Path, required=True, help="Acceleration data file(s).") parser.add_argument("-a", type=Path, required=True, help="Acceleration data file(s).")
parser.add_argument("-H", type=Path, required=True, help="Heartrate data file(s).") parser.add_argument("-H", type=Path, required=True, help="Heartrate data file(s).")
parser.add_argument("-l", type=Path, required=True, help="Labels data file(s).") parser.add_argument("-l", type=Path, required=True, help="Labels data file(s).")
parser.add_argument("-o", "--output", type=Path, required=True, help="Output .pt file.")
return parser.parse_args() return parser.parse_args()
def featureize(data: pandas.DataFrame) -> tuple[pandas.DataFrame, Tensor, Tensor, list]:
"""Convert loaded data into input and target tensors.
Args:
data: Loaded data, should contain the keys: [timestamp, heartrate, motion, x, y, z, stage]
"""
logger.info(f"Featurizing the data...")
epochs = build_epochs(data)
epochs["hr_std"] = epochs["hr_std"].fillna(0.0)
epochs = epochs.dropna(subset=["hr_mean", "motion_mean", "motion_max", "stage"])
epochs["seconds_since_start"] = epochs.index.total_seconds()
CONTEXT = 2 # epochs each side -> 5 epochs total = 25 min window
FEATURE_COLS = ["hr_mean", "hr_std", "motion_mean", "motion_max", "seconds_since_start"]
features = epochs[FEATURE_COLS].to_numpy(dtype=numpy.float32)
labels = epochs["stage"].astype(numpy.int64).to_numpy()
X, y = [], []
for i in range(CONTEXT, len(epochs) - CONTEXT):
window = features[i - CONTEXT : i + CONTEXT + 1] # shape: (2*CONTEXT+1, n_feats)
X.append(window.flatten()) # -> 1D vector
y.append(labels[i])
if not X:
raise ValueError("Not enough complete epochs to build a training set.")
X = torch.tensor(numpy.stack(X), dtype=torch.float32) # shape: (n_epochs, (2*CONTEXT+1)*n_feats)
y = torch.tensor(y, dtype=torch.long) # shape: (n_epochs,)
return epochs, X, y, SLEEP_STAGE_LABELS
def featureize_sessions(dataframes: Sequence[pandas.DataFrame]) -> tuple[pandas.DataFrame, Tensor, Tensor, list]:
"""Convert multiple loaded sessions into input and target tensors."""
epochs_per_session = []
for data in dataframes:
epochs = build_epochs(data)
epochs["hr_std"] = epochs["hr_std"].fillna(0.0)
epochs = epochs.dropna(subset=["hr_mean", "motion_mean", "motion_max", "stage"])
epochs["seconds_since_start"] = epochs.index.total_seconds()
epochs_per_session.append(epochs)
if not epochs_per_session:
raise ValueError("No sessions provided.")
combined_epochs = pandas.concat(epochs_per_session)
CONTEXT = 2 # epochs each side -> 5 epochs total = 25 min window
FEATURE_COLS = ["hr_mean", "hr_std", "motion_mean", "motion_max", "seconds_since_start"]
X, y = [], []
for epochs in epochs_per_session:
features = epochs[FEATURE_COLS].to_numpy(dtype=numpy.float32)
labels = epochs["stage"].astype(numpy.int64).to_numpy()
for i in range(CONTEXT, len(epochs) - CONTEXT):
window = features[i - CONTEXT : i + CONTEXT + 1] # shape: (2*CONTEXT+1, n_feats)
X.append(window.flatten()) # -> 1D vector
y.append(labels[i])
if not X:
raise ValueError("Not enough complete epochs to build a training set.")
X = torch.tensor(numpy.stack(X), dtype=torch.float32) # shape: (n_epochs, (2*CONTEXT+1)*n_feats)
y = torch.tensor(y, dtype=torch.long) # shape: (n_epochs,)
return combined_epochs, X, y, SLEEP_STAGE_LABELS
def load_session(acceleration_file: Path, heartrate_file: Path, label_file: Path) -> pandas.DataFrame: def load_session(acceleration_file: Path, heartrate_file: Path, label_file: Path) -> pandas.DataFrame:
"""Load a session triplet.""" """Load a session triplet."""
adf = load_acceleration_data(acceleration_file) adf = load_acceleration_data(acceleration_file)
@@ -152,48 +67,10 @@ def load_session(acceleration_file: Path, heartrate_file: Path, label_file: Path
# Label the heartrate dataset. # Label the heartrate dataset.
ldf.index = ldf.index.astype(float) ldf.index = ldf.index.astype(float)
hdf = pandas.merge_asof(hdf, ldf, on="timestamp", direction="backward") hdf = pandas.merge_asof(hdf, ldf, on="timestamp", direction="backward")
with pandas.option_context('display.max_rows', None, 'display.max_columns', None):
print(f"{hdf}")
logger.info(f"\n{hdf}") logger.info(f"\n{hdf}")
return hdf return hdf
def build_epochs(data: pandas.DataFrame) -> pandas.DataFrame:
"""Aggregate raw signals into 5-minute epochs."""
EPOCH_LEN = pandas.Timedelta(minutes=5)
return data.resample(EPOCH_LEN).agg(
hr_mean=("hr", "mean"),
hr_std=("hr", "std"),
motion_mean=("motion", "mean"),
motion_max=("motion", "max"),
stage=("stage", lambda s: s.dropna().mode().iloc[0] if len(s.dropna()) else numpy.nan),
)
def execute(epochs: pandas.DataFrame, input: Tensor, target: Tensor, classifier: Module, label_mapping: list) -> None:
"""Run the model and emit per-epoch predictions."""
classifier.eval()
with torch.no_grad():
output = classifier(input)
predicted = output.argmax(dim=1)
decoded_predicted = [label_mapping[index] for index in predicted.tolist()]
decoded_target = [label_mapping[index] for index in target.tolist()]
report = pandas.DataFrame(
{
"timestamp": epochs.index[2 : len(epochs) - 2].astype("timedelta64[s]") / numpy.timedelta64(1, "s"),
"predicted": decoded_predicted,
"actual": decoded_target,
}
)
report.to_csv(sys.stdout, index=False)
accuracy = (predicted == target).float().mean().item()
logger.info(f"accuracy={accuracy:.3f}")
def load_acceleration_data(data_file: Path) -> pandas.DataFrame: def load_acceleration_data(data_file: Path) -> pandas.DataFrame:
"""Load acceleration data file.""" """Load acceleration data file."""
logger.info(f"Loading acceleration data file {data_file}") logger.info(f"Loading acceleration data file {data_file}")
@@ -219,49 +96,76 @@ def load_labels_data(data_file: Path) -> pandas.DataFrame:
"""Load label data file.""" """Load label data file."""
logger.info(f"Loading label date file {data_file}") logger.info(f"Loading label date file {data_file}")
df = pandas.read_csv(data_file, sep=r"\s+", names=["timestamp", "stage"], header=None) df = pandas.read_csv(data_file, sep=r"\s+", names=["timestamp", "stage"], header=None)
# df["stage"] = pandas.to_numeric(df["stage"], errors="coerce").map(normalize_sleep_stage) df["stage"] = pandas.to_numeric(df["stage"], errors="coerce").map(normalize_sleep_stage)
df = df.drop(df[df["timestamp"] < 0].index).drop_duplicates() df = df.drop(df[df["timestamp"] < 0].index).drop_duplicates()
df = df.set_index("timestamp") df = df.set_index("timestamp")
logger.info(f"({len(df)})\n{df}") logger.info(f"({len(df)})\n{df}")
return df return df
def train(input: Tensor, target: Tensor, classifier: Module): def normalize_sleep_stage(stage) -> int:
classifier.train() """Map raw sleep stages to the 3-class target space."""
optimizer = SGD(classifier.parameters(), lr=0.01) if pandas.isna(stage):
criterion = CrossEntropyLoss() return int(numpy.nan)
value = int(stage)
if value == 0:
return 0
if 1 <= value <= 4:
return 1
if 5 <= value <= 6:
return 2
return int(numpy.nan)
def train(input: Tensor, target: Tensor, net: Module) -> None:
net.train()
optimizer = SGD(net.parameters(), lr=0.001, momentum=0.9)
# 20/80 split the input so we can get an accurate `accuracy` log.
n_samples = input.shape[0] n_samples = input.shape[0]
n_train = max(1, int(n_samples * 0.8)) n_train = max(1, int(n_samples * 0.8))
permutation = torch.randperm(n_samples) permutation = torch.randperm(n_samples)
train_idx = permutation[:n_train] train_idx, val_idx = permutation[:n_train], permutation[n_train:]
val_idx = permutation[n_train:] train_input, train_target = input[train_idx], target[train_idx]
val_input, val_target = input[val_idx], target[val_idx]
train_input = input[train_idx] # Tweak the loss function.
train_target = target[train_idx] class_counts = torch.bincount(train_target)
val_input = input[val_idx] if len(val_idx) else None class_weights = 1.0 / torch.sqrt(class_counts.float())
val_target = target[val_idx] if len(val_idx) else None class_weights = class_weights / class_weights.sum() # normalize, not strictly required but keeps scale sane
criterion = CrossEntropyLoss(weight=class_weights)
# Train for a bunch of epochs.
for epoch in range(100): for epoch in range(100):
optimizer.zero_grad() optimizer.zero_grad()
output = classifier(train_input) output = net(train_input)
loss = criterion(output, train_target) loss = criterion(output, train_target)
loss.backward() loss.backward()
optimizer.step() optimizer.step()
# Every 10th epoch, we log the current status.
if epoch % 10 == 0: if epoch % 10 == 0:
message = f"epoch={epoch} train_loss={loss.item():.4f}" train_accuracy = (output.argmax(dim=1) == train_target).float().mean().item()
if val_input is not None and val_target is not None:
classifier.eval()
with torch.no_grad():
val_output = classifier(val_input)
val_loss = criterion(val_output, val_target)
val_accuracy = (val_output.argmax(dim=1) == val_target).float().mean().item()
classifier.train()
message += f" val_loss={val_loss.item():.4f} val_acc={val_accuracy:.3f}"
logger.info(message)
net.eval()
with torch.no_grad():
val_output = net(val_input)
val_accuracy = (val_output.argmax(dim=1) == val_target).float().mean().item()
def save_network(output_file: Path, network: Module, label_mapping: list) -> None: logger.info(
logger.info(f"Saving network {network} to {output_file}") f"epoch={epoch} loss={loss.item():.4f} train_acc={train_accuracy:.2f} val_acc={val_accuracy:.2f}"
torch.save({"model_state_dict": network.state_dict(), "label_mapping": label_mapping}, output_file) )
logger.info(f"last output={output}")
# Log the confusion matrix - so we get some better insights into how the training went.
net.eval()
with torch.no_grad():
predictions = net(val_input).argmax(dim=1)
pred_counts = Counter(predictions.tolist())
logger.info(f"prediction distribution: {pred_counts}")
for true_class in sorted(set(val_target.tolist())):
mask = val_target == true_class
class_predictions = predictions[mask]
class_counts = Counter(class_predictions.tolist())
logger.info(f"true={true_class} (n={mask.sum().item()}) -> predicted: {class_counts}")