| | import streamlit as st |
| | import normalizer_model |
| | import numpy as np |
| | import pandas as pd |
| | import altair as alt |
| | import plotly.graph_objects as go |
| | from scipy.stats import norm |
| |
|
| | |
| | st.set_page_config( |
| | page_title="Smartwatch Normative Z-Score Calculator", |
| | layout="wide", |
| | ) |
| |
|
| |
|
| | |
| | def load_norm_df(path: str): |
| | return normalizer_model.load_normative_table(path) |
| |
|
| |
|
| | load_norm_df = st.cache_data(load_norm_df) |
| |
|
| | |
| | norm_df = load_norm_df("Table_1_summary_measure.csv") |
| |
|
| | |
| | BIOMARKER_LABELS = { |
| | "nb_steps": "Number of Steps", |
| | "max_steps": "Maximum Steps", |
| | "mean_active_time": "Mean Active Time", |
| | "sbp": "Systolic Blood Pressure", |
| | "dbp": "Diastolic Blood Pressure", |
| | "sleep_duration": "Sleep Duration", |
| | "avg_night_hr": "Average Night Heart Rate", |
| | "nb_moderate_active_minutes": "Moderate Active Minutes", |
| | "nb_vigorous_active_minutes": "Vigorous Active Minutes", |
| | "weight": "Weight", |
| | "pwv": "Pulse Wave Velocity", |
| | |
| | } |
| |
|
| |
|
| | def main(): |
| | if "disclaimer_shown" not in st.session_state: |
| | st.info( |
| | "These calculations are dedicated for scientifically purposes only. " |
| | "For detailed questions regarding personal health data contact your " |
| | "healthcare professionals." |
| | ) |
| | st.session_state.disclaimer_shown = True |
| | st.title("Smartwatch Normative Z-Score Calculator") |
| | st.sidebar.header("Input Parameters") |
| |
|
| | |
| | regions = sorted(norm_df["area"].unique()) |
| | if "Western Europe" in regions: |
| | default_region = "Western Europe" |
| | else: |
| | default_region = regions[0] |
| | region = st.sidebar.selectbox( |
| | "Region", |
| | regions, |
| | index=regions.index(default_region), |
| | ) |
| |
|
| | |
| | gender = st.sidebar.selectbox( |
| | "Gender", |
| | sorted(norm_df["gender"].unique()), |
| | ) |
| |
|
| | |
| | st.sidebar.subheader("Age Input") |
| | age_input_mode = st.sidebar.radio( |
| | "Age input mode", |
| | ("Years", "Group"), |
| | ) |
| | if age_input_mode == "Years": |
| | age_years = st.sidebar.number_input( |
| | "Age (years)", |
| | min_value=0, |
| | max_value=120, |
| | value=30, |
| | step=1, |
| | ) |
| | age_param = age_years |
| | else: |
| | age_groups = sorted( |
| | norm_df["Age"].unique(), |
| | key=lambda x: int(x.split("-")[0]), |
| | ) |
| | age_group = st.sidebar.selectbox("Age group", [""] + age_groups) |
| | age_param = age_group |
| |
|
| | |
| | st.sidebar.subheader("BMI Input") |
| | bmi_input_mode = st.sidebar.radio( |
| | "BMI input mode", |
| | ("Value", "Category"), |
| | ) |
| | if bmi_input_mode == "Value": |
| | bmi_val = st.sidebar.number_input( |
| | "BMI", |
| | min_value=0.0, |
| | max_value=100.0, |
| | value=24.0, |
| | step=0.1, |
| | format="%.1f", |
| | ) |
| | bmi_param = bmi_val |
| | else: |
| | bmi_cats = sorted(norm_df["Bmi"].unique()) |
| | bmi_cat = st.sidebar.selectbox("BMI category", [""] + bmi_cats) |
| | bmi_param = bmi_cat |
| |
|
| | |
| | codes = sorted(norm_df["Biomarkers"].unique()) |
| | friendly = [BIOMARKER_LABELS.get(c, c.title()) for c in codes] |
| | default_idx = friendly.index("Number of Steps") |
| | selected_label = st.sidebar.selectbox( |
| | "Biomarker", |
| | friendly, |
| | index=default_idx, |
| | ) |
| | biomarker = codes[friendly.index(selected_label)] |
| |
|
| | |
| | default_value = 6500.0 if biomarker == "nb_steps" else 0.0 |
| | |
| | mask = norm_df["Biomarkers"].str.lower() == biomarker.lower() |
| | max_val = float(norm_df.loc[mask, "max"].max()) |
| | value = st.sidebar.number_input( |
| | f"{selected_label} value", |
| | min_value=0.0, |
| | max_value=max_val, |
| | value=default_value, |
| | step=1.0, |
| | ) |
| |
|
| | |
| | norm_button = st.sidebar.button("Compute Normative Z-Score") |
| | if norm_button: |
| | try: |
| | res = normalizer_model.compute_normative_position( |
| | value=value, |
| | biomarker=biomarker, |
| | age_group=age_param, |
| | region=region, |
| | gender=gender, |
| | bmi=bmi_param, |
| | normative_df=norm_df, |
| | ) |
| | except Exception as e: |
| | st.error(f"Error: {e}") |
| | return |
| |
|
| | |
| | st.subheader("Results") |
| | m1, m2, m3, m4, m5 = st.columns(5) |
| | m1.metric("Z-Score", f"{res['z_score']:.2f}") |
| | m2.metric("Percentile", f"{res['percentile']:.2f}") |
| | m3.metric("Mean", f"{res['mean']:.2f}") |
| | m4.metric("SD", f"{res['sd']:.2f}") |
| | m5.metric("Sample Size", res["n"]) |
| |
|
| | |
| | age_group_str = normalizer_model._categorize_age(age_param, norm_df) |
| | bmi_cat = normalizer_model.categorize_bmi(bmi_param) |
| | st.markdown( |
| | f"**Basis of calculation:** Data from region **{region}**, " |
| | f"gender **{gender}**, age group **{age_group_str}**, " |
| | f"and BMI category **{bmi_cat}. " |
| | f"Sample size: {res['n']}**." |
| | ) |
| |
|
| | |
| | st.subheader("Detailed Statistics") |
| | stats_df = pd.DataFrame( |
| | { |
| | "Statistic": [ |
| | "Z-Score", |
| | "Percentile", |
| | "Mean", |
| | "SD", |
| | "Sample Size", |
| | "Median", |
| | "Q1", |
| | "Q3", |
| | "IQR", |
| | "MAD", |
| | "SE", |
| | "CI", |
| | ], |
| | "Value": [ |
| | f"{res['z_score']:.2f}", |
| | f"{res['percentile']:.2f}", |
| | f"{res['mean']:.2f}", |
| | f"{res['sd']:.2f}", |
| | res.get("n", "N/A"), |
| | f"{res.get('median', float('nan')):.2f}", |
| | f"{res.get('q1', float('nan')):.2f}", |
| | f"{res.get('q3', float('nan')):.2f}", |
| | f"{res.get('iqr', float('nan')):.2f}", |
| | f"{res.get('mad', float('nan')):.2f}", |
| | f"{res.get('se', float('nan')):.2f}", |
| | f"{res.get('ci', float('nan')):.2f}", |
| | ], |
| | } |
| | ) |
| | st.table(stats_df) |
| |
|
| | |
| | note = ( |
| | "*Note: Percentile and z-score estimation assume a normal " |
| | "distribution based on global Withings user data stratified by " |
| | "the parameters entered.*" |
| | ) |
| | st.write(note) |
| |
|
| | |
| | import normality_checks as nc |
| |
|
| | R = nc.iqr_tail_heaviness(res["iqr"], res["sd"]) |
| | q1_z, q3_z = nc.quartile_z_scores( |
| | res["mean"], |
| | res["sd"], |
| | res["q1"], |
| | res["q3"], |
| | ) |
| | skew = nc.pearson_skewness(res["mean"], res["median"], res["sd"]) |
| | st.subheader("Normality Heuristics") |
| |
|
| | |
| | if abs(skew) <= 0.1: |
| | skew_interp = "Symmetric (OK)" |
| | elif abs(skew) <= 0.5: |
| | skew_interp = f"{'Right' if skew > 0 else 'Left'} slight skew (usually OK)" |
| | elif abs(skew) <= 1.0: |
| | skew_interp = f"{'Right' if skew > 0 else 'Left'} noticeable skew" |
| | else: |
| | skew_interp = f"{'Right' if skew > 0 else 'Left'} strong skew" |
| |
|
| | norm_checks = pd.DataFrame( |
| | { |
| | "Check": [ |
| | "IQR/SD", |
| | "Q1 z-score", |
| | "Q3 z-score", |
| | "Pearson Skewness", |
| | ], |
| | "Value": [ |
| | f"{R:.2f}", |
| | f"{q1_z:.2f}", |
| | f"{q3_z:.2f}", |
| | f"{skew:.2f}", |
| | ], |
| | "Flag": [ |
| | ( |
| | "Heavier tails" |
| | if R > 1.5 |
| | else "Lighter tails" if R < 1.2 else "OK" |
| | ), |
| | "Deviation" if abs(q1_z + 0.6745) > 0.1 else "OK", |
| | "Deviation" if abs(q3_z - 0.6745) > 0.1 else "OK", |
| | skew_interp, |
| | ], |
| | } |
| | ) |
| | st.table(norm_checks) |
| |
|
| | |
| | st.markdown( |
| | """ |
| | **Pearson Skewness Interpretation:** |
| | - ≈ 0: Symmetric distribution |
| | - ±0.1 to ±0.5: Slight/moderate skew |
| | - ±0.5 to ±1: Noticeable skew |
| | - larger than±1: Strong skew |
| | |
| | - Positive values: Right skew (longer tail on right) |
| | - Negative values: Left skew (longer tail on left) |
| | """ |
| | ) |
| |
|
| | |
| | if any(("OK" not in val) for val in norm_checks["Flag"]): |
| | st.warning( |
| | "Warning: Heuristic checks indicate possible deviations " |
| | "from normality; interpret z-score and percentiles with " |
| | "caution." |
| | ) |
| |
|
| | |
| | with st.expander("Optional: Skew-Corrected Results"): |
| | st.write("Adjusts for skew via Pearson Type III back-transform.") |
| | st.write("Error often <1 percentile point when |skew| ≤ 0.5.") |
| | st.write("Usually more useful for stronger skewed distributions.") |
| | st.write("Note: This is a heuristic and may not always be accurate.") |
| | res_skew = normalizer_model.compute_skew_corrected_position( |
| | value=value, |
| | mean=res["mean"], |
| | sd=res["sd"], |
| | median=res["median"], |
| | ) |
| | pct_skew = f"{res_skew['percentile_skew_corrected']:.2f}" |
| | sc1, sc2 = st.columns(2) |
| | sc1.metric( |
| | "Skew-Corrected Z-Score", |
| | f"{res_skew['z_skew_corrected']:.2f}", |
| | ) |
| | sc2.metric( |
| | "Skew-Corrected Percentile", |
| | pct_skew, |
| | ) |
| |
|
| | st.markdown("---") |
| | st.subheader("Visualizations") |
| | |
| | z_vals = np.linspace(-4, 4, 400) |
| | density = norm.pdf(z_vals) |
| | df_chart = pd.DataFrame({"z": z_vals, "density": density}) |
| | |
| | area = ( |
| | alt.Chart(df_chart) |
| | .mark_area(color="orange", opacity=0.3) |
| | .transform_filter(alt.datum.z <= res["z_score"]) |
| | .encode( |
| | x=alt.X( |
| | "z:Q", |
| | title="z-score", |
| | ), |
| | y=alt.Y( |
| | "density:Q", |
| | title="Density", |
| | ), |
| | ) |
| | ) |
| | |
| | line = ( |
| | alt.Chart(df_chart) |
| | .mark_line(color="orange") |
| | .encode( |
| | x="z:Q", |
| | y="density:Q", |
| | ) |
| | ) |
| | |
| | vline = ( |
| | alt.Chart(pd.DataFrame({"z": [res["z_score"]]})) |
| | .mark_rule(color="orange") |
| | .encode(x="z:Q") |
| | ) |
| | chart = (area + line + vline).properties( |
| | width=600, |
| | height=300, |
| | title="Standard Normal Distribution", |
| | ) |
| | st.altair_chart(chart, use_container_width=True) |
| | |
| | st.write( |
| | f"Your value is z = {res['z_score']:.2f}, which places you in " |
| | f"the {res['percentile']:.1f}th percentile of a normal " |
| | f"distribution." |
| | ) |
| | |
| | |
| | bullet = go.Figure( |
| | go.Indicator( |
| | mode="number+gauge", |
| | value=res["z_score"], |
| | number={"suffix": " SD"}, |
| | gauge={ |
| | "shape": "bullet", |
| | "axis": { |
| | "range": [-3, 3], |
| | "tickmode": "linear", |
| | "dtick": 0.5, |
| | }, |
| | "bar": {"color": "orange"}, |
| | }, |
| | ) |
| | ) |
| | bullet.update_layout( |
| | height=150, |
| | margin={"t": 20, "b": 20, "l": 20, "r": 20}, |
| | ) |
| | st.plotly_chart(bullet, use_container_width=True) |
| | |
| | st.write(f"Percentile: {res['percentile']:.1f}%") |
| | else: |
| | st.sidebar.info( |
| | "Fill in all inputs and click Compute " "to get normative Z-score." |
| | ) |
| |
|
| | |
| | st.markdown("---") |
| | st.markdown( |
| | "Built in with ❤️ in Düsseldorf. © Lars Masanneck 2025. " |
| | "Thanks to Withings for sharing this data openly." |
| | ) |
| |
|
| |
|
| | if __name__ == "__main__": |
| | main() |
| |
|