Files
2025-09-10 10:37:55 +02:00

533 lines
18 KiB
Python

import copy
import logging
import math
from typing import Callable
import lmdb
import numpy as np
import sklearn
import torch
from torch import nn
from tqdm import tqdm
from utils.inference import scale_features, apply_sigmoid_if_necessary
from utils.utils import get_logger
from utils.data_utils import get_collated_batch_for_key, get_padding_length
from utils.dataset_creation import get_scalers_for_model, inverse_scale_feature
from utils.dataset_utils import load_key_stats
from utils.training_utils import load_model_for_usage
from vsm_datascience_common import constants
def evaluate_model(model_configuration: dict,
training_configuration: dict,
test_ids: list,
evaluation_functions: list,
log_dir: str,
aggregate: bool = True,
logger: logging.Logger = None,
print_progress: bool = False) -> dict:
if logger is None:
logger = get_logger(__name__, f"{log_dir}/{model_configuration['id']}_{training_configuration['id']}.log")
model, training_configuration = load_model_for_usage(
model_configuration,
training_configuration,
test_ids[0]
)
dataset_dir = model_configuration["feature_config"]["dataset_dir"]
lmdb_env = lmdb.open(dataset_dir, readonly=True)
# get computation rank
if torch.distributed.is_initialized():
local_rank = torch.distributed.get_rank()
torch.cuda.set_device(local_rank)
device = torch.device(f"cuda:{local_rank}")
else:
local_rank = 0
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
batch_size = training_configuration["batch_size"]
logger.info(
f"Rank {local_rank}: Estimated batch size: {batch_size} for GPU {torch.cuda.get_device_name(local_rank)}")
# load key stats to retrieve individual cycles
keys_stats = load_key_stats(model_configuration["feature_config"]["dataset_dir"])
predict_fn = model_configuration["predict_fn"]
actual_fn = model_configuration["actual_fn"]
target_features = [x["name"] for x in model_configuration["feature_config"]["target_features"]]
ignored_features = model_configuration["feature_config"]["ignored_features"]
used_targets = [x for x in target_features if x not in ignored_features]
scalers = get_scalers_for_model(model_configuration)
errors = dict()
for test_id in tqdm(test_ids, disable=not print_progress):
# fetch stats for key
current_key_stats = keys_stats["by_key"][test_id]
cycle_stats = current_key_stats["cycle_stats"]
# make sure cycle stats are sorted by start time
# cycle_stats = sorted(cycle_stats, key=lambda x: x["starts_at"])
for i in range(len(cycle_stats)):
# compute cutoffs to isolate current cycle
current_cycle_start_cutoff = sum([x["cycle_length"] for x in cycle_stats[:i]])
current_cycle_end_cutoff = sum([x["cycle_length"] for x in cycle_stats[:i + 1]])
try:
batch = get_collated_batch_for_key(test_id, model_configuration,
start_cutoff=current_cycle_start_cutoff,
end_cutoff=current_cycle_end_cutoff,
lmdb_env=lmdb_env)
except ValueError:
print(f"Skipping {test_id}")
continue
if batch is None:
print(f"Empty batch, skipping {test_id}")
continue
preds = predict_fn(model, batch,
batch_size=batch_size,
device=device)
actuals = actual_fn(batch)
num_outputs = preds.shape[-1] if len(preds.shape) > 1 else 1
processed_preds = apply_sigmoid_if_necessary(preds, model_configuration)
scaled_preds = scale_features(
processed_preds,
used_targets,
model_configuration,
scalers,
)
scaled_actuals = scale_features(
actuals,
used_targets,
model_configuration,
scalers,
)
for eval_fn in evaluation_functions:
if eval_fn is not None:
eval_fn_name = eval_fn["name"]
if eval_fn_name not in errors:
errors[eval_fn_name] = dict()
eval_function = eval_fn["eval_fn"]
eval_fn_indices = [eval_fn["input_index"]] if "input_index" in eval_fn else range(num_outputs)
# skip error fn if actuals are nan, since they are ignored
for output_index in eval_fn_indices:
if any(np.isnan(scaled_actuals[:, output_index])):
continue
error = eval_function(scaled_preds[:, output_index],
scaled_actuals[:, output_index])
if np.isnan(error):
# skip if error is nan
continue
if f"after_{i}" not in errors[eval_fn_name]:
errors[eval_fn_name][f"after_{i}"] = dict()
if output_index not in errors[eval_fn_name][f"after_{i}"]:
errors[eval_fn_name][f"after_{i}"][output_index] = list()
errors[eval_fn_name][f"after_{i}"][output_index].append(error)
if aggregate:
errors = aggregate_errors(errors, evaluation_functions)
return errors
def aggregate_errors(errors: dict, evaluation_functions: list) -> dict:
errors = copy.deepcopy(errors)
for eval_fn in evaluation_functions:
if eval_fn is not None:
eval_fn_name = eval_fn["name"]
accumulation_fn = eval_fn["accumulation_fn"]
if eval_fn_name not in errors:
continue
for key in errors[eval_fn_name]:
for output_index in errors[eval_fn_name][key]:
if len(errors[eval_fn_name][key][output_index]) == 0:
errors[eval_fn_name][key][output_index] = np.nan
else:
# only use non nan values
without_nans = [val for val in errors[eval_fn_name][key][output_index] if
val is not None and val is not np.nan]
errors[eval_fn_name][key][output_index] = accumulation_fn(without_nans)
return errors
def get_index_offset(offset_in_hours: int,
model_configuration: dict, ) -> float:
step_size = model_configuration["preprocessing"]["window_shift"]
measurements_per_day = constants.MEASUREMENTS_PER_DAY
downsampling_factor = model_configuration["preprocessing"]["take_every_nth"]
shift_hour_factor = 24 / (measurements_per_day / downsampling_factor) * step_size
if offset_in_hours == 0:
return 0
index_offset_in_hours = math.ceil(offset_in_hours / shift_hour_factor)
return index_offset_in_hours
def pre_ov_error(preds, actuals, *args, **kwargs):
if any(np.isnan(actuals)):
return np.nan
# get ov day index
try:
ov_day_index = np.where(actuals == 0)[0][0]
except IndexError:
# no ov day in actuals
return np.nan
# get pre ov predictions
pre_ov_preds = preds[:ov_day_index]
pre_ov_actuals = actuals[:ov_day_index]
if len(pre_ov_preds) == 0 or len(pre_ov_actuals) == 0:
return np.nan
# calculate error
error = sklearn.metrics.mean_absolute_error(pre_ov_preds, pre_ov_actuals)
return error
def after_ov_error(preds, actuals, *args, **kwargs):
if any(np.isnan(actuals)):
return np.nan
# get ov day index
try:
ov_day_index = np.where(actuals == 0)[0][0]
except IndexError:
# no ov day in actuals
return np.nan
# get pre ov predictions
after_ov_preds = preds[ov_day_index:]
after_ov_actuals = actuals[ov_day_index:]
if len(after_ov_preds) == 0 or len(after_ov_actuals) == 0:
return np.nan
# calculate error
error = sklearn.metrics.mean_absolute_error(after_ov_preds, after_ov_actuals)
return error
def ov_error(preds, actuals, model_configuration, *args, **kwargs):
if any(np.isnan(actuals)):
return np.nan
# get ov day index
try:
ov_day_index = np.where(actuals == 0)[0][0]
except IndexError:
# no ov day in actuals
return np.nan
# get predicted ov index
try:
pred_ov_index = np.where(preds >= 0)[0][0]
except IndexError:
# no ov day in actuals
return np.nan
# calculate error
error = abs(pred_ov_index - ov_day_index)
# scale error to account for step size
step_size = model_configuration["preprocessing"]["window_shift"]
measurements_per_day = constants.MEASUREMENTS_PER_DAY
downsampling_factor = model_configuration["preprocessing"]["take_every_nth"]
shift_hour_factor = 24 / (measurements_per_day // downsampling_factor) * step_size
error_in_days = error * shift_hour_factor / 24
return error_in_days
def day_relative_to_ov_error(preds: np.ndarray | torch.Tensor,
actual: np.ndarray | torch.Tensor,
day_relative_to_ov: int,
model_configuration: dict) -> float:
"""
Calculate the error of the model predictions relative to the ov day
Args:
preds: predictions
actual: actual values
day_relative_to_ov: day relative to ov day
model_configuration: model configuration
Returns:
error: error of the model predictions relative to the ov day
"""
if isinstance(preds, torch.Tensor):
preds = preds.cpu().numpy()
if isinstance(actual, torch.Tensor):
actual = actual.cpu().numpy()
if any(np.isnan(actual)):
return np.nan
# get ov day index
try:
ov_day_index = np.where(actual == 0)[0][0]
except IndexError:
# no ov day in actuals
return np.nan
# get index offset factor -> how much time between each step
index_offset = get_index_offset(day_relative_to_ov * 24, model_configuration)
if ov_day_index + index_offset >= preds.shape[0]:
relative_day_pred = preds[-1] if len(preds.shape) > 1 else preds[-1]
elif ov_day_index + index_offset < 0:
relative_day_pred = preds[0][0] if len(preds.shape) > 1 else preds[0]
else:
relative_day_pred = preds[ov_day_index + index_offset][0] if len(preds.shape) > 1 else \
preds[ov_day_index + index_offset][0]
relative_day_actual = day_relative_to_ov
# calculate error
error = np.abs(relative_day_pred - relative_day_actual)
return error
def get_ov_over_ov_index(input_sequence: np.ndarray) -> int:
"""
Returns the index of the ovulation based on the ov_over feature curve.
Ov index is first non-zero index in actual curve
Args:
input_sequence: input sequence
Returns:
index of the ovulation based on the ov_over feature curve
"""
try:
ov_index = int(np.where(input_sequence > 0)[0][0])
except IndexError:
return None
return ov_index
def get_ov_over_pre_ov_error(preds: np.ndarray | torch.Tensor,
actual: np.ndarray | torch.Tensor,
error_fn: Callable) -> float:
ov_index = get_ov_over_ov_index(actual)
if ov_index is None:
return np.nan
if any(np.isnan(actual)):
return np.nan
pre_ov_preds = preds[:ov_index]
pre_ov_actuals = actual[:ov_index]
if len(pre_ov_preds) == 0 or len(pre_ov_actuals) == 0:
return np.nan
error = error_fn(pre_ov_preds, pre_ov_actuals)
return error
def get_ov_over_post_ov_error(preds: np.ndarray | torch.Tensor,
actual: np.ndarray | torch.Tensor,
error_fn: Callable) -> float:
ov_index = get_ov_over_ov_index(actual)
if any(np.isnan(actual)):
return np.nan
post_ov_preds = preds[ov_index:]
post_ov_actuals = actual[ov_index:]
if len(post_ov_preds) == 0 or len(post_ov_actuals) == 0:
return np.nan
error = error_fn(post_ov_preds, post_ov_actuals)
return error
def get_pre_fertility_error(preds: np.ndarray | torch.Tensor,
actual: np.ndarray | torch.Tensor,
error_fn: Callable) -> float:
try:
fertility_start_index = np.where(actual > 0)[0][0]
except IndexError:
return np.nan
pre_fertility_preds = preds[:fertility_start_index]
pre_fertility_actuals = actual[:fertility_start_index]
if len(pre_fertility_preds) == 0 or len(pre_fertility_actuals) == 0:
return np.nan
error = error_fn(pre_fertility_preds, pre_fertility_actuals)
return error
def get_post_fertility_error(preds: np.ndarray | torch.Tensor,
actual: np.ndarray | torch.Tensor,
error_fn: Callable) -> float:
try:
fertility_end_index = np.where(actual > 0)[-1][-1]
except IndexError:
return np.nan
pre_fertility_preds = preds[fertility_end_index:]
pre_fertility_actuals = actual[fertility_end_index:]
if len(pre_fertility_preds) == 0 or len(pre_fertility_actuals) == 0:
return np.nan
error = error_fn(pre_fertility_preds, pre_fertility_actuals)
return error
def get_during_fertility_error(preds: np.ndarray | torch.Tensor,
actual: np.ndarray | torch.Tensor,
error_fn: Callable) -> float:
try:
fertility_indices = np.where(actual > 0)[0]
except IndexError:
return np.nan
during_fertility_preds = preds[fertility_indices]
during_fertility_actuals = actual[fertility_indices]
if len(during_fertility_preds) == 0 or len(during_fertility_actuals) == 0:
return np.nan
error = error_fn(during_fertility_preds, during_fertility_actuals)
return error
def get_non_fertility_error(preds: np.ndarray | torch.Tensor,
actual: np.ndarray | torch.Tensor,
error_fn: Callable) -> float:
"""
Calculate the error of the model predictions for non-fertility periods.
Args:
preds: predictions
actual: actual values
Returns:
error: error of the model predictions for non-fertility periods
"""
if any(np.isnan(actual)):
return np.nan
try:
non_fertility_indices = np.where(actual == 0)[0]
except IndexError:
return np.nan
if len(non_fertility_indices) == 0:
return np.nan
# get non-fertility predictions
non_fertility_preds = preds[non_fertility_indices]
non_fertility_actuals = actual[non_fertility_indices]
if len(non_fertility_preds) == 0 or len(non_fertility_actuals) == 0:
return np.nan
# calculate error
error = error_fn(non_fertility_preds, non_fertility_actuals)
return error
def get_fertility_over_index(input_sequence: np.ndarray) -> int:
"""
Returns the index of the fertility based on the fertility feature curve.
Fertility index is first non-zero index in actual curve
Args:
input_sequence: input sequence
Returns:
index of the fertility based on the fertility feature curve
"""
try:
fertility_index = np.where(input_sequence > 0)[-1][-1]
except IndexError:
return None
return fertility_index
def get_error_relative_to_x(preds: np.ndarray | torch.Tensor,
actual: np.ndarray | torch.Tensor,
x_fn: Callable,
offset_in_hours: int,
model_configuration: dict) -> float:
"""
Calculate the error at the index relative
Args:
preds: predictions
actual: actual values
x_fn: function to get the index of the x value
offset_in_hours: offset in hours to apply to the x index
model_configuration: model configuration
Returns:
error: error of the model predictions relative to the x value
"""
if isinstance(preds, torch.Tensor):
preds = preds.cpu().numpy()
if isinstance(actual, torch.Tensor):
actual = actual.cpu().numpy()
if any(np.isnan(actual)):
return np.nan
# get x index
try:
x_index = x_fn(actual)
except Exception:
return np.nan
if x_index is None:
return np.nan
# get index offset factor -> how much time between each step
index_offset = get_index_offset(offset_in_hours, model_configuration)
if x_index + index_offset >= preds.shape[0]:
offset_pred = preds[-1]
offset_actual = actual[-1]
elif x_index + index_offset < 0:
offset_pred = preds[0]
offset_actual = actual[0]
else:
offset_pred = preds[x_index + index_offset]
offset_actual = actual[x_index + index_offset]
# calculate error
error = np.abs(offset_pred - offset_actual)
return error
def r2_wrapper(preds: np.ndarray | torch.Tensor,
actual: np.ndarray | torch.Tensor,
var_tol: float = 1e-8,
max_allowed_var: float = 1.0):
if len(preds) <= 1 or len(actual) <= 1:
return np.nan
# make sure to capture the case where actuals have no variance
if np.var(actual) < var_tol:
# return variance of predictions here, as it should be at best zero
# cap at 1.0 to make it comparable
error = min(np.var(preds), max_allowed_var)
return error
# return 1 - r2 to get an error based metric, where lower is better
error = 1 - sklearn.metrics.r2_score(actual, preds)
return error