Files
temperature-based-fertility…/code/new_realtime/utils/feature_functions.py
T
Alex Blank 50cf43b9fe added code
2025-05-19 11:11:04 +02:00

373 lines
16 KiB
Python

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