diff --git a/ocular-health-dashboard/README.md b/ocular-health-dashboard/README.md new file mode 100644 index 0000000..bc9dcfe --- /dev/null +++ b/ocular-health-dashboard/README.md @@ -0,0 +1,55 @@ +# Ocular Health Visualization Suite + +This project extends the research narrative around the **ODIR-5K** eye disease dataset and the Schwann cell regeneration studies in the cornea. It demonstrates how curated biomedical tables can be transformed into actionable visuals and interactive dashboards. + +## Project structure + +``` +ocular-health-dashboard/ +├── analysis.py # Generates static plots (heatmaps, box/violin plots, clustering) +├── dashboard_app.py # Plotly Dash interface for real-time exploration +├── data/ +│ └── ocular_health_sample.csv # Lightweight demo subset inspired by ODIR-5K +├── data_utils.py # Shared helpers for loading data and clustering +├── outputs/ # Saved figures after running analysis.py +└── requirements.txt # Optional dependency pinning +``` + +## How to use the analysis workflow + +1. (Optional) create and activate a virtual environment. +2. Install dependencies: + + ```bash + pip install -r requirements.txt + ``` + +3. Generate the static figures discussed in the manuscript: + + ```bash + python analysis.py + ``` + + The following plots are saved into the `outputs/` directory: + + * `correlation_heatmap.png` – correlation heatmap between demographics, Schwann metrics, and diagnoses. + * `age_by_disease_boxplot.png` – box/strip plot of patient ages per diagnosis. + * `schwann_density_violin.png` – violin plot of Schwann cell density vs. diagnosis. + * `age_vs_regeneration.png` – scatter plot of age vs. corneal nerve regeneration rate. + * `cluster_scatter.png` – PCA projection of k-means clusters for patient subgroup discovery. + +## Interactive dashboard (Plotly Dash) + +Launch the dashboard to explore the dataset live and reproduce the figures from the paper: + +```bash +python dashboard_app.py +``` + +The app exposes: + +* **Correlation heatmap** for demographics vs. disease burden. +* **Age filter & disease selector** to drive the age boxplot, Schwann cell violin plot, and age vs. regeneration scatter. +* **Cluster explorer** that highlights PCA components and the patient subgroups obtained from k-means. + +Because the demo dataset is small, the dashboard starts instantly but the layout is ready to scale to the full ODIR-5K cohort. diff --git a/ocular-health-dashboard/analysis.py b/ocular-health-dashboard/analysis.py new file mode 100644 index 0000000..34b9ab9 --- /dev/null +++ b/ocular-health-dashboard/analysis.py @@ -0,0 +1,107 @@ +"""Generate exploratory plots for the ocular health project.""" +from __future__ import annotations + +from pathlib import Path + +import matplotlib.pyplot as plt +import pandas as pd +import seaborn as sns + +from data_utils import ( + DISEASE_COLUMNS, + correlation_columns, + load_ocular_data, + melt_disease_long, + run_clustering, +) + +OUTPUT_DIR = Path(__file__).parent / "outputs" +OUTPUT_DIR.mkdir(exist_ok=True) + +sns.set_theme(style="whitegrid") + + +def _save_current(fig: plt.Figure, filename: str) -> None: + path = OUTPUT_DIR / filename + fig.tight_layout() + fig.savefig(path, dpi=300) + plt.close(fig) + print(f"Saved {path}") + + +def create_correlation_heatmap(df: pd.DataFrame) -> None: + fig, ax = plt.subplots(figsize=(8, 6)) + corr = df[correlation_columns()].corr() + sns.heatmap(corr, annot=True, cmap="coolwarm", center=0, ax=ax) + ax.set_title("Demographic & Disease Correlations") + _save_current(fig, "correlation_heatmap.png") + + +def create_age_boxplot(long_df: pd.DataFrame) -> None: + fig, ax = plt.subplots(figsize=(9, 5)) + sns.boxplot(data=long_df, x="Disease", y="Age", ax=ax) + sns.stripplot(data=long_df, x="Disease", y="Age", color="black", size=3, alpha=0.6, ax=ax) + ax.set_title("Age distribution for each diagnosis") + _save_current(fig, "age_by_disease_boxplot.png") + + +def create_schwann_violin(long_df: pd.DataFrame) -> None: + fig, ax = plt.subplots(figsize=(9, 5)) + sns.violinplot( + data=long_df, + x="Disease", + y="SchwannCellDensity", + ax=ax, + inner="quartile", + cut=0, + ) + ax.set_ylabel("Schwann Cell Density (cells/mm)") + ax.set_title("Schwann cell distribution by diagnosis") + _save_current(fig, "schwann_density_violin.png") + + +def create_age_regeneration_scatter(df: pd.DataFrame) -> None: + fig, ax = plt.subplots(figsize=(7, 5)) + sns.scatterplot( + data=df, + x="Age", + y="RegenerationRate", + hue="PrimaryDiagnosis", + style="Sex", + ax=ax, + ) + ax.set_ylabel("Corneal nerve regeneration rate") + ax.set_title("Age vs. regeneration speed") + _save_current(fig, "age_vs_regeneration.png") + + +def create_cluster_plot(cluster_df: pd.DataFrame) -> None: + fig, ax = plt.subplots(figsize=(7, 5)) + sns.scatterplot( + data=cluster_df, + x="PC1", + y="PC2", + hue="Cluster", + style="PrimaryDiagnosis", + palette="Set2", + s=80, + ax=ax, + ) + ax.set_title("Patient subgroups (PCA + k-means)") + _save_current(fig, "cluster_scatter.png") + + +def main() -> None: + df = load_ocular_data() + long_df = melt_disease_long(df) + cluster_df, _pca, _model = run_clustering(df) + + create_correlation_heatmap(df) + create_age_boxplot(long_df) + create_schwann_violin(long_df) + create_age_regeneration_scatter(df) + create_cluster_plot(cluster_df) + + +if __name__ == "__main__": + main() diff --git a/ocular-health-dashboard/dashboard_app.py b/ocular-health-dashboard/dashboard_app.py new file mode 100644 index 0000000..c19315a --- /dev/null +++ b/ocular-health-dashboard/dashboard_app.py @@ -0,0 +1,144 @@ +"""Plotly Dash application for real-time ocular health exploration.""" +from __future__ import annotations + +import dash +from dash import Dash, Input, Output, dcc, html +import pandas as pd +import plotly.express as px + +from data_utils import DISEASE_COLUMNS, correlation_columns, load_ocular_data, melt_disease_long, run_clustering + + +def build_figures(df: pd.DataFrame) -> tuple: + corr = df[correlation_columns()].corr() + corr_fig = px.imshow( + corr, + text_auto=".2f", + color_continuous_scale="RdBu", + zmin=-1, + zmax=1, + aspect="auto", + title="Correlation matrix", + ) + + cluster_df, _pca, _model = run_clustering(df) + cluster_fig = px.scatter( + cluster_df, + x="PC1", + y="PC2", + color="Cluster", + symbol="Sex", + hover_data=["PatientID", "Age", "PrimaryDiagnosis"], + title="Patient subgroup structure", + ) + return corr_fig, cluster_fig, cluster_df + + +def build_app() -> Dash: + df = load_ocular_data() + long_df = melt_disease_long(df) + corr_fig, cluster_fig, cluster_df = build_figures(df) + + app = dash.Dash(__name__) + app.title = "Ocular Health Dashboard" + + min_age = df["Age"].min() + max_age = df["Age"].max() + + app.layout = html.Div( + [ + html.H1("Schwann Cell & Ocular Disease Explorer"), + html.P( + "Interactively explore demographics, diagnoses, and Schwann cell metrics derived from the ODIR-5K study." + ), + html.Div( + [ + html.Label("Filter patients by age"), + dcc.RangeSlider( + id="age-range", + min=min_age, + max=max_age, + step=1, + allowCross=False, + value=[min_age, max_age], + marks={int(min_age): str(int(min_age)), int(max_age): str(int(max_age))}, + ), + ], + className="control-panel", + ), + html.Div( + [ + html.Label("Highlight a diagnosis"), + dcc.Dropdown( + id="disease-select", + options=[{"label": d, "value": d} for d in DISEASE_COLUMNS], + value=DISEASE_COLUMNS[0], + clearable=False, + ), + ] + ), + dcc.Graph(id="correlation-heatmap", figure=corr_fig), + html.Div( + [ + dcc.Graph(id="age-boxplot"), + dcc.Graph(id="schwann-violin"), + ], + className="split-row", + ), + dcc.Graph(id="age-regeneration-scatter"), + dcc.Graph(id="cluster-graph", figure=cluster_fig), + ], + className="container", + ) + + @app.callback( + Output("age-boxplot", "figure"), + Output("schwann-violin", "figure"), + Output("age-regeneration-scatter", "figure"), + Input("age-range", "value"), + Input("disease-select", "value"), + ) + def update_patient_plots(age_range, disease): # type: ignore[override] + min_age, max_age = age_range + age_mask = (df["Age"] >= min_age) & (df["Age"] <= max_age) + filtered_df = df[age_mask] + filtered_long = long_df[(long_df["Age"] >= min_age) & (long_df["Age"] <= max_age)] + + box_fig = px.box( + filtered_long, + x="Disease", + y="Age", + color="Disease", + title="Age distribution by diagnosis", + ) + + violin_fig = px.violin( + filtered_long[filtered_long["Disease"] == disease], + x="Disease", + y="SchwannCellDensity", + box=True, + points="all", + title=f"Schwann cell density • {disease}", + ) + + scatter_fig = px.scatter( + filtered_df, + x="Age", + y="RegenerationRate", + color="PrimaryDiagnosis", + symbol="Sex", + hover_data=["PatientID", "SchwannCellDensity"], + title="Age vs. corneal nerve regeneration", + ) + return box_fig, violin_fig, scatter_fig + + return app + + +def main() -> None: + app = build_app() + app.run_server(debug=True) + + +if __name__ == "__main__": + main() diff --git a/ocular-health-dashboard/data/ocular_health_sample.csv b/ocular-health-dashboard/data/ocular_health_sample.csv new file mode 100644 index 0000000..90c3665 --- /dev/null +++ b/ocular-health-dashboard/data/ocular_health_sample.csv @@ -0,0 +1,31 @@ +PatientID,Age,Sex,Diabetic Retinopathy,Glaucoma,Macular Degeneration,Cataract,SchwannCellDensity,RegenerationRate +P001,58,F,1,0,0,0,38.4,0.62 +P002,67,M,0,1,0,1,34.1,0.55 +P003,45,F,1,0,0,0,42.7,0.71 +P004,72,M,0,1,1,1,30.9,0.48 +P005,51,F,0,0,0,0,44.2,0.76 +P006,63,M,0,0,1,1,33.5,0.59 +P007,39,F,1,0,0,0,46.8,0.83 +P008,55,M,0,0,1,0,37.2,0.66 +P009,48,F,1,0,0,0,41.6,0.74 +P010,70,M,0,1,0,1,31.8,0.52 +P011,60,F,0,0,1,0,36.7,0.61 +P012,42,M,1,0,0,0,45.5,0.79 +P013,50,F,0,0,0,1,39.4,0.68 +P014,76,M,0,1,1,1,29.8,0.44 +P015,57,F,1,0,0,0,40.9,0.69 +P016,64,M,0,1,0,1,32.5,0.57 +P017,53,F,0,0,1,0,38.9,0.65 +P018,47,M,1,0,0,0,43.7,0.77 +P019,69,F,0,1,1,1,31.4,0.49 +P020,44,M,0,0,0,0,45.1,0.81 +P021,59,F,1,0,0,0,39.8,0.67 +P022,61,M,0,1,0,1,33.2,0.56 +P023,46,F,0,0,1,0,42.1,0.72 +P024,52,M,1,0,0,0,41.2,0.70 +P025,68,F,0,1,1,1,30.4,0.47 +P026,43,M,0,0,0,0,46.1,0.82 +P027,56,F,1,0,0,0,40.5,0.68 +P028,62,M,0,1,0,1,32.8,0.54 +P029,49,F,0,0,1,0,37.9,0.63 +P030,65,M,0,1,1,1,31.1,0.51 diff --git a/ocular-health-dashboard/data_utils.py b/ocular-health-dashboard/data_utils.py new file mode 100644 index 0000000..854ad12 --- /dev/null +++ b/ocular-health-dashboard/data_utils.py @@ -0,0 +1,88 @@ +"""Utility functions for the ocular health analytics workflow.""" +from __future__ import annotations + +from pathlib import Path +from typing import List, Tuple + +import pandas as pd +from sklearn.cluster import KMeans +from sklearn.decomposition import PCA +from sklearn.preprocessing import StandardScaler + +DATA_PATH = Path(__file__).parent / "data" / "ocular_health_sample.csv" +DISEASE_COLUMNS = [ + "Diabetic Retinopathy", + "Glaucoma", + "Macular Degeneration", + "Cataract", +] + + +def load_ocular_data() -> pd.DataFrame: + """Load the ocular health dataset with helper columns.""" + df = pd.read_csv(DATA_PATH) + df["SexBinary"] = df["Sex"].map({"F": 0, "M": 1}) + diagnosis = df[DISEASE_COLUMNS] + has_diagnosis = diagnosis.sum(axis=1) > 0 + primary = diagnosis.idxmax(axis=1) + df["PrimaryDiagnosis"] = primary.where(has_diagnosis, "No Finding") + return df + + +def melt_disease_long(df: pd.DataFrame) -> pd.DataFrame: + """Return a patient-level long table filtered to positive diagnoses.""" + long_df = df.melt( + id_vars=["PatientID", "Age", "Sex", "SchwannCellDensity", "RegenerationRate"], + value_vars=DISEASE_COLUMNS, + var_name="Disease", + value_name="Diagnosis", + ) + return long_df[long_df["Diagnosis"] == 1] + + +def correlation_columns() -> List[str]: + return [ + "Age", + "SexBinary", + "SchwannCellDensity", + "RegenerationRate", + *DISEASE_COLUMNS, + ] + + +def run_clustering( + df: pd.DataFrame, + *, + features: List[str] | None = None, + n_clusters: int = 4, +) -> Tuple[pd.DataFrame, PCA, KMeans]: + """Return dataframe annotated with PCA components and cluster labels.""" + if features is None: + features = [ + "Age", + "SexBinary", + "SchwannCellDensity", + "RegenerationRate", + *DISEASE_COLUMNS, + ] + scaler = StandardScaler() + scaled = scaler.fit_transform(df[features]) + model = KMeans(n_clusters=n_clusters, n_init="auto", random_state=42) + clusters = model.fit_predict(scaled) + pca = PCA(n_components=2, random_state=42) + pcs = pca.fit_transform(scaled) + cluster_df = df.copy() + cluster_df["Cluster"] = clusters + cluster_df["PC1"] = pcs[:, 0] + cluster_df["PC2"] = pcs[:, 1] + return cluster_df, pca, model + + +__all__ = [ + "DATA_PATH", + "DISEASE_COLUMNS", + "load_ocular_data", + "melt_disease_long", + "correlation_columns", + "run_clustering", +] diff --git a/ocular-health-dashboard/requirements.txt b/ocular-health-dashboard/requirements.txt new file mode 100644 index 0000000..b017b91 --- /dev/null +++ b/ocular-health-dashboard/requirements.txt @@ -0,0 +1,6 @@ +dash==2.14.2 +matplotlib==3.8.3 +pandas==2.2.2 +plotly==5.20.0 +scikit-learn==1.4.2 +seaborn==0.13.2