###########################################################################
# Copyright (C) 2025 ETH Zurich
# CosinorAge: Prediction of biological age based on accelerometer data
# using the CosinorAge method proposed by Shim, Fleisch and Barata
# (https://www.nature.com/articles/s41746-024-01111-x)
#
# Authors: Jacob Leo Oskar Hunecke
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
##########################################################################
from typing import List
import matplotlib.pyplot as plt
import numpy as np
from ..features.utils.cosinor_analysis import cosinor_multiday
# model parameters
model_params_generic = {
"shape": 0.01462774,
"rate": -13.36715309,
"mesor": -0.03204933,
"amp1": -0.01971357,
"phi1": -0.01664718,
"age": 0.10033692,
}
model_params_female = {
"shape": 0.01294402,
"rate": -13.28530410,
"mesor": -0.02569062,
"amp1": -0.02170987,
"phi1": -0.13191562,
"age": 0.08840283,
}
model_params_male = {
"shape": 0.013878454,
"rate": -13.016951633,
"mesor": -0.023988922,
"amp1": -0.030620390,
"phi1": 0.008960155,
"age": 0.101726103,
}
m_n = -1.405276
m_d = 0.01462774
BA_n = -0.01447851
BA_d = 0.112165
BA_i = 133.5989
[docs]
class CosinorAge:
"""A class to compute biological age predictions using the CosinorAge method.
This class implements the CosinorAge method proposed by Shim, Fleisch and Barata
for predicting biological age based on accelerometer data patterns. The method
uses cosinor analysis to extract circadian rhythm parameters (MESOR, amplitude,
acrophase) from accelerometer data and applies gender-specific regression models
to predict biological age.
The CosinorAge method is based on the principle that circadian rhythm patterns
in physical activity are associated with biological aging. By analyzing the
daily activity patterns using cosinor analysis, the method can predict whether
an individual's biological age is advanced or delayed compared to their
chronological age.
Attributes
----------
records : List[dict]
List of dictionaries containing accelerometer data records with computed predictions.
Each record contains the original data plus computed cosinor parameters and
biological age predictions.
model_params_generic : dict
Model parameters for generic gender classification (used when gender is 'unknown').
model_params_female : dict
Model parameters for female gender classification.
model_params_male : dict
Model parameters for male gender classification.
Examples
--------
Basic usage with a single participant:
>>> from cosinorage.bioages import CosinorAge
>>> from cosinorage.datahandlers import GenericDataHandler
>>>
>>> # Create a data handler with accelerometer data
>>> handler = GenericDataHandler(
... file_path='data/participant_001.csv',
... data_type='accelerometer-mg',
... time_column='timestamp',
... data_columns=['x', 'y', 'z']
... )
>>>
>>> # Create a record with age and gender information
>>> record = {
... 'handler': handler,
... 'age': 45.5,
... 'gender': 'female'
... }
>>>
>>> # Compute CosinorAge predictions
>>> cosinor_age = CosinorAge([record])
>>> predictions = cosinor_age.get_predictions()
>>>
>>> # Access the results
>>> result = predictions[0]
>>> print(f"Chronological age: {result['age']:.1f}")
>>> print(f"Predicted biological age: {result['cosinorage']:.1f}")
>>> print(f"Age advance: {result['cosinorage_advance']:.1f}")
>>> print(f"MESOR: {result['mesor']:.4f}")
>>> print(f"Amplitude: {result['amp1']:.4f}")
>>> print(f"Acrophase: {result['phi1']:.4f}")
Multiple participants with different genders:
>>> from cosinorage.datahandlers import GalaxyDataHandler
>>>
>>> # Create multiple data handlers
>>> handlers = []
>>> for i in range(3):
... handler = GalaxyDataHandler(f'data/participant_{i+1:03d}.csv')
... handlers.append(handler)
>>>
>>> # Create records with different ages and genders
>>> records = [
... {'handler': handlers[0], 'age': 30.2, 'gender': 'male'},
... {'handler': handlers[1], 'age': 45.8, 'gender': 'female'},
... {'handler': handlers[2], 'age': 62.1, 'gender': 'unknown'}
... ]
>>>
>>> # Compute predictions for all participants
>>> cosinor_age = CosinorAge(records)
>>> predictions = cosinor_age.get_predictions()
>>>
>>> # Analyze results
>>> for i, pred in enumerate(predictions):
... print(f"Participant {i+1}:")
... print(f" Age: {pred['age']:.1f}, Gender: {pred['gender']}")
... print(f" Biological age: {pred['cosinorage']:.1f}")
... print(f" Age advance: {pred['cosinorage_advance']:.1f}")
... if pred['cosinorage_advance'] > 0:
... print(" Status: Biologically older than chronological age")
... else:
... print(" Status: Biologically younger than chronological age")
Notes
-----
- The method requires at least 24 hours of continuous accelerometer data
- Data should be preprocessed to minute-level ENMO values
- Gender-specific models provide more accurate predictions than the generic model
- Invalid cosinor parameters (NaN, inf) result in None values for predictions
- The method automatically handles missing or invalid data gracefully
- Age advance > 0 indicates biological age is older than chronological age
- Age advance < 0 indicates biological age is younger than chronological age
References
----------
Shim, S., Fleisch, E., & Barata, F. (2024). CosinorAge: A novel method for
predicting biological age from accelerometer data using circadian rhythm
analysis. npj Digital Medicine, 7(1), 1-12.
"""
[docs]
def __init__(self, records: List[dict]):
"""
Initialize CosinorAge with accelerometer data records.
This method initializes the CosinorAge calculator and immediately computes
biological age predictions for all provided records. The computation is
performed automatically during initialization.
Parameters
----------
records : List[dict]
A list of dictionaries containing accelerometer data records.
Each record must contain:
- 'handler': A DataHandler object (e.g., GenericDataHandler, GalaxyDataHandler)
that provides minute-level ENMO data via get_ml_data() method
- 'age': Chronological age as a float (e.g., 45.5)
Each record may optionally contain:
- 'gender': Gender classification as string ('male', 'female', or 'unknown')
If not provided, defaults to 'unknown' and uses the generic model
Notes
-----
- The computation is performed immediately during initialization
- Each record is processed independently
- Failed computations (invalid data) result in None values for predictions
- Gender-specific models are used when gender is 'male' or 'female'
- Generic model is used when gender is 'unknown' or not provided
"""
self.records = records
self.model_params_generic = model_params_generic
self.model_params_female = model_params_female
self.model_params_male = model_params_male
self.__compute_cosinor_ages()
def __compute_cosinor_ages(self):
"""Compute CosinorAge predictions for all records.
Processes each record to extract cosinor parameters and calculate biological age.
Updates each record dictionary with the following keys:
- mesor: The rhythm-adjusted mean
- amp1: The amplitude of the circadian rhythm
- phi1: The acrophase (timing) of the circadian rhythm
- cosinorage: Predicted biological age
- cosinorage_advance: Difference between predicted and chronological age
"""
import numpy as np
import pandas as pd
for record in self.records:
try:
result = cosinor_multiday(record["handler"].get_ml_data())[0]
# Check if cosinor parameters are valid
mesor = result["mesor"]
amplitude = result["amplitude"]
acrophase = result["acrophase"]
# Validate cosinor parameters
if (pd.isna(mesor) or np.isnan(mesor) or np.isinf(mesor) or
pd.isna(amplitude) or np.isnan(amplitude) or np.isinf(amplitude) or
pd.isna(acrophase) or np.isnan(acrophase) or np.isinf(acrophase)):
# Set invalid values for this record
record["mesor"] = None
record["amp1"] = None
record["phi1"] = None
record["cosinorage"] = None
record["cosinorage_advance"] = None
continue
record["mesor"] = mesor
record["amp1"] = amplitude
record["phi1"] = acrophase
bm_data = {
"mesor": mesor,
"amp1": amplitude,
"phi1": acrophase,
"age": record["age"],
}
gender = record.get("gender", "unknown")
if gender == "female":
coef = self.model_params_female
elif gender == "male":
coef = self.model_params_male
else:
coef = self.model_params_generic
n1 = {key: bm_data[key] * coef[key] for key in bm_data}
xb = sum(n1.values()) + coef["rate"]
m_val = 1 - np.exp((m_n * np.exp(xb)) / m_d)
cosinorage = float(
((np.log(BA_n * np.log(1 - m_val))) / BA_d) + BA_i
)
record["cosinorage"] = float(cosinorage)
record["cosinorage_advance"] = float(
record["cosinorage"] - record["age"]
)
except Exception as e:
# Set invalid values for this record if any error occurs
record["mesor"] = None
record["amp1"] = None
record["phi1"] = None
record["cosinorage"] = None
record["cosinorage_advance"] = None
[docs]
def get_predictions(self):
"""Return the processed records with CosinorAge predictions.
This method returns the complete records list with all computed predictions
and cosinor parameters. Each record contains the original input data plus
the computed biological age predictions and circadian rhythm parameters.
Returns
-------
List[dict]
The records list containing the original data and predictions.
Each record dictionary includes:
- Original keys: 'handler', 'age', 'gender'
- Computed cosinor parameters: 'mesor', 'amp1', 'phi1'
- Biological age predictions: 'cosinorage', 'cosinorage_advance'
Notes
-----
- Returns the same records that were passed to the constructor
- Each record is updated in-place with computed predictions
- Failed computations result in None values for prediction fields
- The method can be called multiple times without recomputation
"""
return self.records
[docs]
def plot_predictions(self):
"""Generate visualization plots comparing chronological age vs CosinorAge.
This method creates individual plots for each record showing the comparison
between chronological age and predicted biological age. The plots use a
timeline visualization with color coding to indicate whether the biological
age is advanced (red) or delayed (green) compared to chronological age.
The plots include:
- Chronological age and CosinorAge as points on a timeline
- Color-coded line segments (red for advanced, green for younger)
- Numerical labels showing exact age values
- Clear visual distinction between the two age measures
Notes
-----
- Creates one plot per record in the dataset
- Red color indicates biological age > chronological age (advanced aging)
- Green color indicates biological age < chronological age (delayed aging)
- Plots are displayed immediately when called
- Records with invalid predictions (None values) are skipped
- Each plot shows exact numerical values for both ages
- The visualization helps quickly identify aging patterns across participants
"""
for record in self.records:
# Skip records with invalid cosinorage values
if record["cosinorage"] is None:
print(f"Skipping plot for record with invalid cosinorage value")
continue
plt.figure(figsize=(22.5, 2.5))
plt.hlines(
y=0,
xmin=0,
xmax=min(record["age"], record["cosinorage"]),
color="grey",
alpha=0.8,
linewidth=2,
zorder=1,
)
if record["cosinorage"] > record["age"]:
color = "red"
else:
color = "green"
plt.hlines(
y=0,
xmin=min(record["age"], record["cosinorage"]),
xmax=max(record["age"], record["cosinorage"]),
color=color,
alpha=0.8,
linewidth=2,
zorder=1,
)
plt.scatter(
record["cosinorage"],
0,
color=color,
s=100,
marker="o",
label="CosinorAge",
)
plt.scatter(
record["age"], 0, color=color, s=100, marker="o", label="Age"
)
plt.text(
record["cosinorage"],
0.4,
"CosinorAge",
fontsize=12,
color=color,
alpha=0.8,
ha="center",
va="bottom",
rotation=45,
)
plt.text(
record["age"],
0.4,
"Age",
fontsize=12,
color=color,
alpha=0.8,
ha="center",
va="bottom",
rotation=45,
)
plt.text(
record["age"],
-0.5,
f"{record['age']:.1f}",
fontsize=12,
color=color,
alpha=0.8,
ha="center",
va="top",
rotation=45,
)
plt.text(
record["cosinorage"],
-0.5,
f"{record['cosinorage']:.1f}",
fontsize=12,
color=color,
alpha=0.8,
ha="center",
va="top",
rotation=45,
)
plt.xlim(0, max(record["age"], record["cosinorage"]) * 1.25)
plt.yticks([])
plt.ylim(-1.5, 2)