fixes
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
|
||||
import lmdb
|
||||
import numpy as np
|
||||
from bson import ObjectId
|
||||
from torch.utils.data import IterableDataset
|
||||
from tqdm import tqdm
|
||||
|
||||
from utils.dataset_creation import get_features, load_scalers, scale_item, combine_features
|
||||
from utils.dataset_utils import load_key_stats
|
||||
from utils.lmdb_utils import load_from_lmdb
|
||||
from vsm_datascience_common.cycle_database_connection.cycle_data import get_cycle_by_id
|
||||
from vsm_datascience_common.cycle_database_connection.db_utils import get_cycles_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_prepared_sequence_length(input_sequence: np.ndarray, model_configuration: dict) -> int:
|
||||
"""
|
||||
Returns the length of the input sequence after padding and resampling
|
||||
"""
|
||||
|
||||
initial_length = len(input_sequence)
|
||||
take_every_nth = model_configuration["preprocessing"]["take_every_nth"]
|
||||
padding_length = get_padding_length(model_configuration, resampled=False)
|
||||
raw_length = initial_length + padding_length
|
||||
adjusted = int(raw_length // take_every_nth)
|
||||
return adjusted
|
||||
|
||||
|
||||
def get_padding_length(model_configuration: dict, resampled: bool = True) -> int:
|
||||
"""
|
||||
Returns the length of the input sequence after padding and resampling
|
||||
"""
|
||||
take_every_nth = model_configuration["preprocessing"]["take_every_nth"]
|
||||
padding_length = int(model_configuration["input_window_length"] * (
|
||||
1 - model_configuration["preprocessing"]["min_input_length_fraction_for_padding"])) * (
|
||||
take_every_nth if not resampled else 1)
|
||||
return padding_length
|
||||
|
||||
|
||||
def get_number_of_windows(base_length: int, model_configuration: dict) -> int:
|
||||
"""
|
||||
Returns the number of windows for a given sequence length
|
||||
"""
|
||||
window_shift = model_configuration["preprocessing"]["window_shift"]
|
||||
input_window_length = model_configuration["input_window_length"]
|
||||
output_window_length = model_configuration["output_window_length"]
|
||||
output_window_offset = model_configuration["output_window_offset"]
|
||||
|
||||
if input_window_length > output_window_length + output_window_offset:
|
||||
return (base_length - input_window_length) // window_shift + 1
|
||||
else:
|
||||
return (base_length - output_window_length - output_window_offset) // window_shift + 1
|
||||
|
||||
|
||||
def get_collated_batch_for_key(sample_key: ObjectId | str,
|
||||
model_configuration: dict,
|
||||
start_cutoff: int = None,
|
||||
end_cutoff: int = None,
|
||||
lmdb_env=None) -> dict:
|
||||
sample_batch_for_key = get_batch_for_key(sample_key, model_configuration,
|
||||
start_cutoff=start_cutoff,
|
||||
end_cutoff=end_cutoff,
|
||||
lmdb_env=lmdb_env)
|
||||
collated = model_configuration["collate_fn"](sample_batch_for_key)
|
||||
return collated
|
||||
|
||||
|
||||
def get_batch_for_key(key,
|
||||
model_configuration: dict,
|
||||
start_cutoff: int = None,
|
||||
end_cutoff: int = None,
|
||||
lmdb_env=None) -> np.ndarray:
|
||||
"""
|
||||
Get the batch for a given key from the lmdb database or compute it directly.
|
||||
|
||||
The return has the same format as the batch produced by the batch_fn in the model configuration.
|
||||
Args:
|
||||
key: key to get the batch for, can also be object id from database
|
||||
model_configuration: model configuration to use
|
||||
start_cutoff: start cutoff for the batch, if None, the whole batch is used, CAUTION: cutoff should not be normalized -> in raw data points
|
||||
end_cutoff: end cutoff for the batch, if None, the whole batch is used, CAUTION: cutoff should not be normalized -> in raw data points
|
||||
lmdb_env: the lmdb environment to use, if None, the features are computed directly from the database
|
||||
|
||||
Returns:
|
||||
batch: batch for the given key, as returned by the batch_fn in the model configuration
|
||||
|
||||
"""
|
||||
if lmdb_env is None:
|
||||
# compute and scale features, ignored features are handled internally
|
||||
features = get_scaled_feature_for_key(key, model_configuration)
|
||||
else:
|
||||
# load features from lmdb
|
||||
features = load_from_lmdb(lmdb_env, str(key))
|
||||
# ignored features are handled internally, thus we need to remove them here
|
||||
ignored_features = model_configuration["feature_config"]["ignored_features"] if "ignored_features" in \
|
||||
model_configuration[
|
||||
"feature_config"] else []
|
||||
# add scaled versions of features
|
||||
ignored_features = ignored_features + [feat + "_scaled" for feat in ignored_features]
|
||||
for feature_set in features:
|
||||
for feature_name in list(features[feature_set].keys()):
|
||||
if feature_name in ignored_features:
|
||||
del features[feature_set][feature_name]
|
||||
|
||||
processed_chunk = process_chunk([key], [features], model_configuration)
|
||||
|
||||
# use cutoff, if provided
|
||||
if start_cutoff is not None or end_cutoff is not None:
|
||||
|
||||
item_length = len(processed_chunk[0]["target_features"])
|
||||
|
||||
if start_cutoff is None:
|
||||
start_cutoff = 0
|
||||
if end_cutoff is None:
|
||||
end_cutoff = item_length
|
||||
|
||||
# "normalize" cutoff to adjust for added padding and resampling
|
||||
padding_length = get_padding_length(model_configuration, resampled=False)
|
||||
take_every_nth = model_configuration["preprocessing"]["take_every_nth"]
|
||||
# make sure that there is no padding added to the start cutoff, so that it includes the padding added to the sequence
|
||||
start_cutoff_normalized = max(int(start_cutoff // take_every_nth), 0)
|
||||
# end cutoff must be adjusted to include the padding added to the sequence
|
||||
end_cutoff_normalized = min(int((end_cutoff + padding_length) // take_every_nth), item_length)
|
||||
for feature_set in processed_chunk[0]:
|
||||
if feature_set in model_configuration["feature_config"]["feature_sets"]:
|
||||
processed_chunk[0][feature_set] = processed_chunk[0][feature_set][
|
||||
start_cutoff_normalized:end_cutoff_normalized + 1]
|
||||
|
||||
if "batch_fn" in model_configuration and model_configuration["batch_fn"] is not None:
|
||||
batch = model_configuration["batch_fn"](processed_chunk, model_configuration)
|
||||
else:
|
||||
batch = processed_chunk
|
||||
if batch is None or len(batch) == 0:
|
||||
raise ValueError(f"Batch is empty for key {key}")
|
||||
return batch
|
||||
|
||||
|
||||
def get_scaled_feature_for_key(key: str,
|
||||
model_configuration: dict) -> tuple:
|
||||
feature_config = model_configuration["feature_config"]
|
||||
|
||||
user_cycles = list(
|
||||
get_cycles_collection().find({"user_id": ObjectId(key)} | feature_config["filter_criteria"]).sort("starts_at",
|
||||
1))
|
||||
cycle_features = list()
|
||||
for cycle in user_cycles:
|
||||
features = get_features(cycle, feature_config)
|
||||
cycle_features.append(features)
|
||||
|
||||
# combine all cycles for the user
|
||||
if len(cycle_features) == 0:
|
||||
raise ValueError(f"No features found for key {key}")
|
||||
features = combine_features(cycle_features, feature_config)
|
||||
scaler_dir = os.path.join(feature_config["dataset_dir"], "scalers")
|
||||
scalers = load_scalers(scaler_dir)
|
||||
# scale features
|
||||
scaled_features = scale_item(features, scalers)
|
||||
return scaled_features
|
||||
|
||||
|
||||
def augment_items(identifiers: list,
|
||||
items: list[dict],
|
||||
feature_config: dict,
|
||||
lmdb_env: lmdb.Environment = None) -> list:
|
||||
"""
|
||||
Augments the given sequences by attaching previous sequences
|
||||
Args:
|
||||
identifiers: identifiers of items for finding previous sequences
|
||||
items: actual items
|
||||
feature_config: feature config of the dataset
|
||||
lmdb_env: lmdb environment for loading previous sequences, can be None, in this case the items are computed directly
|
||||
|
||||
Returns:
|
||||
list of augmented items
|
||||
"""
|
||||
|
||||
if "augmentation" not in feature_config or \
|
||||
feature_config["augmentation"]["use_augmentation"] is False:
|
||||
return items
|
||||
|
||||
augmented_items = list()
|
||||
for i, item in enumerate(items):
|
||||
item_id = identifiers[i]
|
||||
try:
|
||||
item_data = get_cycles_collection().find_one({"_id": ObjectId(item_id)}, {"user_id": 1, "starts_at": 1})
|
||||
item_user_id = item_data["user_id"]
|
||||
item_starts_at = item_data["starts_at"]
|
||||
except KeyError:
|
||||
print(f"User ID not found for item {item_id}")
|
||||
continue
|
||||
previous_item_identifiers = list(get_cycles_collection().aggregate(
|
||||
# make sure to filter by user first to significantly reduce the number of items
|
||||
[
|
||||
{
|
||||
"$match": {
|
||||
"user_id": item_user_id,
|
||||
"starts_at": {"$lt": item_starts_at},
|
||||
}
|
||||
}
|
||||
] + feature_config["filter_criteria_pipeline"] + [
|
||||
{
|
||||
"$sort": {
|
||||
"starts_at": -1
|
||||
}
|
||||
},
|
||||
{
|
||||
"$project": {
|
||||
"_id": 1,
|
||||
"starts_at": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
))
|
||||
max_lookback = feature_config["augmentation"]["max_lookback"]
|
||||
previous_items = list()
|
||||
for previous_item in previous_item_identifiers:
|
||||
if len(previous_items) >= max_lookback:
|
||||
break
|
||||
previous_item_id = previous_item["_id"]
|
||||
if lmdb_env is None:
|
||||
# compute item
|
||||
cycle = get_cycle_by_id(previous_item_id)
|
||||
features = get_features(cycle, feature_config)
|
||||
scaler_dir = os.path.join(feature_config["dataset_dir"], "scalers")
|
||||
scalers = load_scalers(scaler_dir)
|
||||
previous_item = scale_item(features, scalers)
|
||||
previous_items.append(previous_item)
|
||||
else:
|
||||
# take item from lmdb
|
||||
try:
|
||||
# make sure to parse object id
|
||||
previous_item = load_from_lmdb(lmdb_env, str(previous_item_id))
|
||||
except KeyError:
|
||||
continue
|
||||
previous_items.append(previous_item)
|
||||
|
||||
# merge items into one
|
||||
feature_sets = feature_config["feature_sets"]
|
||||
augmented_item = copy.deepcopy(item)
|
||||
for feature_set in feature_sets:
|
||||
if feature_set not in augmented_item:
|
||||
continue
|
||||
if "static" in feature_set:
|
||||
continue
|
||||
for feature_name in augmented_item[feature_set]:
|
||||
if feature_name not in augmented_item[feature_set]:
|
||||
continue
|
||||
augmented_item[feature_set][feature_name] = np.concatenate(
|
||||
[previous_item[feature_set][feature_name] for previous_item in previous_items]
|
||||
+ [augmented_item[feature_set][feature_name]])
|
||||
|
||||
augmented_items.append(augmented_item)
|
||||
|
||||
return augmented_items
|
||||
|
||||
|
||||
def process_chunk(ids: list,
|
||||
chunk: list,
|
||||
model_configuration: dict,
|
||||
ignored_features: list = None,
|
||||
pad_sequences: bool = True,
|
||||
statics_as_list: bool = True):
|
||||
"""
|
||||
Creates a chunk of data as dataframe for training and inference of a tft model.
|
||||
:param ids: list of ids of the data points in the chunk
|
||||
:param chunk: list of data points
|
||||
|
||||
:return: dataframe with the data points in the chunk
|
||||
"""
|
||||
|
||||
if ignored_features is None:
|
||||
ignored_features = list()
|
||||
|
||||
input_features = list(chunk[0]["target_features"].keys())
|
||||
feature_config = model_configuration["feature_config"]
|
||||
|
||||
# variables for padding
|
||||
padding_value = 0
|
||||
|
||||
# create dataframe from items in chunk
|
||||
data = []
|
||||
for i, item in enumerate(chunk):
|
||||
item_length = item["target_features"][input_features[0]].shape[0]
|
||||
take_every_nth = model_configuration["preprocessing"]["take_every_nth"]
|
||||
|
||||
data_item = dict()
|
||||
for feature_set in feature_config["feature_sets"]:
|
||||
if feature_set in item and len(item[feature_set]) > 0:
|
||||
# fill in the data
|
||||
source = item[feature_set]
|
||||
features_to_use = [feature for feature in source.keys() if "_scaled" in feature]
|
||||
feature_set_data = [source[feature] for feature in features_to_use]
|
||||
|
||||
padding_length = get_padding_length(model_configuration, resampled=False)
|
||||
|
||||
if len(feature_set_data) == 0:
|
||||
raise ValueError(f"No features found for key {ids[i]} in feature set {feature_set}")
|
||||
|
||||
if len(feature_set_data[0]) == 1:
|
||||
# if the feature is constant, we need to repeat it for all time points
|
||||
if statics_as_list:
|
||||
if pad_sequences:
|
||||
desired_length = item_length + padding_length
|
||||
feature_set_data = np.array(
|
||||
[np.full((desired_length,), val) for val in feature_set_data]).T[::take_every_nth]
|
||||
else:
|
||||
feature_set_data = np.array(
|
||||
[np.full((item_length,), val) for val in feature_set_data]).T[::take_every_nth]
|
||||
else:
|
||||
feature_set_data = np.array(feature_set_data).flatten()
|
||||
else:
|
||||
if pad_sequences:
|
||||
padding_values = np.full((padding_length, len(feature_set_data)),
|
||||
[padding_value for vals in feature_set_data]).T
|
||||
feature_set_data = np.concatenate([padding_values, np.array(feature_set_data)], axis=1)
|
||||
# create bins of n values and apply mean
|
||||
|
||||
# check, if special accumulation function has been specified
|
||||
individual_feature_data = list()
|
||||
for i, feature_to_use in enumerate(features_to_use):
|
||||
try:
|
||||
# get config for feature, make sure to replace scaled to get actual config
|
||||
current_feature_config = \
|
||||
[x for x in feature_config[feature_set] if
|
||||
x["name"] == feature_to_use.replace("_scaled", "")][0]
|
||||
current_accumulation_fn = current_feature_config["accumulation_fn"]
|
||||
except (KeyError, IndexError):
|
||||
# default to mean
|
||||
current_accumulation_fn = np.mean
|
||||
|
||||
current_feature_data = apply_fn_to_bins(feature_set_data[i], take_every_nth,
|
||||
current_accumulation_fn)
|
||||
individual_feature_data.append(current_feature_data)
|
||||
|
||||
# stack individual features
|
||||
feature_set_data = np.stack(individual_feature_data).T
|
||||
|
||||
if feature_set not in data_item:
|
||||
data_item[feature_set] = feature_set_data
|
||||
else:
|
||||
data_item[feature_set] = np.concatenate([data_item[feature_set], feature_set_data], axis=1)
|
||||
|
||||
data.append(data_item)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def apply_fn_to_bins(input_sequence: np.ndarray,
|
||||
bin_size: int,
|
||||
fn: callable) -> np.ndarray:
|
||||
"""
|
||||
Applies a function to the bins of the input sequence along the last dimension and returns the results.
|
||||
Args:
|
||||
input_sequence: sequence to apply the function to (1D or 2D)
|
||||
bin_size: size of the bins
|
||||
fn: function to apply to the bins
|
||||
|
||||
Returns:
|
||||
np.ndarray: result of the function applied to the bins
|
||||
|
||||
"""
|
||||
if input_sequence.ndim == 1:
|
||||
input_sequence = input_sequence.reshape(1, -1)
|
||||
dims = 1
|
||||
else:
|
||||
dims = input_sequence.shape[-1]
|
||||
|
||||
result = []
|
||||
for row in input_sequence:
|
||||
row_result = []
|
||||
for start in range(0, len(row), bin_size):
|
||||
end = min(start + bin_size, len(row))
|
||||
bin_slice = row[start:end]
|
||||
row_result.append(fn(bin_slice))
|
||||
result.append(row_result)
|
||||
|
||||
if dims > 1:
|
||||
return np.array(result)
|
||||
else:
|
||||
return np.array(result[0])
|
||||
|
||||
|
||||
def produce_window_batches(data_chunk: list,
|
||||
model_configuration: dict,
|
||||
offsets: list | np.ndarray = None) -> list:
|
||||
"""
|
||||
Produces batches of windows from the data chunk.
|
||||
Args:
|
||||
data_chunk: data chunk
|
||||
model_configuration: configuration to use
|
||||
offsets: offsets used to determine the length of data to use, uses item[-offset:] of data
|
||||
|
||||
Returns:
|
||||
data: list of windows
|
||||
"""
|
||||
input_window_length = model_configuration["input_window_length"]
|
||||
output_window_length = model_configuration["output_window_length"]
|
||||
output_window_offset = model_configuration["output_window_offset"]
|
||||
window_shift = model_configuration["preprocessing"]["window_shift"]
|
||||
|
||||
if offsets is None:
|
||||
offsets = np.zeros(len(data_chunk), dtype=int)
|
||||
|
||||
data = list()
|
||||
for i, item in enumerate(data_chunk):
|
||||
item_length = item["target_features"][-offsets[i]:].shape[0]
|
||||
total_window_length = input_window_length if input_window_length > output_window_length + output_window_offset \
|
||||
else output_window_length + output_window_offset
|
||||
|
||||
if item_length < total_window_length:
|
||||
continue
|
||||
|
||||
# number of windows is defined by the input window length and output window length with offset
|
||||
num_windows = get_number_of_windows(item_length, model_configuration)
|
||||
for j in range(num_windows):
|
||||
window = dict()
|
||||
for key, value in item.items():
|
||||
if value is None:
|
||||
window[key] = None
|
||||
else:
|
||||
if key == "target_features":
|
||||
window[key] = value[-offsets[i]:][j * window_shift + output_window_offset:
|
||||
j * window_shift + output_window_length + output_window_offset]
|
||||
else:
|
||||
window[key] = value[-offsets[i]:][j * window_shift:j * window_shift + input_window_length]
|
||||
|
||||
data.append(window)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def produce_simple_batches(data_chunk: list,
|
||||
model_configuration: dict,
|
||||
offsets: list | np.ndarray = None) -> list:
|
||||
"""
|
||||
Produces batches without windowing.
|
||||
Args:
|
||||
data_chunk: data chunk
|
||||
model_configuration: configuration to use
|
||||
offsets: offsets used to determine the length of data to use, uses item[-offset:] of data
|
||||
|
||||
Returns:
|
||||
data: list of items
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class LMDBIterableDataset(IterableDataset):
|
||||
def __init__(self,
|
||||
lmdb_env_path: str,
|
||||
lmdb_keys: list[str],
|
||||
model_configuration: dict,
|
||||
batch_size: int = 32):
|
||||
self.lmdb_path = lmdb_env_path
|
||||
self.lmdb_env = None
|
||||
self.lmdb_keys = lmdb_keys
|
||||
self.key_subset = None
|
||||
self.model_configuration = model_configuration
|
||||
self.batch_size = batch_size
|
||||
self.keys_stats = load_key_stats(model_configuration["feature_config"]["dataset_dir"])
|
||||
self.len = None
|
||||
|
||||
try:
|
||||
self.model_configuration["batch_fn"]
|
||||
except KeyError:
|
||||
raise KeyError("Batch function not found in model configuration")
|
||||
|
||||
def set_key_subset(self, key_subset: list[str]):
|
||||
"""
|
||||
Set the key subset to use for the dataset.
|
||||
Args:
|
||||
key_subset: list of keys to use
|
||||
"""
|
||||
self.key_subset = key_subset
|
||||
# reset length
|
||||
self.len = None
|
||||
|
||||
def get_length_of_data_subset(self, key_set: list[str]):
|
||||
"""
|
||||
Get the length of the data subset.
|
||||
Args:
|
||||
key_set: list of keys to use
|
||||
"""
|
||||
# run simplified version of __iter__ to get the length
|
||||
|
||||
num_steps = 0
|
||||
i = 0
|
||||
num_batch_items = 0
|
||||
while True:
|
||||
if num_batch_items >= self.batch_size:
|
||||
num_steps += 1
|
||||
num_batch_items -= self.batch_size
|
||||
else:
|
||||
if i >= len(key_set):
|
||||
if num_batch_items > 0:
|
||||
num_steps += 1
|
||||
break
|
||||
key = key_set[i]
|
||||
i += 1
|
||||
|
||||
# try to fetch stats from key_stats
|
||||
try:
|
||||
current_key_stats = self.keys_stats["by_key"][key]
|
||||
base_length = current_key_stats["item_length"]
|
||||
take_every_nth = self.model_configuration["preprocessing"]["take_every_nth"]
|
||||
padding_length = get_padding_length(self.model_configuration, resampled=False)
|
||||
raw_length = base_length + padding_length
|
||||
item_length = int(raw_length // take_every_nth)
|
||||
except:
|
||||
item = load_from_lmdb(self.lmdb_env, key)
|
||||
item_length = get_prepared_sequence_length(
|
||||
item["target_features"][list(item["target_features"])[0]],
|
||||
self.model_configuration)
|
||||
num_windows = get_number_of_windows(item_length, self.model_configuration)
|
||||
num_batch_items += num_windows
|
||||
|
||||
return num_steps
|
||||
|
||||
def __iter__(self):
|
||||
self.init_lmdb_env()
|
||||
|
||||
# if no key subset is set, use all keys
|
||||
if self.key_subset is None:
|
||||
self.key_subset = self.lmdb_keys
|
||||
|
||||
random.shuffle(self.key_subset)
|
||||
|
||||
batch = list()
|
||||
counter = 0
|
||||
collate_fn = self.model_configuration["collate_fn"]
|
||||
while True:
|
||||
if len(batch) >= self.batch_size:
|
||||
if collate_fn is None:
|
||||
yield batch[:self.batch_size]
|
||||
else:
|
||||
yield collate_fn(batch[:self.batch_size])
|
||||
batch = batch[self.batch_size:]
|
||||
else:
|
||||
if counter >= len(self.key_subset):
|
||||
if len(batch) > 0:
|
||||
yield collate_fn(batch)
|
||||
break
|
||||
key = self.lmdb_keys[counter]
|
||||
counter += 1
|
||||
try:
|
||||
current_batch = get_batch_for_key(key, self.model_configuration, lmdb_env=self.lmdb_env)
|
||||
except ValueError:
|
||||
# if betch is empty, try next
|
||||
continue
|
||||
batch.extend(current_batch)
|
||||
|
||||
def init_lmdb_env(self):
|
||||
if self.lmdb_env is None:
|
||||
self.lmdb_env = lmdb.open(self.lmdb_path,
|
||||
readonly=True,
|
||||
lock=False,
|
||||
readahead=False,
|
||||
meminit=False)
|
||||
|
||||
def __len__(self):
|
||||
self.init_lmdb_env()
|
||||
|
||||
# if no key subset is set, use all keys
|
||||
if self.key_subset is None:
|
||||
self.key_subset = self.lmdb_keys
|
||||
|
||||
if self.len is None:
|
||||
num_steps = self.get_length_of_data_subset(self.key_subset)
|
||||
self.len = num_steps
|
||||
|
||||
return self.len
|
||||
@@ -0,0 +1,235 @@
|
||||
import os
|
||||
|
||||
import lmdb
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
from sklearn.preprocessing import StandardScaler, MinMaxScaler
|
||||
import joblib
|
||||
|
||||
from utils.lmdb_utils import load_from_lmdb, get_lmdb_keys
|
||||
|
||||
|
||||
def get_features(cycle: dict,
|
||||
feature_config: dict) -> dict:
|
||||
"""
|
||||
Computes the feature for a given cycle defined by the feature_config
|
||||
:param cycle: cycle data as dictionary
|
||||
:param feature_config: feature config as dictionary
|
||||
:return: dict with feature according feature config
|
||||
"""
|
||||
features = {}
|
||||
feature_sets = feature_config["feature_sets"]
|
||||
for feature_set in feature_sets:
|
||||
for feature_def in feature_config[feature_set]:
|
||||
if feature_set not in features:
|
||||
features[feature_set] = {}
|
||||
|
||||
# skip features that are marked as ignored
|
||||
if "ignored_features" in feature_config and feature_def["name"] in feature_config["ignored_features"]:
|
||||
continue
|
||||
|
||||
feature_return = feature_def["fn"](cycle=cycle)
|
||||
if isinstance(feature_return, dict):
|
||||
if len(feature_return) > 1:
|
||||
for feature_name, feature in feature_return.items():
|
||||
features[feature_set][f"{feature_def['name']}_{feature_name}"] = feature
|
||||
else:
|
||||
features[feature_set][feature_def["name"]] = list(feature_return.values())[0]
|
||||
else:
|
||||
features[feature_set][feature_def["name"]] = feature_return
|
||||
|
||||
# check, if all features have same length
|
||||
if feature_set in features and len(features[feature_set]) > 0 and isinstance(
|
||||
list(features[feature_set].values())[0], np.ndarray):
|
||||
feature_lengths = [len(x) for x in features[feature_set].values()]
|
||||
if len(set(feature_lengths)) > 1:
|
||||
raise ValueError(f"Feature set {feature_set} has features of different lengths: {feature_lengths}")
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def save_scalers(scalers: dict,
|
||||
scaler_dir: str):
|
||||
if not os.path.exists(scaler_dir):
|
||||
os.makedirs(scaler_dir)
|
||||
for feature_type in scalers:
|
||||
for feature_name in scalers[feature_type]:
|
||||
joblib.dump(scalers[feature_type][feature_name],
|
||||
f"{scaler_dir}/{feature_name}.pkl")
|
||||
|
||||
|
||||
def load_scalers(scaler_dir: str) -> dict:
|
||||
scalers = dict()
|
||||
for scaler_file in os.listdir(scaler_dir):
|
||||
if scaler_file.endswith(".pkl"):
|
||||
feature_name = scaler_file.replace(".pkl", "")
|
||||
scalers[feature_name] = joblib.load(f"{scaler_dir}/{scaler_file}")
|
||||
return scalers
|
||||
|
||||
|
||||
def get_scalers_for_model(model_configuration: dict):
|
||||
"""
|
||||
Get the scalers for the features of a model configuration
|
||||
Args:
|
||||
model_configuration: configuration of the model
|
||||
|
||||
Returns:
|
||||
scalers: scalers for the model
|
||||
|
||||
"""
|
||||
|
||||
scaler_dir = os.path.join(model_configuration["feature_config"]["dataset_dir"], "scalers")
|
||||
scalers = load_scalers(scaler_dir)
|
||||
return scalers
|
||||
|
||||
|
||||
def get_feature_values(feature_type: str | None,
|
||||
feature_name: str,
|
||||
env: lmdb.Environment,
|
||||
keys: list = None) -> list:
|
||||
"""
|
||||
Get the values of a feature from the lmdb dataset for all keys
|
||||
:param feature_type: type of feature, can be None, then the first feature with the given name will be used
|
||||
:param feature_name: feature name to extract
|
||||
:param env: lmdb environment
|
||||
:param keys: keys to extract the feature from, if None, all keys will be used / fetched from the database
|
||||
:return: list with feature values
|
||||
"""
|
||||
if keys is None:
|
||||
keys = get_lmdb_keys(env)
|
||||
feature_values = []
|
||||
for key in keys:
|
||||
data = load_from_lmdb(env, key)
|
||||
if feature_type is None:
|
||||
for feature_type, feature_data in data.items():
|
||||
if feature_name in feature_data:
|
||||
feature_values.append(feature_data[feature_name])
|
||||
break
|
||||
else:
|
||||
for current_feature_name, feature_data in data[feature_type].items():
|
||||
# catch sub features that have been prefixed with the feature name
|
||||
if current_feature_name.startswith(feature_name):
|
||||
feature_values.append(feature_data)
|
||||
break
|
||||
return feature_values
|
||||
|
||||
|
||||
def train_scalers(feature_type: str,
|
||||
feature_name: str,
|
||||
scaler_type,
|
||||
sample,
|
||||
env) -> dict:
|
||||
scalers = dict()
|
||||
all_features = sample[feature_type]
|
||||
individual_feature_names = list()
|
||||
for individual_feature_name in all_features:
|
||||
if individual_feature_name.startswith(feature_name) and not individual_feature_name.endswith("_scaled"):
|
||||
individual_feature_names.append(individual_feature_name)
|
||||
|
||||
for individual_feature_name in individual_feature_names:
|
||||
feature_values = get_feature_values(feature_type, individual_feature_name, env)
|
||||
if len(feature_values) == 0:
|
||||
raise ValueError(f"No feature values found for feature {individual_feature_name}")
|
||||
if isinstance(feature_values[0], list) or isinstance(feature_values[0], np.ndarray):
|
||||
features_reshaped = np.concatenate(feature_values).reshape(-1, 1)
|
||||
else:
|
||||
features_reshaped = np.array(feature_values).reshape(-1, 1)
|
||||
del feature_values
|
||||
|
||||
if scaler_type is not None:
|
||||
scaler = scaler_type()
|
||||
scaler.fit(features_reshaped)
|
||||
else:
|
||||
scaler = None
|
||||
|
||||
scalers[individual_feature_name] = scaler
|
||||
return scalers
|
||||
|
||||
|
||||
def scale_item(data: dict,
|
||||
scalers: dict):
|
||||
data_format = dict()
|
||||
for feature_set in data:
|
||||
if feature_set not in data_format:
|
||||
data_format[feature_set] = dict()
|
||||
for feature_name in data[feature_set]:
|
||||
if feature_name.endswith("_scaled"):
|
||||
continue
|
||||
data_format[feature_set][feature_name] = data[feature_set][feature_name]
|
||||
|
||||
for feature_set in data_format:
|
||||
for feature_name in data_format[feature_set]:
|
||||
feature_values = data[feature_set][feature_name]
|
||||
if scalers[feature_name] is not None:
|
||||
if isinstance(feature_values, list) or isinstance(feature_values, np.ndarray):
|
||||
scaled_feature_values = scalers[feature_name].transform(
|
||||
feature_values.reshape(-1, 1)).flatten()
|
||||
else:
|
||||
scaled_feature_values = scalers[feature_name].transform(
|
||||
np.array(feature_values).reshape(-1, 1)).flatten()
|
||||
else:
|
||||
scaled_feature_values = feature_values
|
||||
data[feature_set][f"{feature_name}_scaled"] = scaled_feature_values
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def inverse_scale_feature(input_feature: np.ndarray | int | float | torch.Tensor,
|
||||
feature_names: np.ndarray | list | str,
|
||||
scalers: dict) -> np.ndarray:
|
||||
"""
|
||||
Inverse scales the input features, can handle single and multi feature input
|
||||
Args:
|
||||
input_feature: input feature as a numpy array
|
||||
feature_names: names of input features as reference for scalers
|
||||
scalers: dict of feature scalers
|
||||
|
||||
Returns:
|
||||
scaled input features as numpy array
|
||||
"""
|
||||
|
||||
if isinstance(feature_names, str):
|
||||
feature_names = [feature_names]
|
||||
|
||||
if isinstance(input_feature, np.ndarray) or isinstance(input_feature, torch.Tensor):
|
||||
input_dim = input_feature.shape[1] if len(input_feature.shape) > 1 else 1
|
||||
elif isinstance(input_feature, float) or isinstance(input_feature, int):
|
||||
# handle case, where input is scalar
|
||||
input_scaled = scalers[feature_names[0]].inverse_transform([[input_feature]])[0][0]
|
||||
return input_scaled
|
||||
elif isinstance(input_feature, list):
|
||||
input_feature = np.array(input_feature)
|
||||
input_dim = input_feature.shape[1] if len(input_feature.shape) > 1 else 1
|
||||
else:
|
||||
raise ValueError(f"Unsupported input type: {type(input_feature)}")
|
||||
|
||||
# reshape, if input is one dimensional
|
||||
if input_dim == 1:
|
||||
input_feature = input_feature.reshape(-1, 1)
|
||||
|
||||
input_scaled = input_feature.copy()
|
||||
for i in range(input_dim):
|
||||
input_scaled[:, i] = scalers[feature_names[i]].inverse_transform(np.array([input_scaled[:, i]]))
|
||||
|
||||
return input_scaled
|
||||
|
||||
|
||||
def combine_features(cycles: list[dict], feature_config: dict) -> dict:
|
||||
combined_features = dict()
|
||||
for feature_set in feature_config["feature_sets"]:
|
||||
combined_features[feature_set] = dict()
|
||||
if feature_set not in cycles[0]:
|
||||
continue
|
||||
features_in_set = cycles[0][feature_set].keys()
|
||||
for feature in features_in_set:
|
||||
feature_values = [cycles[i][feature_set][feature] for i in range(len(cycles))]
|
||||
if isinstance(feature_values[0], dict):
|
||||
for key in feature_values[0].keys():
|
||||
feature_array = np.concatenate([feature_values[i][key] for i in range(len(feature_values))])
|
||||
combined_features[feature_set][key] = feature_array
|
||||
else:
|
||||
feature_array = np.concatenate(feature_values)
|
||||
combined_features[feature_set][feature] = feature_array
|
||||
|
||||
return combined_features
|
||||
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
import pickle
|
||||
|
||||
|
||||
def get_dataset_path(lmdb_base_dir: str,
|
||||
dataset_name: str) -> str:
|
||||
"""
|
||||
Get the path to the dataset in the lmdb directory
|
||||
Args:
|
||||
lmdb_base_dir: base directory of the lmdb dataset
|
||||
dataset_name: name of the dataset
|
||||
|
||||
Returns:
|
||||
path to the dataset
|
||||
"""
|
||||
|
||||
dataset_path = os.path.join(lmdb_base_dir, dataset_name)
|
||||
if not os.path.exists(dataset_path):
|
||||
raise FileNotFoundError(f"Dataset {dataset_name} not found in {lmdb_base_dir}")
|
||||
return dataset_path
|
||||
|
||||
|
||||
def load_key_stats(lmdb_dir: str) -> dict:
|
||||
"""
|
||||
Load key statistics from the LMDB database.
|
||||
:param lmdb_dir: Directory of the LMDB database.
|
||||
:return: Dictionary with statistics.
|
||||
"""
|
||||
key_stats_path = f"{lmdb_dir}/key_stats.pickle"
|
||||
|
||||
if not os.path.exists(key_stats_path):
|
||||
return dict()
|
||||
|
||||
with open(key_stats_path, "rb") as f:
|
||||
key_stats = pickle.load(f)
|
||||
return key_stats
|
||||
@@ -0,0 +1,266 @@
|
||||
import lmdb
|
||||
import numpy as np
|
||||
import sklearn
|
||||
import torch
|
||||
from torch import nn
|
||||
from tqdm import tqdm
|
||||
|
||||
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 vsm_datascience_common import constants
|
||||
|
||||
|
||||
def evaluate_model(model_configuration: dict,
|
||||
training_configuration: dict,
|
||||
test_ids: list,
|
||||
evaluation_functions: list) -> dict:
|
||||
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:
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
load_fn = model_configuration["model_load_fn"]
|
||||
model = load_fn(model_configuration=model_configuration,
|
||||
training_configuration=training_configuration,
|
||||
sample_key=test_ids[0],
|
||||
device=device,
|
||||
lmdb_env=lmdb_env)
|
||||
# load key stats to retrieve individual cycles
|
||||
keys_stats = load_key_stats(model_configuration["feature_config"]["dataset_dir"])
|
||||
|
||||
batch_size = training_configuration["batch_size"]
|
||||
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):
|
||||
# fetch stats for key
|
||||
current_key_stats = keys_stats["by_key"][test_id]
|
||||
cycle_stats = current_key_stats["cycle_stats"]
|
||||
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
|
||||
scaled_preds = list()
|
||||
for j in range(num_outputs):
|
||||
if isinstance(preds, torch.Tensor):
|
||||
preds = preds.cpu().numpy()
|
||||
|
||||
if len(preds.shape) == 3:
|
||||
output = preds[:, 0, j].squeeze()
|
||||
else:
|
||||
output = preds[:, j] if num_outputs > 1 else preds
|
||||
|
||||
if "loss_functions" in training_configuration and len(training_configuration["loss_functions"]) > j:
|
||||
if isinstance(training_configuration["loss_functions"][j], nn.BCEWithLogitsLoss):
|
||||
output = torch.sigmoid(torch.tensor(output)).numpy()
|
||||
|
||||
scaled_output = inverse_scale_feature(output,
|
||||
used_targets[j],
|
||||
scalers)
|
||||
|
||||
scaled_preds.append(scaled_output)
|
||||
|
||||
scaled_actuals = list()
|
||||
for j in range(num_outputs):
|
||||
if len(actuals.shape) == 3:
|
||||
output = actuals[:, 0, j].squeeze()
|
||||
else:
|
||||
output = actuals[:, j] if num_outputs > 1 else actuals
|
||||
if isinstance(output, torch.Tensor):
|
||||
output = output.cpu().numpy()
|
||||
scaled_output = inverse_scale_feature(output,
|
||||
used_targets[j],
|
||||
scalers).ravel()
|
||||
|
||||
scaled_actuals.append(scaled_output)
|
||||
|
||||
for eval_fn in evaluation_functions:
|
||||
if eval_fn is not None:
|
||||
eval_fn_name = eval_fn["name"]
|
||||
eval_function = eval_fn["eval_fn"]
|
||||
eval_fn_index = eval_fn["input_index"]
|
||||
# skip error fn if actuals are nan, since they are ignored
|
||||
if any(np.isnan(scaled_actuals[eval_fn_index])):
|
||||
continue
|
||||
error = eval_function(scaled_preds[eval_fn_index], scaled_actuals[eval_fn_index])
|
||||
|
||||
if np.isnan(error):
|
||||
# skip if error is nan
|
||||
continue
|
||||
|
||||
if eval_fn_name not in errors:
|
||||
errors[eval_fn_name] = dict()
|
||||
|
||||
if f"after_{i}" not in errors[eval_fn_name]:
|
||||
errors[eval_fn_name][f"after_{i}"] = list()
|
||||
|
||||
errors[eval_fn_name][f"after_{i}"].append(error)
|
||||
|
||||
for eval_fn in evaluation_functions:
|
||||
if eval_fn is not None:
|
||||
eval_fn_name = eval_fn["name"]
|
||||
accumulation_fn = eval_fn["accumulation_fn"]
|
||||
for key in errors[eval_fn_name]:
|
||||
if len(errors[eval_fn_name][key]) == 0:
|
||||
errors[eval_fn_name][key] = np.nan
|
||||
else:
|
||||
errors[eval_fn_name][key] = accumulation_fn(errors[eval_fn_name][key])
|
||||
return errors
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
index_offset = int(day_relative_to_ov * (shift_hour_factor // 24))
|
||||
|
||||
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
|
||||
@@ -0,0 +1,372 @@
|
||||
from datetime import timedelta
|
||||
from typing import Callable
|
||||
|
||||
import numpy as np
|
||||
from vsm_datascience_common import constants
|
||||
from vsm_datascience_common.cycle_database_connection.db_utils import get_cycles_collection
|
||||
from vsm_datascience_common.cycles.sequences import get_timestamps, get_values
|
||||
|
||||
from utils.smoothing import get_curve_composition
|
||||
|
||||
|
||||
def get_day_of_week_encoded(cycle: dict, shift: int = 0) -> dict:
|
||||
timestamps = get_timestamps(cycle)
|
||||
weekdays = np.array([x.weekday() for x in timestamps])
|
||||
sine_encoded = np.sin(weekdays * (2 * np.pi / 7))
|
||||
cosine_encoded = np.cos(weekdays * (2 * np.pi / 7))
|
||||
return {
|
||||
"sine": sine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None],
|
||||
"cosine": cosine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
}
|
||||
|
||||
|
||||
def get_hour_of_day_encoded(cycle: dict, shift: int = 0) -> dict:
|
||||
timestamps = get_timestamps(cycle)
|
||||
hours = np.array([x.hour for x in timestamps])
|
||||
sine_encoded = np.sin(hours * (2 * np.pi / 24))
|
||||
cosine_encoded = np.cos(hours * (2 * np.pi / 24))
|
||||
return {
|
||||
"sine": sine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None],
|
||||
"cosine": cosine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
}
|
||||
|
||||
|
||||
def get_month_of_year_encoded(cycle: dict, shift: int = 0) -> dict:
|
||||
timestamps = get_timestamps(cycle)
|
||||
months = np.array([x.month for x in timestamps])
|
||||
sine_encoded = np.sin(months * (2 * np.pi / 12))
|
||||
cosine_encoded = np.cos(months * (2 * np.pi / 12))
|
||||
return {
|
||||
"sine": sine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None],
|
||||
"cosine": cosine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
}
|
||||
|
||||
|
||||
def get_hours_from_start(cycle: dict, shift: int = 0) -> np.ndarray:
|
||||
timestamps = get_timestamps(cycle)
|
||||
return np.arange(len(timestamps))[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
|
||||
|
||||
def get_temperature(cycle: dict, shift: int = 0) -> np.ndarray:
|
||||
return get_values(cycle)[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
|
||||
|
||||
def get_fertility_curve(cycle_length: int,
|
||||
ov_index: int,
|
||||
fertility_base_curve: np.ndarray | list,
|
||||
base_curve_offset: int) -> np.ndarray:
|
||||
fert_prob = np.zeros((cycle_length,))
|
||||
if ov_index + base_curve_offset < 0:
|
||||
length_in_cycle = len(fertility_base_curve) + ov_index + base_curve_offset
|
||||
fert_prob[:length_in_cycle] = fertility_base_curve[-length_in_cycle:]
|
||||
elif ov_index + base_curve_offset + len(fertility_base_curve) > cycle_length:
|
||||
length_in_cycle = cycle_length - ov_index - base_curve_offset
|
||||
fert_prob[ov_index + base_curve_offset:] = fertility_base_curve[:length_in_cycle]
|
||||
else:
|
||||
fert_prob[ov_index + base_curve_offset:
|
||||
ov_index + base_curve_offset + len(fertility_base_curve)] = fertility_base_curve
|
||||
return fert_prob
|
||||
|
||||
|
||||
def get_fertility_probability_base_curve() -> (np.ndarray, int):
|
||||
"""
|
||||
This function returns the base curve for the fertility probability as well as the offset to the ovulation day in measurements
|
||||
:return: numpy array with the base curve resampled to the number of measurements per day
|
||||
"""
|
||||
fertilization_chance_curve = [0, 0.07, 0.12, 0.25, 0.3, 0.18, 0]
|
||||
offset_to_ov = -6
|
||||
|
||||
new_indices = np.arange(len(fertilization_chance_curve) * constants.MEASUREMENTS_PER_DAY)
|
||||
fertility_chance_curve_resampled = np.interp(new_indices,
|
||||
np.linspace(0, len(new_indices), num=len(fertilization_chance_curve)),
|
||||
fertilization_chance_curve)
|
||||
ov_offset_resampled = offset_to_ov * constants.MEASUREMENTS_PER_DAY
|
||||
return fertility_chance_curve_resampled, ov_offset_resampled
|
||||
|
||||
|
||||
def get_fertility_probability(cycle: dict, shift: int = 0) -> np.ndarray:
|
||||
# check, if biphasic
|
||||
if "classification_results" in cycle and "results" in cycle["classification_results"][0]:
|
||||
if cycle["classification_results"][0]["results"]["predicted_class"] != "biphasic":
|
||||
return np.zeros((len(get_values(cycle)) - abs(shift, )))
|
||||
else:
|
||||
return np.zeros((len(get_values(cycle)) - abs(shift, )))
|
||||
|
||||
if "ov_detection_results" in cycle and "results" in cycle["ov_detection_results"][0]:
|
||||
try:
|
||||
ov_timestamp = cycle["ov_detection_results"][0]["results"]["ovulation_timestamp"]
|
||||
timestamps = get_timestamps(cycle)
|
||||
ov_index = np.where(timestamps >= ov_timestamp)[0][0]
|
||||
fertility_chance_curve_resampled, ov_offset_resampled = get_fertility_probability_base_curve()
|
||||
return get_fertility_curve(len(get_values(cycle)), ov_index, fertility_chance_curve_resampled,
|
||||
ov_offset_resampled)[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
except:
|
||||
return np.zeros((len(get_values(cycle)) - abs(shift, )))
|
||||
else:
|
||||
return np.zeros((len(get_values(cycle)) - abs(shift, )))
|
||||
|
||||
|
||||
def get_ov_over_probability(cycle: dict, shift: int = 0) -> np.ndarray:
|
||||
"""
|
||||
Get the ovulation over probability for a cycle
|
||||
:param cycle: cycle data
|
||||
:param shift: shift to apply
|
||||
:return: ovulation over probability
|
||||
"""
|
||||
if "ov_detection_results" in cycle and "results" in cycle["ov_detection_results"][0]:
|
||||
try:
|
||||
ov_timestamp = cycle["ov_detection_results"][0]["results"]["ovulation_timestamp"]
|
||||
timestamps = get_timestamps(cycle)
|
||||
ov_index = np.where(timestamps > ov_timestamp)[0][0]
|
||||
ov_over_probability = np.zeros(len(get_values(cycle)))
|
||||
ov_over_probability[ov_index:] = 1
|
||||
return ov_over_probability[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
except:
|
||||
return np.zeros(len(get_values(cycle)))[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
else:
|
||||
return np.zeros(len(get_values(cycle)))[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
|
||||
|
||||
def get_days_relative_to_ov(cycle: dict) -> np.ndarray:
|
||||
"""
|
||||
Get the time in days until the next OV event.
|
||||
"""
|
||||
timestamps = get_timestamps(cycle)
|
||||
if ("ov_detection_results" in cycle and "results" in cycle["ov_detection_results"][0]) and (
|
||||
"classification_results" in cycle and "results" in cycle["classification_results"][0] and
|
||||
cycle["classification_results"][0]["results"]["predicted_class"] == "biphasic"):
|
||||
try:
|
||||
ov_timestamp = cycle["ov_detection_results"][0]["results"]["ovulation_timestamp"]
|
||||
ov_index = np.where(timestamps > ov_timestamp)[0][0]
|
||||
ov_timestamp = timestamps[ov_index]
|
||||
days_until_ov = np.empty(len(timestamps))
|
||||
for i in range(len(timestamps)):
|
||||
current_day = timestamps[i]
|
||||
day_diff = ov_timestamp - current_day
|
||||
if day_diff.days < -1:
|
||||
pass
|
||||
# days_until_ov[i] = max(day_diff.days -1)
|
||||
# use - days, as days relative to event
|
||||
days_until_ov[i] = - day_diff.days
|
||||
|
||||
return np.array(days_until_ov)
|
||||
except:
|
||||
pass
|
||||
|
||||
return np.full((len(timestamps),), np.nan)
|
||||
|
||||
|
||||
def get_ov_day(cycle: dict) -> np.ndarray:
|
||||
"""
|
||||
Get the ovulation day for a cycle
|
||||
:param cycle: cycle data
|
||||
:return: ovulation day
|
||||
"""
|
||||
values = get_values(cycle)
|
||||
seq_len = len(values)
|
||||
is_biphasic = "classification_results" in cycle and "results" in cycle["classification_results"][0] and \
|
||||
cycle["classification_results"][0]["results"]["predicted_class"] == "biphasic"
|
||||
has_ov_results = "ov_detection_results" in cycle and "results" in cycle["ov_detection_results"][0]
|
||||
if is_biphasic and has_ov_results:
|
||||
try:
|
||||
ov_day = cycle["ov_detection_results"][0]["results"]["ovulation_day"]
|
||||
return np.full((seq_len,), ov_day)
|
||||
except:
|
||||
pass
|
||||
|
||||
return np.full((seq_len,), np.nan)
|
||||
|
||||
|
||||
def get_is_biphasic(cycle: dict) -> np.ndarray:
|
||||
"""
|
||||
Get the is_biphasic flag for a cycle
|
||||
:param cycle: cycle data
|
||||
:return: is_biphasic flag
|
||||
"""
|
||||
values = get_values(cycle)
|
||||
seq_len = len(values)
|
||||
is_biphasic = "classification_results" in cycle and "results" in cycle["classification_results"][0] and \
|
||||
cycle["classification_results"][0]["results"]["predicted_class"] == "biphasic"
|
||||
if is_biphasic:
|
||||
return np.full((seq_len,), 1)
|
||||
|
||||
return np.full((seq_len,), 0)
|
||||
|
||||
|
||||
def get_curve_composition_as_features(cycle: dict, shift: int = 0) -> dict:
|
||||
composition = get_curve_composition(get_values(cycle))
|
||||
return {
|
||||
"trend": composition[0][shift if shift > 0 else 0:shift if shift < 0 else None],
|
||||
"seasonal": composition[1][shift if shift > 0 else 0:shift if shift < 0 else None],
|
||||
"residual": composition[2][shift if shift > 0 else 0:shift if shift < 0 else None],
|
||||
"smoothed": composition[3][shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
}
|
||||
|
||||
|
||||
def get_rolling_average_with_padding(cycle: dict,
|
||||
shift: int = 0,
|
||||
rolling_average_window_length: int = constants.MEASUREMENTS_PER_DAY) -> np.ndarray:
|
||||
"""
|
||||
Get the rolling average of the temperature values with padding, so that the rolling average has the same length as the
|
||||
original temperature values
|
||||
Args:
|
||||
cycle (dict): cycle data
|
||||
shift (int): shift to apply
|
||||
rolling_average_window_length (int): window length for the rolling average, default is one day (288 measurements)
|
||||
|
||||
Returns:
|
||||
np.ndarray: rolling average of the temperature values with padding
|
||||
|
||||
"""
|
||||
|
||||
values = get_values(cycle)
|
||||
values_padded = np.concatenate(
|
||||
[np.full((constants.MEASUREMENTS_PER_DAY,), np.mean(values[:constants.MEASUREMENTS_PER_DAY])), values])
|
||||
rolling_average = np.convolve(values_padded, np.ones(rolling_average_window_length) / rolling_average_window_length,
|
||||
mode="valid")[-len(values):]
|
||||
return rolling_average[shift if shift > 0 else 0:shift if shift < 0 else None]
|
||||
|
||||
|
||||
def get_window_fn(cycle: dict,
|
||||
window_size: int,
|
||||
fn: Callable) -> np.ndarray:
|
||||
"""
|
||||
Apply a window function to a time series.
|
||||
:param cycle: cycle data
|
||||
:param window_size: window size
|
||||
:return: array with minimum value in window for each value in input array
|
||||
"""
|
||||
values = get_values(cycle)
|
||||
values_padded = np.concatenate([np.full(window_size - 1, values[0]), values])
|
||||
|
||||
windows = np.lib.stride_tricks.sliding_window_view(values_padded, window_shape=window_size)
|
||||
return fn(windows)
|
||||
|
||||
|
||||
def get_cycle_length_stats(cycle: dict) -> dict:
|
||||
user_id = cycle["user_id"]
|
||||
cycle_length = len(get_values(cycle))
|
||||
cycles_dates = list(get_cycles_collection().find({
|
||||
"user_id": user_id, "ends_at": {"$exists": True},
|
||||
"starts_at": {"$lt": cycle["starts_at"]},
|
||||
},
|
||||
{"starts_at": 1, "ends_at": 1}))
|
||||
if len(cycles_dates) == 0:
|
||||
# use the average cycle length as fallback
|
||||
average_cycle_length = 37.13
|
||||
cycle_length_std = 12.42
|
||||
else:
|
||||
cycle_lengths = [x["ends_at"] - x["starts_at"] for x in cycles_dates]
|
||||
average_cycle_length = np.mean(cycle_lengths).total_seconds() / (60 * 60 * 24)
|
||||
cycle_length_std = np.std([x.total_seconds() for x in cycle_lengths]) / (60 * 60 * 24)
|
||||
|
||||
return {
|
||||
"average_cycle_length": np.full((cycle_length,), average_cycle_length),
|
||||
"cycle_length_std": np.full((cycle_length,), cycle_length_std),
|
||||
}
|
||||
|
||||
|
||||
def get_average_ovulation_day(cycle: dict) -> float:
|
||||
user_id = cycle["user_id"]
|
||||
cycle_length = len(get_values(cycle))
|
||||
cycles_dates = list(
|
||||
get_cycles_collection().find({
|
||||
"user_id": user_id,
|
||||
"ov_detection_results.0.results": {"$exists": True},
|
||||
"starts_at": {"$lt": cycle["starts_at"]},
|
||||
},
|
||||
{"ov_detection_results": 1}))
|
||||
if len(cycles_dates) == 0:
|
||||
# use the average ovulation day as fallback
|
||||
average_ovulation_day = 18.9
|
||||
else:
|
||||
ovulation_days = [x["ov_detection_results"][0]["results"]["ovulation_day"] for x in cycles_dates]
|
||||
average_ovulation_day = np.mean(ovulation_days)
|
||||
return np.full((cycle_length,), average_ovulation_day)
|
||||
|
||||
|
||||
def get_ovulation_std(cycle: dict) -> float:
|
||||
user_id = cycle["user_id"]
|
||||
cycle_length = len(get_values(cycle))
|
||||
cycles_dates = list(
|
||||
get_cycles_collection().find({
|
||||
"user_id": user_id,
|
||||
"ov_detection_results.0.results": {"$exists": True},
|
||||
"starts_at": {"$lt": cycle["starts_at"]},
|
||||
},
|
||||
{"ov_detection_results": 1}))
|
||||
if len(cycles_dates) == 0:
|
||||
# use the average ovulation std as fallback
|
||||
ovulation_std = 4.07
|
||||
else:
|
||||
ovulation_days = [x["ov_detection_results"][0]["results"]["ovulation_day"] for x in cycles_dates]
|
||||
ovulation_std = np.std(ovulation_days)
|
||||
return np.full((cycle_length,), ovulation_std)
|
||||
|
||||
|
||||
def get_biphasic_fraction(cycle: dict) -> float:
|
||||
user_id = cycle["user_id"]
|
||||
cycle_length = len(get_values(cycle))
|
||||
cycle_dates = list(
|
||||
get_cycles_collection().find({
|
||||
"user_id": user_id,
|
||||
"classification_results.0.results": {"$exists": True},
|
||||
"starts_at": {"$lt": cycle["starts_at"]},
|
||||
},
|
||||
{"classification_results": 1}))
|
||||
if len(cycle_dates) == 0:
|
||||
# use the average cycle length as fallback
|
||||
biphasic_fraction = 0.9449
|
||||
else:
|
||||
biphasic = [x for x in cycle_dates if
|
||||
x["classification_results"][0]["results"]["predicted_class"] == "biphasic"]
|
||||
biphasic_fraction = len(biphasic) / len(cycle_dates)
|
||||
return np.full((cycle_length,), biphasic_fraction)
|
||||
|
||||
|
||||
def get_num_cycles(cycle: dict) -> int:
|
||||
user_id = cycle["user_id"]
|
||||
cycle_length = len(get_values(cycle))
|
||||
cycles_dates = list(get_cycles_collection().find({"user_id": user_id,
|
||||
"ends_at": {"$exists": True},
|
||||
"starts_at": {"$lt": cycle["starts_at"]}
|
||||
},
|
||||
{"starts_at": 1, "ends_at": 1}))
|
||||
num_cycles = len(cycles_dates)
|
||||
return np.full((cycle_length,), num_cycles)
|
||||
|
||||
|
||||
def get_average_temperatures(cycle: dict) -> dict:
|
||||
user_id = cycle["user_id"]
|
||||
cycle_length = len(get_values(cycle))
|
||||
cycles_dates = list(
|
||||
get_cycles_collection().find({"user_id": user_id, "ov_detection_results.0.results": {"$exists": True}},
|
||||
{"starts_at": 1, "ov_detection_results": 1, "measurements": 1}))
|
||||
|
||||
if len(cycles_dates) == 0:
|
||||
return {
|
||||
"pre_ov_temperatures": np.full((cycle_length,), 37.07),
|
||||
"post_ov_temperatures": np.full((cycle_length,), 37.37),
|
||||
}
|
||||
|
||||
pre_ov_temperatures = []
|
||||
post_ov_temperatures = []
|
||||
for x in cycles_dates:
|
||||
ov_detection = x["ov_detection_results"][0]["results"]
|
||||
values = get_values(x)
|
||||
timestamps = get_timestamps(x)
|
||||
cutoff_day = x["starts_at"] + timedelta(days=ov_detection["ovulation_day"])
|
||||
cutoff_index = np.where(np.array(timestamps) >= cutoff_day)[0][0]
|
||||
pre_ov_temperatures.append(values[:cutoff_index])
|
||||
post_ov_temperatures.append(values[cutoff_index:])
|
||||
|
||||
pre_ov_temperatures = np.concatenate(pre_ov_temperatures)
|
||||
post_ov_temperatures = np.concatenate(post_ov_temperatures)
|
||||
|
||||
pre_ov_temperatures = np.mean(pre_ov_temperatures)
|
||||
post_ov_temperatures = np.mean(post_ov_temperatures)
|
||||
|
||||
return {
|
||||
"pre_ov_temperatures": np.full((cycle_length,), pre_ov_temperatures),
|
||||
"post_ov_temperatures": np.full((cycle_length,), post_ov_temperatures),
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import pickle
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def save_to_lmdb(env, key, dataset):
|
||||
"""
|
||||
Saves the given dataset to an LMDB environment with the given key.
|
||||
:param env: LMDB environment
|
||||
:param key: key to save the dataset to
|
||||
:param dataset: tuple of pandas dataframes
|
||||
"""
|
||||
|
||||
with env.begin(write=True) as txn:
|
||||
txn.put(key.encode('ascii'), pickle.dumps(dataset))
|
||||
|
||||
|
||||
def load_from_lmdb(env, key):
|
||||
"""
|
||||
Loads a dataset from an LMDB environment with the given key.
|
||||
:param env: LMDB environment
|
||||
:param key: key of the dataset to load
|
||||
:return: key and tuple of pandas dataframes (input, context, output)
|
||||
"""
|
||||
with env.begin(write=False) as txn:
|
||||
try:
|
||||
data = pickle.loads(txn.get(key.encode('ascii')))
|
||||
return data
|
||||
except TypeError:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
def delete_from_lmdb(env, key):
|
||||
"""
|
||||
Deletes a dataset from an LMDB environment with the given key.
|
||||
:param env: LMDB environment
|
||||
:param key: key of the dataset to delete
|
||||
"""
|
||||
with env.begin(write=True) as txn:
|
||||
txn.delete(key.encode('ascii'))
|
||||
|
||||
|
||||
def clear_lmdb(env):
|
||||
"""
|
||||
Clears all datasets from an LMDB environment.
|
||||
:param env: LMDB environment
|
||||
"""
|
||||
with env.begin(write=True) as txn:
|
||||
cursor = txn.cursor()
|
||||
for key, value in cursor:
|
||||
txn.delete(key)
|
||||
|
||||
|
||||
def lmdb_dataset_generator(env):
|
||||
"""
|
||||
Generator function to yield datasets from an LMDB environment.
|
||||
:param env: LMDB environment
|
||||
:return: generator
|
||||
"""
|
||||
with env.begin(write=False) as txn:
|
||||
cursor = txn.cursor()
|
||||
for key, value in cursor:
|
||||
data = pickle.loads(value)
|
||||
yield data
|
||||
|
||||
|
||||
def lmdb_contains(env, substring) -> bool:
|
||||
"""
|
||||
Checks if the given substring is contained in any of the keys of the LMDB environment.
|
||||
:param env: LMDB environment
|
||||
:param substring: substring to search for
|
||||
:return: boolean
|
||||
"""
|
||||
with env.begin(write=False) as txn:
|
||||
cursor = txn.cursor()
|
||||
for key, value in cursor:
|
||||
if substring in key.decode('ascii'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def lmdb_substring_key_search(env, substring):
|
||||
"""
|
||||
Searches for keys in the LMDB environment that contain the given substring.
|
||||
:param env: LMDB environment
|
||||
:param substring: substring to search for
|
||||
:return: list of keys
|
||||
"""
|
||||
keys = []
|
||||
with env.begin(write=False) as txn:
|
||||
cursor = txn.cursor()
|
||||
for key, value in cursor:
|
||||
if substring in key.decode('ascii'):
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
|
||||
def get_lmdb_keys(env, limit: int = None):
|
||||
"""
|
||||
Get all keys in the LMDB environment.
|
||||
:param env: LMDB environment
|
||||
:param limit: maximum number of keys to return
|
||||
:return: list of keys
|
||||
"""
|
||||
with env.begin(write=False) as txn:
|
||||
with txn.cursor() as cursor:
|
||||
keys = [key.decode("ascii") for key in cursor.iternext(keys=True, values=False)]
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def get_lmdb_keyspace_size(env):
|
||||
"""
|
||||
Get the number of keys in the LMDB environment.
|
||||
:param env: LMDB environment
|
||||
:return: number of keys
|
||||
"""
|
||||
with env.begin(write=False) as txn:
|
||||
return txn.stat()['entries']
|
||||
@@ -0,0 +1,118 @@
|
||||
import torch
|
||||
from torch import nn
|
||||
import torch.nn.functional as F
|
||||
import numpy as np
|
||||
|
||||
|
||||
class QuantileLoss(nn.Module):
|
||||
def __init__(self, config):
|
||||
super().__init__()
|
||||
self.register_buffer('q', torch.tensor(config.quantiles))
|
||||
|
||||
def forward(self, predictions, targets):
|
||||
diff = predictions - targets.unsqueeze(-1).expand(-1, -1, -1, predictions.shape[-1])
|
||||
ql = (1 - self.q) * F.relu(diff) + self.q * F.relu(-diff)
|
||||
losses = ql.view(-1, ql.shape[-1]).mean(0)
|
||||
return losses
|
||||
|
||||
|
||||
def qrisk(pred, tgt, quantiles):
|
||||
diff = pred - tgt
|
||||
ql = (1 - quantiles) * np.clip(diff, 0, float('inf')) + quantiles * np.clip(-diff, 0, float('inf'))
|
||||
losses = ql.reshape(-1, ql.shape[-1])
|
||||
normalizer = np.abs(tgt).mean()
|
||||
risk = 2 * losses / normalizer
|
||||
return risk.mean(0)
|
||||
|
||||
|
||||
def weighted_bce_loss_fn():
|
||||
bce = nn.BCEWithLogitsLoss(reduction="none") # Use reduction="none" to get per-element loss
|
||||
use_class_weights = False
|
||||
time_weight_factor = 1
|
||||
|
||||
def _loss_fn(y_pred, y_true):
|
||||
loss_list = [] # To accumulate per-sample loss values
|
||||
for i in range(len(y_pred)):
|
||||
current_pred = y_pred[i]
|
||||
current_true = y_true[i]
|
||||
loss_raw = bce(current_pred, current_true)
|
||||
|
||||
if use_class_weights:
|
||||
# Calculate class weights using torch operations
|
||||
try:
|
||||
factor = torch.max(current_true) / torch.mean(current_true) / 2
|
||||
non_zero_indices = current_true != 0
|
||||
class_weights = torch.ones_like(current_pred)
|
||||
class_weights[non_zero_indices] = factor
|
||||
except Exception:
|
||||
class_weights = torch.ones_like(current_pred)
|
||||
else:
|
||||
class_weights = torch.ones_like(current_pred)
|
||||
|
||||
# Calculate time weights using torch operations
|
||||
try:
|
||||
# Find indices where the event occurs
|
||||
event_indices = torch.where(current_true == 1)[0]
|
||||
if len(event_indices) > 0:
|
||||
first_event_index = event_indices[0].item()
|
||||
time_weights = torch.ones_like(current_pred)
|
||||
# Weight the loss before the first event higher if needed
|
||||
time_weights[:first_event_index] = time_weight_factor
|
||||
else:
|
||||
time_weights = torch.ones_like(current_pred)
|
||||
except Exception:
|
||||
time_weights = torch.ones_like(current_pred)
|
||||
|
||||
# Apply the weights to the raw loss
|
||||
loss = loss_raw * class_weights * time_weights
|
||||
# Optionally, take the mean over the time dimension
|
||||
loss_list.append(loss.mean())
|
||||
|
||||
# Aggregate the loss for the entire batch
|
||||
return torch.stack(loss_list).mean()
|
||||
|
||||
return _loss_fn
|
||||
|
||||
|
||||
def weighted_mse_prob_loss_fn():
|
||||
mse = nn.MSELoss(reduce=False)
|
||||
after_event_factor = 1
|
||||
use_weighted = False
|
||||
|
||||
def _loss_fn(y_pred, y_true):
|
||||
nonlocal mse, after_event_factor
|
||||
loss_list = list()
|
||||
for i in range(len(y_pred)):
|
||||
current_pred = y_pred[i]
|
||||
current_true = y_true[i]
|
||||
loss_raw = mse(current_pred, current_true)
|
||||
# calculate factor to use for weighting, only works with probability values
|
||||
if use_weighted:
|
||||
try:
|
||||
factor = torch.max(current_true) / torch.mean(current_true) / 2
|
||||
non_zero_indices = current_true != 0
|
||||
class_weights = np.ones(len(current_true))
|
||||
class_weights[non_zero_indices] = factor
|
||||
except:
|
||||
# if there are no non-zero indices, set all weights to 1
|
||||
class_weights = np.ones(len(current_true))
|
||||
else:
|
||||
# if we don't want to weight the loss, set all weights to 1
|
||||
class_weights = np.ones(len(current_true))
|
||||
|
||||
# calculate time weight, here, after the event, the loss should be higher
|
||||
try:
|
||||
last_event_index = np.where(current_true != 0)[-1]
|
||||
time_weights = np.zeros(len(current_true))
|
||||
# here, we want to weight the loss after the 'fertility curve' higher, so that we don't get the signal too late
|
||||
time_weights[last_event_index:] = after_event_factor
|
||||
except:
|
||||
# if there are no non-zero indices, set all weights to 1
|
||||
time_weights = np.ones(len(current_true))
|
||||
|
||||
loss = loss_raw * torch.tensor(class_weights) * torch.tensor(time_weights)
|
||||
loss_list.append(loss.mean())
|
||||
|
||||
return torch.stack(loss_list).mean()
|
||||
|
||||
return _loss_fn
|
||||
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import pickle
|
||||
from datetime import datetime
|
||||
|
||||
from utils.utils import get_config_id
|
||||
|
||||
|
||||
def get_model_config(base_config: dict,
|
||||
base_result_dir: str,
|
||||
dataset_base_dir: str):
|
||||
# get config identifier
|
||||
# name_for_current_config = base_config["model_name"] + "_" + get_config_id(base_config)
|
||||
date_part = datetime.now().strftime("%Y_%m_%d_%H_%M")
|
||||
name_for_current_config = base_config["model_name"] + "_" + date_part
|
||||
model_dir = os.path.abspath(f"{base_result_dir}/{name_for_current_config}")
|
||||
if not os.path.exists(model_dir):
|
||||
# create config
|
||||
os.makedirs(model_dir)
|
||||
|
||||
# append identifier to config
|
||||
config = base_config.copy()
|
||||
config["id"] = name_for_current_config
|
||||
|
||||
# append paths
|
||||
config["model_dir"] = model_dir
|
||||
config["dataset_dir"] = os.path.join(dataset_base_dir, config["feature_config"]["feature_set_name"])
|
||||
config["feature_config"]["dataset_dir"] = config["dataset_dir"]
|
||||
|
||||
# save model configuration
|
||||
with open(f"{model_dir}/model_configuration.pickle", "wb") as f:
|
||||
pickle.dump(config, f)
|
||||
else:
|
||||
# fetch config
|
||||
with open(f"{model_dir}/model_configuration.pickle", "rb") as f:
|
||||
config = pickle.load(f)
|
||||
|
||||
# append paths
|
||||
config["model_dir"] = model_dir
|
||||
config["dataset_dir"] = os.path.join(dataset_base_dir, config["feature_config"]["feature_set_name"])
|
||||
config["feature_config"]["dataset_dir"] = config["dataset_dir"]
|
||||
return config
|
||||
|
||||
|
||||
def get_model_config_from_file(model_dir: str,
|
||||
model_base_dir: str,
|
||||
lmdb_base_dir: str):
|
||||
# fetch config
|
||||
with open(f"{model_dir}/model_configuration.pickle", "rb") as f:
|
||||
config = pickle.load(f)
|
||||
|
||||
# update paths based on base directories
|
||||
config["model_dir"] = os.path.abspath(f"{model_base_dir}/{config['id']}")
|
||||
config["feature_config"]["dataset_dir"] = os.path.abspath(
|
||||
f"{lmdb_base_dir}/{config['feature_config']['feature_set_name']}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def save_model_config(model_dir: str, model_config: dict):
|
||||
# save model configuration
|
||||
with open(f"{model_dir}/model_configuration.pickle", "wb") as f:
|
||||
pickle.dump(model_config, f)
|
||||
@@ -0,0 +1,50 @@
|
||||
import numpy as np
|
||||
from scipy.signal import butter, filtfilt
|
||||
from statsmodels.tsa.stl._stl import STL
|
||||
|
||||
|
||||
def highpass_filter(data, cutoff_freq, fs=288):
|
||||
nyquist = 0.5 * fs
|
||||
normal_cutoff = cutoff_freq / nyquist
|
||||
b, a = butter(N=3, Wn=normal_cutoff, btype="high", analog=False)
|
||||
return filtfilt(b, a, data)
|
||||
|
||||
|
||||
def mirror_extend(series, extend_len):
|
||||
"""Mirrors the beginning and end of the time series to stabilize smoothing."""
|
||||
# Mirror extension
|
||||
start_extension = series[:extend_len][::-1] # Reverse first part
|
||||
end_extension = series[-extend_len:][::-1] # Reverse last part
|
||||
|
||||
extended_series = np.concatenate([start_extension, series, end_extension])
|
||||
return extended_series
|
||||
|
||||
|
||||
def get_trend(input_curve: np.ndarray | list, measurements_per_day: int = 288) -> np.ndarray:
|
||||
extension_len = 3
|
||||
extended_input_curve = mirror_extend(input_curve, extension_len * measurements_per_day)
|
||||
stl = STL(extended_input_curve, period=measurements_per_day, robust=False, trend=measurements_per_day * 14 + 1)
|
||||
trend = stl.fit().trend
|
||||
return trend[extension_len * measurements_per_day:-extension_len * measurements_per_day]
|
||||
|
||||
|
||||
def get_curve_composition(input_curve: np.ndarray | list, measurements_per_day: int = 288) -> tuple:
|
||||
"""
|
||||
Decomposes the input curve into trend, seasonal, residual and smoothed components.
|
||||
:param input_curve: raw input curve
|
||||
:param measurements_per_day: seasonal period, here: measurements per day -> 288
|
||||
:return: composition of curve as tuple (trend, seasonal, residual, smoothed)
|
||||
"""
|
||||
extension_len = 3
|
||||
extended_input_curve = mirror_extend(input_curve, extension_len * measurements_per_day)
|
||||
stl_results = STL(extended_input_curve, period=measurements_per_day, robust=False).fit()
|
||||
long_term = (extended_input_curve - stl_results.seasonal)
|
||||
wiggles = highpass_filter(long_term, 0.1)
|
||||
smoothed = long_term - wiggles
|
||||
|
||||
return (
|
||||
stl_results.trend[extension_len * measurements_per_day:-extension_len * measurements_per_day],
|
||||
stl_results.seasonal[extension_len * measurements_per_day:-extension_len * measurements_per_day],
|
||||
stl_results.resid[extension_len * measurements_per_day:-extension_len * measurements_per_day],
|
||||
smoothed[extension_len * measurements_per_day:-extension_len * measurements_per_day],
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
import math
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from torch.optim import AdamW
|
||||
from torch.optim.lr_scheduler import OneCycleLR
|
||||
from torch.utils.data import IterableDataset, DataLoader
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
from tqdm import tqdm
|
||||
|
||||
from utils.data_utils import LMDBIterableDataset
|
||||
from utils.utils import get_logger
|
||||
|
||||
|
||||
def process_tft_batch(model: nn.Module,
|
||||
data_iterator: IterableDataset,
|
||||
loss_functions: list,
|
||||
device: torch.device,
|
||||
model_configuration: dict) -> torch.Tensor:
|
||||
batch = next(data_iterator)
|
||||
batch = {k: v.to(device) for k, v in batch.items() if v is not None}
|
||||
input_window_length = model_configuration["model_parameters"]["encoder_length"]
|
||||
|
||||
preds = model(batch).cpu() # [B, decoder_len, Q]
|
||||
target = batch["target"][:, input_window_length:, :].cpu() # match decoder segment
|
||||
loss = get_x_y_loss(preds, target, loss_functions)
|
||||
return loss
|
||||
|
||||
|
||||
def get_x_y_loss(pred: torch.Tensor,
|
||||
target: torch.Tensor,
|
||||
loss_functions: list,
|
||||
*args, **kwargs) -> torch.Tensor:
|
||||
if len(loss_functions) > 1:
|
||||
losses = list()
|
||||
for dim in range(target.shape[-1]):
|
||||
if len(pred.shape) > 2:
|
||||
current_preds = pred[:, :, dim].ravel()
|
||||
else:
|
||||
current_preds = pred[:, dim]
|
||||
if len(target.shape) > 2:
|
||||
current_target = target[:, :, dim].ravel()
|
||||
else:
|
||||
current_target = target[:, dim].ravel()
|
||||
# skip dimension, if it contains only NaN values, as loss cens
|
||||
nan_indices = torch.isnan(current_target)
|
||||
if torch.all(nan_indices):
|
||||
continue
|
||||
current_target = current_target[~nan_indices]
|
||||
current_preds = current_preds[~nan_indices]
|
||||
if len(current_target) == 0:
|
||||
continue
|
||||
|
||||
loss = loss_functions[dim](current_preds, current_target)
|
||||
losses.append(loss)
|
||||
loss = torch.stack(losses).mean()
|
||||
else:
|
||||
nan_indices = torch.isnan(target)
|
||||
if torch.all(nan_indices):
|
||||
return torch.tensor(0.0)
|
||||
current_target = target[~nan_indices]
|
||||
current_preds = pred[~nan_indices]
|
||||
if len(current_target) == 0:
|
||||
return torch.tensor(0.0)
|
||||
loss = loss_functions[0](current_preds, current_target)
|
||||
|
||||
return loss
|
||||
|
||||
|
||||
def get_model_loss(model: nn.Module,
|
||||
data_iterator: IterableDataset,
|
||||
loss_functions: list,
|
||||
device: str,
|
||||
*args, **kwargs) -> torch.Tensor:
|
||||
batch_x, batch_y = next(data_iterator)
|
||||
batch_x = batch_x.to(device).float()
|
||||
target = batch_y.to(device).float()
|
||||
pred = model(batch_x)
|
||||
loss = get_x_y_loss(pred, target, loss_functions)
|
||||
return loss
|
||||
|
||||
|
||||
def get_ranked_ids(all_ids, epoch, rank, world_size, base_seed=42):
|
||||
"""
|
||||
Get ranked ids for distributed training.
|
||||
Args:
|
||||
all_ids: list of all available ids
|
||||
epoch: current epoch
|
||||
rank: rank of the current process
|
||||
world_size: number of processes
|
||||
base_seed: base seed for random number generator
|
||||
Returns:
|
||||
list of ids for the current process
|
||||
"""
|
||||
g = torch.Generator()
|
||||
g.manual_seed(base_seed + epoch)
|
||||
permuted = torch.randperm(len(all_ids), generator=g).tolist()
|
||||
return [all_ids[i] for i in permuted[rank::world_size]]
|
||||
|
||||
|
||||
def train_model(model: nn.Module,
|
||||
model_configuration: dict,
|
||||
training_configuration: dict,
|
||||
train_dataset: LMDBIterableDataset,
|
||||
val_dataset: LMDBIterableDataset,
|
||||
log_dir: str = "./logs",
|
||||
logger=None) -> torch.nn.Module:
|
||||
if logger is None:
|
||||
logger = get_logger(__name__, f"{log_dir}/{model_configuration['id']}_{training_configuration['id']}.log")
|
||||
|
||||
learning_parameters = training_configuration["learning_parameters"]
|
||||
num_epochs = learning_parameters["epochs"]
|
||||
patience = learning_parameters["patience"]
|
||||
|
||||
training_id = training_configuration["id"]
|
||||
|
||||
# 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}")
|
||||
world_size = torch.distributed.get_world_size()
|
||||
else:
|
||||
local_rank = 0
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
world_size = 1
|
||||
|
||||
logger.info(f"Rank {local_rank}: Using device: {device}, world size: {world_size}")
|
||||
|
||||
# get subsets for distributed training
|
||||
if torch.distributed.is_initialized():
|
||||
all_train_ids = train_dataset.lmdb_keys
|
||||
train_subsets = [get_ranked_ids(all_train_ids, i, local_rank, world_size) for i in range(num_epochs)]
|
||||
# calc total number of steps for gpu, as it is dependent on subsets
|
||||
total_train_steps = sum([train_dataset.get_length_of_data_subset(subset) for subset in train_subsets])
|
||||
|
||||
all_val_ids = val_dataset.lmdb_keys
|
||||
val_subsets = [get_ranked_ids(all_val_ids, i, local_rank, world_size) for i in range(num_epochs)]
|
||||
else:
|
||||
train_subsets = [train_dataset.lmdb_keys] * num_epochs
|
||||
val_subsets = [val_dataset.lmdb_keys] * num_epochs
|
||||
total_train_steps = len(train_dataset)
|
||||
|
||||
# log the number of training steps for each epoch
|
||||
train_subset_lengths = {f"epoch_{i}": len(subset) for i, subset in enumerate(train_subsets)}
|
||||
logger.info(f"Train_subsets: {train_subset_lengths}")
|
||||
logger.info(f"Rank {local_rank}: Total training steps: {total_train_steps}")
|
||||
|
||||
# initialize loaders for non distributed
|
||||
if not torch.distributed.is_initialized():
|
||||
# set the subsets for the datasets
|
||||
train_dataset.set_key_subset(train_subsets[0])
|
||||
val_dataset.set_key_subset(val_subsets[0])
|
||||
|
||||
# create data loaders
|
||||
train_dataloader = DataLoader(
|
||||
train_dataset,
|
||||
batch_size=None,
|
||||
num_workers=4,
|
||||
)
|
||||
val_dataloader = DataLoader(
|
||||
val_dataset,
|
||||
batch_size=None,
|
||||
num_workers=4,
|
||||
)
|
||||
|
||||
logger.info(f"Rank {local_rank}: Training {training_id} with {num_epochs} epochs")
|
||||
logger.info(f"Rank {local_rank}: Training on {torch.cuda.device_count()} GPUs")
|
||||
logger.info(
|
||||
f"Rank {local_rank}: Current device: {torch.cuda.get_device_name(local_rank)} on local rank {local_rank}")
|
||||
|
||||
model.to(device)
|
||||
|
||||
# load training state from training configuration, if available
|
||||
optimizer = AdamW(model.parameters(), lr=learning_parameters["learning_rate"])
|
||||
scheduler = OneCycleLR(optimizer,
|
||||
max_lr=learning_parameters["learning_rate"],
|
||||
# make sure to use length of full dataset here
|
||||
total_steps=total_train_steps)
|
||||
current_epoch = 1
|
||||
|
||||
loss_functions = training_configuration["loss_functions"]
|
||||
# loss_fn = nn.MSELoss()
|
||||
writer = SummaryWriter(log_dir=f'{log_dir}/{model_configuration["id"]}_{training_id}', )
|
||||
|
||||
best_val_loss = math.inf
|
||||
epochs_no_improve = 0
|
||||
log_every_n_steps = max(len(train_dataset) // 500, 1)
|
||||
|
||||
batch_loss_fn = model_configuration["batch_loss_fn"]
|
||||
|
||||
for epoch in range(current_epoch, num_epochs + 1):
|
||||
logger.info(f"Rank {local_rank}: Epoch {epoch}/{num_epochs}")
|
||||
model.train()
|
||||
total_train_loss = 0
|
||||
|
||||
# on distributed training, reshuffle the data
|
||||
if torch.distributed.is_initialized():
|
||||
# update the datasets with the new ids
|
||||
train_dataset.set_key_subset(train_subsets[epoch - 1])
|
||||
val_dataset.set_key_subset(val_subsets[epoch - 1])
|
||||
|
||||
# recreate data loaders
|
||||
train_dataloader = DataLoader(
|
||||
train_dataset,
|
||||
batch_size=None,
|
||||
num_workers=4,
|
||||
)
|
||||
val_dataloader = DataLoader(
|
||||
val_dataset,
|
||||
batch_size=None,
|
||||
num_workers=4,
|
||||
)
|
||||
|
||||
iterator = iter(train_dataloader)
|
||||
for step in tqdm(range(len(train_dataloader)), total=len(train_dataloader)):
|
||||
loss = batch_loss_fn(model,
|
||||
iterator,
|
||||
loss_functions,
|
||||
device,
|
||||
model_configuration)
|
||||
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
scheduler.step()
|
||||
|
||||
total_train_loss += loss.item()
|
||||
if local_rank == 0:
|
||||
if step % log_every_n_steps == 0:
|
||||
writer.add_scalar("Loss/Train_Step", loss.item(),
|
||||
((epoch - 1) * len(train_dataloader) + step) * training_configuration[
|
||||
"batch_size"])
|
||||
writer.add_scalar("LR", scheduler.get_last_lr()[0],
|
||||
((epoch - 1) * len(train_dataloader) + step) * training_configuration[
|
||||
"batch_size"])
|
||||
writer.flush()
|
||||
|
||||
avg_train_loss = total_train_loss / len(train_dataloader)
|
||||
logger.info(f"Rank {local_rank}: Epoch {epoch}/{num_epochs} done. Train loss: {avg_train_loss:.4f}")
|
||||
|
||||
# Validation
|
||||
model.eval()
|
||||
total_val_loss = 0
|
||||
logger.info(f"Rank {local_rank}: Validation")
|
||||
with torch.no_grad():
|
||||
val_iter = iter(val_dataloader)
|
||||
for step in tqdm(range(len(val_dataloader))):
|
||||
loss = batch_loss_fn(model,
|
||||
val_iter,
|
||||
loss_functions,
|
||||
device,
|
||||
model_configuration)
|
||||
|
||||
total_val_loss += loss.item()
|
||||
|
||||
if len(val_dataloader) == 0:
|
||||
logger.info(f"Rank {local_rank}: Validation set is empty, using 0 as validation loss.")
|
||||
avg_val_loss = None
|
||||
else:
|
||||
avg_val_loss = total_val_loss / len(val_dataloader)
|
||||
|
||||
# publish validation loss and wait for other gpus
|
||||
if torch.distributed.is_initialized():
|
||||
if avg_val_loss is not None:
|
||||
avg_val_loss_global = torch.tensor(avg_val_loss, device=device, dtype=torch.float32)
|
||||
torch.distributed.all_reduce(avg_val_loss_global)
|
||||
avg_val_loss_global /= torch.distributed.get_world_size()
|
||||
|
||||
avg_train_loss_global = torch.tensor(avg_train_loss).to(device)
|
||||
torch.distributed.all_reduce(avg_train_loss_global)
|
||||
avg_train_loss_global /= torch.distributed.get_world_size()
|
||||
else:
|
||||
avg_val_loss_global = torch.tensor(avg_val_loss)
|
||||
avg_train_loss_global = torch.tensor(avg_train_loss)
|
||||
|
||||
# only rank 0 checks for early stopping
|
||||
if local_rank == 0:
|
||||
logger.info(f"Rank {local_rank}: Epoch {epoch}/{num_epochs} done. Val loss: {avg_val_loss:.4f}")
|
||||
writer.add_scalar("Loss/Train_Epoch", avg_train_loss_global, epoch)
|
||||
writer.add_scalar("Loss/Val_Epoch", avg_val_loss_global, epoch)
|
||||
writer.flush()
|
||||
|
||||
# Early stopping
|
||||
should_stop = False
|
||||
if avg_val_loss_global < best_val_loss:
|
||||
logger.info(
|
||||
f"Rank {local_rank}: Validation loss improved from {best_val_loss:.4f} to {avg_val_loss_global:.4f}.")
|
||||
best_val_loss = avg_val_loss_global
|
||||
epochs_no_improve = 0
|
||||
# torch.save(model.state_dict(), os.path.join(model_configuration["id"], "model.pt"))
|
||||
save_fn = model_configuration["model_save_fn"]
|
||||
save_fn(model, training_configuration)
|
||||
else:
|
||||
epochs_no_improve += 1
|
||||
logger.info(
|
||||
f"Rank {local_rank}: No improvement in validation loss, no-improve count: {epochs_no_improve}")
|
||||
if epochs_no_improve >= patience:
|
||||
logger.info("Early stopping triggered.")
|
||||
# broadcast stop signal to all gpus
|
||||
should_stop = True
|
||||
else:
|
||||
should_stop = None
|
||||
|
||||
if torch.distributed.is_initialized():
|
||||
if local_rank == 0:
|
||||
should_stop_tensor = torch.tensor([int(should_stop)], device=device)
|
||||
else:
|
||||
should_stop_tensor = torch.zeros(1, dtype=torch.uint8, device=device) # safe default
|
||||
torch.distributed.broadcast(should_stop_tensor, src=0)
|
||||
should_stop = bool(should_stop_tensor.item())
|
||||
|
||||
if should_stop:
|
||||
logger.info(f"Rank {local_rank}: Stopping training.")
|
||||
break
|
||||
|
||||
# ensure sync between epochs
|
||||
if torch.distributed.is_initialized():
|
||||
torch.distributed.barrier()
|
||||
|
||||
# clean up
|
||||
del loss
|
||||
del train_dataloader
|
||||
del val_dataloader
|
||||
del model
|
||||
del optimizer
|
||||
del scheduler
|
||||
|
||||
# free up memory
|
||||
torch.cuda.synchronize()
|
||||
@@ -0,0 +1,261 @@
|
||||
import os
|
||||
import pickle
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import nn
|
||||
from torch.nn import init
|
||||
from sklearn.model_selection import train_test_split
|
||||
import lmdb
|
||||
from bson import ObjectId
|
||||
from tqdm import tqdm
|
||||
|
||||
from utils.lmdb_utils import get_lmdb_keys
|
||||
from utils.utils import get_config_id
|
||||
from utils.data_utils import produce_window_batches
|
||||
|
||||
from vsm_datascience_common.cycle_database_connection.db_utils import get_cycles_collection
|
||||
|
||||
|
||||
def get_training_config(base_config: dict, model_config: dict):
|
||||
try:
|
||||
if base_config["model_class"] != model_config["model_class"]:
|
||||
raise Exception("Model type differ in model config and training config")
|
||||
except KeyError:
|
||||
raise Exception("Model class missing in training or model config")
|
||||
|
||||
# training_config_id = get_config_id(base_config)
|
||||
# hash = training_config_id[-5:]
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M")
|
||||
training_config_id = f"{timestamp}"
|
||||
|
||||
training_dir = os.path.abspath(f"{model_config['model_dir']}/trainings/{training_config_id}")
|
||||
|
||||
# append identifier to config
|
||||
training_config = base_config.copy()
|
||||
training_config["id"] = training_config_id
|
||||
training_config["training_dir"] = training_dir
|
||||
|
||||
if not os.path.isdir(training_dir):
|
||||
os.makedirs(training_dir)
|
||||
# save training configuration
|
||||
with open(f"{training_dir}/training_configuration.pickle", "wb") as f:
|
||||
pickle.dump(training_config, f)
|
||||
else:
|
||||
# fetch config
|
||||
with open(f"{training_dir}/training_configuration.pickle", "rb") as f:
|
||||
training_config = pickle.load(f)
|
||||
|
||||
return training_config
|
||||
|
||||
|
||||
def get_training_config_from_file(training_dir: str,
|
||||
base_model_dir: str,
|
||||
model_configuration: dict) -> dict:
|
||||
# fetch config
|
||||
with open(f"{training_dir}/training_configuration.pickle", "rb") as f:
|
||||
training_config = pickle.load(f)
|
||||
|
||||
# update paths based on base directories
|
||||
training_config["training_dir"] = os.path.join(os.path.abspath(base_model_dir),
|
||||
model_configuration["id"],
|
||||
"trainings",
|
||||
training_config["id"])
|
||||
|
||||
return training_config
|
||||
|
||||
|
||||
def weight_init(m):
|
||||
"""
|
||||
Usage:
|
||||
model = Model()
|
||||
model.apply(weight_init)
|
||||
"""
|
||||
if isinstance(m, nn.Conv1d):
|
||||
init.normal_(m.weight.data)
|
||||
if m.bias is not None:
|
||||
init.normal_(m.bias.data)
|
||||
elif isinstance(m, nn.Conv2d):
|
||||
init.xavier_normal_(m.weight.data)
|
||||
if m.bias is not None:
|
||||
init.normal_(m.bias.data)
|
||||
elif isinstance(m, nn.Conv3d):
|
||||
init.xavier_normal_(m.weight.data)
|
||||
if m.bias is not None:
|
||||
init.normal_(m.bias.data)
|
||||
elif isinstance(m, nn.ConvTranspose1d):
|
||||
init.normal_(m.weight.data)
|
||||
if m.bias is not None:
|
||||
init.normal_(m.bias.data)
|
||||
elif isinstance(m, nn.ConvTranspose2d):
|
||||
init.xavier_normal_(m.weight.data)
|
||||
if m.bias is not None:
|
||||
init.normal_(m.bias.data)
|
||||
elif isinstance(m, nn.ConvTranspose3d):
|
||||
init.xavier_normal_(m.weight.data)
|
||||
if m.bias is not None:
|
||||
init.normal_(m.bias.data)
|
||||
elif isinstance(m, nn.BatchNorm1d):
|
||||
init.normal_(m.weight.data, mean=1, std=0.02)
|
||||
init.constant_(m.bias.data, 0)
|
||||
elif isinstance(m, nn.BatchNorm2d):
|
||||
init.normal_(m.weight.data, mean=1, std=0.02)
|
||||
init.constant_(m.bias.data, 0)
|
||||
elif isinstance(m, nn.BatchNorm3d):
|
||||
init.normal_(m.weight.data, mean=1, std=0.02)
|
||||
init.constant_(m.bias.data, 0)
|
||||
elif isinstance(m, nn.Linear):
|
||||
init.xavier_normal_(m.weight.data)
|
||||
if m.bias is not None:
|
||||
init.normal_(m.bias.data)
|
||||
elif isinstance(m, nn.LSTM):
|
||||
for param in m.parameters():
|
||||
if len(param.shape) >= 2:
|
||||
init.orthogonal_(param.data)
|
||||
else:
|
||||
init.normal_(param.data)
|
||||
elif isinstance(m, nn.LSTMCell):
|
||||
for param in m.parameters():
|
||||
if len(param.shape) >= 2:
|
||||
init.orthogonal_(param.data)
|
||||
else:
|
||||
init.normal_(param.data)
|
||||
elif isinstance(m, nn.GRU):
|
||||
for param in m.parameters():
|
||||
if len(param.shape) >= 2:
|
||||
init.orthogonal_(param.data)
|
||||
else:
|
||||
init.normal_(param.data)
|
||||
for names in m._all_weights:
|
||||
for name in filter(lambda n: "bias" in n, names):
|
||||
bias = getattr(m, name)
|
||||
n = bias.size(0)
|
||||
bias.data[:n // 3].fill_(-1.)
|
||||
elif isinstance(m, nn.GRUCell):
|
||||
for param in m.parameters():
|
||||
if len(param.shape) >= 2:
|
||||
init.orthogonal_(param.data)
|
||||
else:
|
||||
init.normal_(param.data)
|
||||
|
||||
|
||||
def collate(batch_items: list) -> dict:
|
||||
batch = dict()
|
||||
for key in batch_items[0].keys():
|
||||
if key in ["combination_id", "time_index"]:
|
||||
continue
|
||||
else:
|
||||
if batch_items[0][key] is None:
|
||||
batch[key] = None
|
||||
else:
|
||||
batch[key] = np.stack([item[key] for item in batch_items])
|
||||
|
||||
for key in batch.keys():
|
||||
if batch[key] is not None:
|
||||
batch[key] = torch.tensor(batch[key], dtype=torch.float32)
|
||||
|
||||
return batch
|
||||
|
||||
|
||||
def get_splits_by_user(input_keys: list, train_size: float, val_size: float, test_size: float):
|
||||
if train_size + val_size + test_size != 1:
|
||||
raise ValueError("Train, val and test sizes must sum to 1")
|
||||
|
||||
if len(input_keys) == 0:
|
||||
raise ValueError("Input keys list is empty")
|
||||
|
||||
items_by_use = dict()
|
||||
for input_key in tqdm(input_keys):
|
||||
try:
|
||||
user_id = get_cycles_collection().find_one({"_id": ObjectId(input_key)})["user_id"]
|
||||
if user_id not in items_by_use:
|
||||
items_by_use[user_id] = []
|
||||
items_by_use[user_id].append(input_key)
|
||||
except Exception:
|
||||
print(f"Error getting user id for key {input_key}")
|
||||
continue
|
||||
|
||||
user_ids = list(items_by_use.keys())
|
||||
random.shuffle(user_ids)
|
||||
train_users, temp_users = train_test_split(user_ids, train_size=train_size, test_size=test_size + val_size)
|
||||
# compute relative test size, as it must be relative to the remaining users
|
||||
relative_test_size = test_size / (1 - train_size)
|
||||
val_users, test_users = train_test_split(temp_users, test_size=relative_test_size)
|
||||
|
||||
train_keys = []
|
||||
val_keys = []
|
||||
test_keys = []
|
||||
for user_id in train_users:
|
||||
train_keys.extend(items_by_use[user_id])
|
||||
for user_id in val_users:
|
||||
val_keys.extend(items_by_use[user_id])
|
||||
for user_id in test_users:
|
||||
test_keys.extend(items_by_use[user_id])
|
||||
|
||||
return train_keys, val_keys, test_keys
|
||||
|
||||
|
||||
def save_splits(train_keys: list, val_keys: list, test_keys: list, base_dir: str):
|
||||
if not os.path.exists(base_dir):
|
||||
os.makedirs(base_dir)
|
||||
|
||||
with open(f"{base_dir}/train_keys.pickle", "wb") as f:
|
||||
pickle.dump(train_keys, f)
|
||||
with open(f"{base_dir}/val_keys.pickle", "wb") as f:
|
||||
pickle.dump(val_keys, f)
|
||||
with open(f"{base_dir}/test_keys.pickle", "wb") as f:
|
||||
pickle.dump(test_keys, f)
|
||||
|
||||
|
||||
def load_splits(base_dir: str):
|
||||
with open(f"{base_dir}/train_keys.pickle", "rb") as f:
|
||||
train_keys = pickle.load(f)
|
||||
with open(f"{base_dir}/val_keys.pickle", "rb") as f:
|
||||
val_keys = pickle.load(f)
|
||||
with open(f"{base_dir}/test_keys.pickle", "rb") as f:
|
||||
test_keys = pickle.load(f)
|
||||
|
||||
return train_keys, val_keys, test_keys
|
||||
|
||||
|
||||
def get_data_ids(model_configuration: dict,
|
||||
training_configuration: dict,
|
||||
env_path: str,
|
||||
limit: int = None) -> tuple:
|
||||
env = lmdb.open(f"{env_path}", readonly=True)
|
||||
if os.path.exists(f"{model_configuration['feature_config']['dataset_dir']}/train_keys.pickle"):
|
||||
train_ids, val_ids, test_ids = load_splits(model_configuration["feature_config"]["dataset_dir"])
|
||||
else:
|
||||
lmdb_keys = get_lmdb_keys(env, limit)
|
||||
# train_ids, val_ids, test_ids = get_splits_by_user(lmdb_keys,
|
||||
# training_configuration["train_size"],
|
||||
# training_configuration["val_size"],
|
||||
# training_configuration["test_size"])
|
||||
train_ids, temp_ids = train_test_split(lmdb_keys,
|
||||
train_size=training_configuration["train_size"],
|
||||
test_size=training_configuration["val_size"] + training_configuration[
|
||||
"test_size"])
|
||||
# compute relative test size, as it must be relative to the remaining users
|
||||
relative_test_size = training_configuration["test_size"] / (1 - training_configuration["train_size"])
|
||||
val_ids, test_ids = train_test_split(temp_ids,
|
||||
test_size=relative_test_size)
|
||||
# save splits to file
|
||||
save_splits(train_ids, val_ids, test_ids, model_configuration["feature_config"]["dataset_dir"])
|
||||
|
||||
if limit is not None:
|
||||
train_size = training_configuration["train_size"]
|
||||
val_size = training_configuration["val_size"]
|
||||
test_size = training_configuration["test_size"]
|
||||
|
||||
train_lim = int(limit * train_size)
|
||||
val_lim = int(limit * val_size)
|
||||
test_lim = int(limit * test_size)
|
||||
|
||||
train_ids = train_ids[:train_lim]
|
||||
val_ids = val_ids[:val_lim]
|
||||
test_ids = test_ids[:test_lim]
|
||||
|
||||
return train_ids, val_ids, test_ids
|
||||
@@ -0,0 +1,152 @@
|
||||
import sys
|
||||
import inspect
|
||||
import hashlib
|
||||
import logging
|
||||
from functools import partial
|
||||
import random
|
||||
from typing import Callable
|
||||
import importlib
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def get_logger(module_name: str, filename: str = "main.log") -> logging.Logger:
|
||||
"""
|
||||
Returns a logger for the given module name and filename.
|
||||
:param module_name: name of the module, as string
|
||||
:param filename: name of the logging file, as string
|
||||
:return: the logger, as logging.Logger object
|
||||
"""
|
||||
logger = logging.getLogger(module_name)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
file_handler = logging.FileHandler(filename)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# add system out handler
|
||||
stream_handler = logging.StreamHandler(sys.stdout)
|
||||
stream_handler.setLevel(logging.INFO)
|
||||
stream_handler.setFormatter(formatter)
|
||||
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def get_variable_from_module(module_path: str, variable_name: str):
|
||||
"""
|
||||
Get a variable from a module by its name during runtime
|
||||
|
||||
Args:
|
||||
module_path: module to fetch variable from
|
||||
variable_name: variable to fetch from module
|
||||
|
||||
Returns:
|
||||
variable from module
|
||||
|
||||
"""
|
||||
module = importlib.import_module(module_path)
|
||||
variable = getattr(module, variable_name)
|
||||
if variable is None:
|
||||
raise ValueError(f"Variable {variable_name} not found in module {module_path}")
|
||||
return variable
|
||||
|
||||
|
||||
def get_callable_name(callable_obj):
|
||||
"""
|
||||
Get the name of the callable, handling `functools.partial`.
|
||||
|
||||
:param callable_obj: callable object
|
||||
:return: name of the callable
|
||||
"""
|
||||
if isinstance(callable_obj, partial):
|
||||
func_name = callable_obj.func.__name__
|
||||
args = ", ".join(object_to_string(arg) for arg in callable_obj.args)
|
||||
kwargs = ", ".join(f"{object_to_string(k)}={object_to_string(v)!r}" for k, v in callable_obj.keywords.items())
|
||||
return f"partial({func_name}, {args}, {kwargs})"
|
||||
else:
|
||||
if hasattr(callable_obj, '__name__'):
|
||||
return callable_obj.__name__
|
||||
elif hasattr(callable_obj, '__class__'):
|
||||
return callable_obj.__class__.__name__
|
||||
elif hasattr(callable_obj, '__hash__'):
|
||||
return callable_obj.__hash__
|
||||
else:
|
||||
raise ValueError(f"Could not determine name of callable object {callable_obj}")
|
||||
|
||||
|
||||
def object_to_string(value, skip_types=None):
|
||||
"""
|
||||
Convert any object to a string representation that avoids memory addresses.
|
||||
Handles complex data types recursively.
|
||||
|
||||
:param value: object to convert
|
||||
:param skip_types: types to skip during conversion
|
||||
:return: string representation of the object
|
||||
"""
|
||||
if skip_types is None:
|
||||
skip_types = []
|
||||
|
||||
if any(isinstance(value, t) for t in skip_types):
|
||||
return 'skipped_type'
|
||||
elif isinstance(value, (str, int, float, bool)): # Handle primitive data types directly
|
||||
return repr(value)
|
||||
elif isinstance(value, dict):
|
||||
return '{' + ', '.join(f"{k}: {object_to_string(v, skip_types)}" for k, v in value.items()) + '}'
|
||||
elif isinstance(value, (list, tuple)):
|
||||
return '[' + ', '.join(object_to_string(item, skip_types) for item in value) + ']'
|
||||
elif isinstance(value, partial):
|
||||
return get_callable_name(value)
|
||||
elif inspect.isclass(value):
|
||||
return f"<class '{value.__name__}'>"
|
||||
elif hasattr(value,
|
||||
'__class__') and not value.__class__ != "function": # Correct handling for instances of classes, but not functions
|
||||
return f"<instance of class '{value.__class__.__name__}'>"
|
||||
elif isinstance(value, Callable):
|
||||
return f"<callable '{get_callable_name(value)}'>"
|
||||
else:
|
||||
return repr(value)
|
||||
|
||||
|
||||
def get_config_id(configuration: dict) -> str:
|
||||
"""
|
||||
Generate a somewhat unique human-readable model name from the model and training parameters.
|
||||
:param configuration: dictionary containing model and training parameters
|
||||
:return: human-readable model name
|
||||
"""
|
||||
adjectives = ["autumn", "hidden", "bitter", "misty", "silent", "empty", "dry", "dark", "summer", "icy", "delicate",
|
||||
"quiet", "white", "black", "blue", "green", "red", "yellow",
|
||||
"purple", "orange", "pink", "golden", "silver", "crimson", "violet", "azure", "amber", "sapphire",
|
||||
"emerald", "ruby", "pearl", "topaz", "onyx", "turquoise", "citrine", ]
|
||||
nouns = ["waterfall", "river", "breeze", "moon", "rain", "wind", "sea", "morning", "snow", "lake", "sunset", "pine",
|
||||
"shadow", "leaf", "dawn", "glitter", "forest", "cloud", "sky", "sun", "butterfly",
|
||||
"flower", "bird", "mountain", "valley", "ocean", "star", "night", "dream", "whisper", "echo", "horizon",
|
||||
"wave", "petal", "dew", "mist"]
|
||||
|
||||
# also add dataset config, but skip functions
|
||||
base_name = object_to_string(configuration)
|
||||
|
||||
# hash long name
|
||||
basename_hash = hashlib.md5(base_name.encode()).hexdigest()
|
||||
|
||||
# select adjective and noun based on hash
|
||||
random.seed(int(basename_hash, 16))
|
||||
model_name = f"{random.choice(adjectives)}_{random.choice(nouns)}_{basename_hash[:5]}"
|
||||
|
||||
return model_name
|
||||
|
||||
|
||||
def convert_for_json(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: convert_for_json(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [convert_for_json(v) for v in obj]
|
||||
elif isinstance(obj, np.generic):
|
||||
return obj.item()
|
||||
else:
|
||||
return obj
|
||||
@@ -0,0 +1,61 @@
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from utils.dataset_creation import inverse_scale_feature
|
||||
|
||||
|
||||
def plot_prediction_windows(index: int,
|
||||
training_configuration: dict,
|
||||
item_x: np.ndarray,
|
||||
item_y: np.ndarray,
|
||||
preds: np.ndarray,
|
||||
scalers: dict,
|
||||
features_to_plot: list,
|
||||
output_feature_names: list,
|
||||
fig_widget):
|
||||
window_features = item_x[index]
|
||||
indices = np.arange(window_features.shape[0])
|
||||
actual = item_y[index].ravel()
|
||||
predicted = preds[index]
|
||||
|
||||
# clear previous traces
|
||||
fig_widget.data = []
|
||||
|
||||
for feature in features_to_plot:
|
||||
feature_index = feature["index"]
|
||||
feature_name = feature["name"]
|
||||
fig_widget.add_scatter(
|
||||
x=indices,
|
||||
y=window_features[:, feature_index],
|
||||
mode="lines",
|
||||
name=feature_name,
|
||||
)
|
||||
|
||||
num_outputs = predicted.shape[-1]
|
||||
scaled_preds = list()
|
||||
for i in range(num_outputs):
|
||||
if isinstance(training_configuration["loss_functions"][i], nn.BCEWithLogitsLoss):
|
||||
output = torch.sigmoid(torch.tensor(predicted[i]))
|
||||
else:
|
||||
output = predicted[i]
|
||||
|
||||
output = float(output)
|
||||
|
||||
scaled_output = inverse_scale_feature(output,
|
||||
output_feature_names[i],
|
||||
scalers)
|
||||
|
||||
scaled_preds.append(scaled_output)
|
||||
|
||||
scaled_actuals = list()
|
||||
for i in range(num_outputs):
|
||||
output = float(actual[i].numpy())
|
||||
scaled_output = inverse_scale_feature(output,
|
||||
output_feature_names[i],
|
||||
scalers)
|
||||
|
||||
scaled_actuals.append(scaled_output)
|
||||
|
||||
# add actual and predicted values
|
||||
print(f"actual: {scaled_actuals}, predicted: {scaled_preds}")
|
||||
Reference in New Issue
Block a user