Spaces:
Sleeping
Sleeping
Download app.py from geogem245/HearLife_Lab: direct link, hf CLI and curl.
- Browser
- Download file 87.6 kB
-
https://huggingface.co/spaces/geogem245/HearLife_Lab/resolve/main/app.py
- Command line
-
hf download hf://spaces/geogem245/HearLife_Lab/app.py
-
curl -L -o app.py https://huggingface.co/spaces/geogem245/HearLife_Lab/resolve/main/app.py
87.6 kB
| import random | |
| from dataclasses import dataclass | |
| import numpy as np | |
| import pandas as pd | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| import gradio as gr | |
| import spaces | |
| # ============================================================ | |
| # APPLICATION CONFIGURATION | |
| # ============================================================ | |
| APP_TITLE = "Hearing Technology Behaviour Lab" | |
| APP_VERSION = "0.5.1" | |
| DISCLAIMER = ( | |
| "Synthetic behavioural simulation for research and exploration. " | |
| "It is not a clinical prediction model and does not represent " | |
| "individual real-world patients." | |
| ) | |
| # ============================================================ | |
| # EVIDENCE / RAG CONTEXT | |
| # ============================================================ | |
| EVIDENCE = { | |
| "directionality": { | |
| "label": "Directional microphone benefit", | |
| "description": ( | |
| "Laboratory studies commonly report roughly 3β4 dB directional " | |
| "benefit in difficult near-field conditions; individual studies " | |
| "also report values around 2β5 dB depending on configuration." | |
| ), | |
| "source": "Ricketts review; Kim & Kim study; hearing-aid directionality literature", | |
| "tier": "A β empirical anchor", | |
| "default": "Fixed directional prior = 3.2 dB", | |
| }, | |
| "bilateral_directionality": { | |
| "label": "Bilateral spatial processing", | |
| "description": ( | |
| "Bilateral directional processing can provide larger SNR advantages " | |
| "than a single first-order directional microphone under favourable conditions." | |
| ), | |
| "source": "Ricketts et al. / hearing-aid prescription literature", | |
| "tier": "A/B β empirical anchor with configuration dependence", | |
| "default": "Binaural beamformer prior = 5.5 dB", | |
| }, | |
| "adaptive_directionality": { | |
| "label": "Adaptive directionality", | |
| "description": ( | |
| "Adaptive directional processing improves speech recognition over " | |
| "omnidirectional processing in several simulated real-world scenes, " | |
| "while the realised advantage depends on noise movement and geometry." | |
| ), | |
| "source": "Evaluation of adaptive directional-microphone hearing aids", | |
| "tier": "A/B β empirical anchor with scene dependence", | |
| "default": "Adaptive directional prior = 3.8 dB", | |
| }, | |
| "dnn": { | |
| "label": "DNN / learned noise reduction", | |
| "description": ( | |
| "Recent controlled work reports benefits from DNN processing, particularly " | |
| "when combined with beamforming, but outcomes vary with noise type and " | |
| "spatial configuration. The model therefore uses a broad prior rather than " | |
| "a single claimed DNN gain." | |
| ), | |
| "source": "Recent DNN hearing-aid speech-recognition/listening-effort literature", | |
| "tier": "B β literature-informed", | |
| "default": "DNN beamformer prior = 4.8 dB", | |
| }, | |
| "listening_effort": { | |
| "label": "Listening effort", | |
| "description": ( | |
| "Directional microphone processing has been shown to reduce listening effort " | |
| "in controlled speech-in-noise tasks." | |
| ), | |
| "source": "Picou & Ricketts; Desjardins", | |
| "tier": "A β empirical direction of effect", | |
| "default": "Effort responds to effective SNR and task difficulty", | |
| }, | |
| "sound_quality": { | |
| "label": "Signal fidelity / sound quality", | |
| "description": ( | |
| "Noise reduction can improve effective SNR while also introducing signal " | |
| "distortion; stronger processing can therefore create a performanceβfidelity trade-off." | |
| ), | |
| "source": "Hearing-aid noise-reduction / HASQI literature", | |
| "tier": "A/B β empirical direction of effect", | |
| "default": "Processing artefact penalty rises with processing intensity", | |
| }, | |
| "satisfaction": { | |
| "label": "Satisfaction", | |
| "description": ( | |
| "MarkeTrak 2022 reported 83% overall hearing-aid satisfaction, broadly " | |
| "consistent with 81% in 2015 and 83% in 2019." | |
| ), | |
| "source": "MarkeTrak 2022", | |
| "tier": "A β population anchor", | |
| "default": "Population-level satisfaction anchor β 0.83", | |
| }, | |
| "daily_use": { | |
| "label": "Daily use", | |
| "description": ( | |
| "MarkeTrak provides a useful real-world anchor for daily use and hours worn, " | |
| "while individual use remains heterogeneous." | |
| ), | |
| "source": "MarkeTrak survey literature", | |
| "tier": "A β population anchor", | |
| "default": "Daily-use prior β 0.67β0.70; wear time β 8β10 h/day", | |
| }, | |
| "wearability": { | |
| "label": "Wearability / practical burden", | |
| "description": ( | |
| "Comfort, handling, visibility, charging and technology complexity are modelled " | |
| "as behavioural modifiers rather than acoustic effects." | |
| ), | |
| "source": "Behavioural modelling informed by hearing-aid user research", | |
| "tier": "C β modelling construct", | |
| "default": "Explicitly synthetic; not a prevalence estimate", | |
| }, | |
| "spatial_performance": { | |
| "label": "Spatial geometry", | |
| "description": ( | |
| "Directional benefit depends on target/noise geometry, source distance, " | |
| "reverberation and movement; frontal target access is generally more favourable " | |
| "for conventional directional processing." | |
| ), | |
| "source": "Directional microphone and spatial-processing literature", | |
| "tier": "A/B β evidence-informed mechanism", | |
| "default": "Scene-specific geometry modifier", | |
| }, | |
| "intelligibility": { | |
| "label": "Estimated intelligibility", | |
| "description": ( | |
| "The simulation uses a logistic psychometric proxy so that an identical SNR " | |
| "change can produce different percentage-point gains depending on starting difficulty." | |
| ), | |
| "source": "Modelled functional construct", | |
| "tier": "C β modelling construct", | |
| "default": "Not a clinical speech-recognition score", | |
| }, | |
| "glasses": { | |
| "label": "Augmented listening glasses", | |
| "description": ( | |
| "Hypothetical technology. There is no single empirical parameter set in the " | |
| "literature that justifies treating this as an established device class." | |
| ), | |
| "source": "Synthetic scenario / hypothesis", | |
| "tier": "C β hypothetical", | |
| "default": "Conservative 4.0 dB potential prior; wide uncertainty", | |
| }, | |
| } | |
| # ============================================================ | |
| # TECHNOLOGIES | |
| # ============================================================ | |
| REFERENCE_CONDITIONS = [ | |
| "Unaided listening", | |
| "Omnidirectional", | |
| "Fixed directional", | |
| "Adaptive directional", | |
| "Beamformer", | |
| "Binaural beamformer", | |
| "DNN beamformer", | |
| "Augmented listening glasses", | |
| ] | |
| TECHNOLOGIES = { | |
| "Omnidirectional": { | |
| "potential_snr_db": 0.0, | |
| "off_axis_robustness": 1.00, | |
| "spatial_selectivity": 0.05, | |
| "target_preservation": 0.98, | |
| "mobility_robustness": 1.00, | |
| "reverb_robustness": 1.00, | |
| "competing_speech_robustness": 0.55, | |
| "processing_intensity": 0.02, | |
| "naturalness": 0.98, | |
| "battery": 0.96, | |
| "wearability": 0.92, | |
| "visibility": 0.05, | |
| "complexity": 0.02, | |
| "charging_hours": 14.0, | |
| "description": "Broad acoustic access with minimal spatial filtering.", | |
| "evidence_tier": "A/B baseline condition", | |
| }, | |
| "Fixed directional": { | |
| "potential_snr_db": 3.2, | |
| "off_axis_robustness": 0.38, | |
| "spatial_selectivity": 0.68, | |
| "target_preservation": 0.92, | |
| "mobility_robustness": 0.50, | |
| "reverb_robustness": 0.68, | |
| "competing_speech_robustness": 0.60, | |
| "processing_intensity": 0.24, | |
| "naturalness": 0.88, | |
| "battery": 0.88, | |
| "wearability": 0.90, | |
| "visibility": 0.05, | |
| "complexity": 0.18, | |
| "charging_hours": 13.0, | |
| "description": "Stable forward-oriented directionality; strongest when geometry is favourable.", | |
| "evidence_tier": "A β literature-anchored prior", | |
| }, | |
| "Adaptive directional": { | |
| "potential_snr_db": 3.8, | |
| "off_axis_robustness": 0.64, | |
| "spatial_selectivity": 0.72, | |
| "target_preservation": 0.92, | |
| "mobility_robustness": 0.72, | |
| "reverb_robustness": 0.66, | |
| "competing_speech_robustness": 0.66, | |
| "processing_intensity": 0.34, | |
| "naturalness": 0.87, | |
| "battery": 0.80, | |
| "wearability": 0.88, | |
| "visibility": 0.05, | |
| "complexity": 0.32, | |
| "charging_hours": 12.5, | |
| "description": "Directionality adapts to changing acoustic scenes and moving noise.", | |
| "evidence_tier": "A/B β literature-anchored prior", | |
| }, | |
| "Beamformer": { | |
| "potential_snr_db": 4.5, | |
| "off_axis_robustness": 0.68, | |
| "spatial_selectivity": 0.80, | |
| "target_preservation": 0.90, | |
| "mobility_robustness": 0.68, | |
| "reverb_robustness": 0.64, | |
| "competing_speech_robustness": 0.68, | |
| "processing_intensity": 0.48, | |
| "naturalness": 0.82, | |
| "battery": 0.72, | |
| "wearability": 0.84, | |
| "visibility": 0.05, | |
| "complexity": 0.50, | |
| "charging_hours": 11.5, | |
| "description": "Stronger spatial target extraction with greater processing burden.", | |
| "evidence_tier": "B β engineering/literature-informed prior", | |
| }, | |
| "Binaural beamformer": { | |
| "potential_snr_db": 5.5, | |
| "off_axis_robustness": 0.76, | |
| "spatial_selectivity": 0.86, | |
| "target_preservation": 0.92, | |
| "mobility_robustness": 0.76, | |
| "reverb_robustness": 0.67, | |
| "competing_speech_robustness": 0.74, | |
| "processing_intensity": 0.60, | |
| "naturalness": 0.83, | |
| "battery": 0.65, | |
| "wearability": 0.82, | |
| "visibility": 0.05, | |
| "complexity": 0.62, | |
| "charging_hours": 10.5, | |
| "description": "Uses bilateral information for stronger spatial target extraction.", | |
| "evidence_tier": "A/B β literature-informed prior", | |
| }, | |
| "DNN beamformer": { | |
| "potential_snr_db": 4.8, | |
| "off_axis_robustness": 0.70, | |
| "spatial_selectivity": 0.88, | |
| "target_preservation": 0.89, | |
| "mobility_robustness": 0.66, | |
| "reverb_robustness": 0.72, | |
| "competing_speech_robustness": 0.78, | |
| "processing_intensity": 0.76, | |
| "naturalness": 0.77, | |
| "battery": 0.55, | |
| "wearability": 0.79, | |
| "visibility": 0.05, | |
| "complexity": 0.78, | |
| "charging_hours": 9.5, | |
| "description": "Learned spatial/noise separation; potentially strong but scene-dependent.", | |
| "evidence_tier": "B β recent literature-informed prior", | |
| }, | |
| "Augmented listening glasses": { | |
| "potential_snr_db": 4.0, | |
| "off_axis_robustness": 0.82, | |
| "spatial_selectivity": 0.90, | |
| "target_preservation": 0.90, | |
| "mobility_robustness": 0.78, | |
| "reverb_robustness": 0.68, | |
| "competing_speech_robustness": 0.72, | |
| "processing_intensity": 0.72, | |
| "naturalness": 0.70, | |
| "battery": 0.48, | |
| "wearability": 0.62, | |
| "visibility": 0.45, | |
| "complexity": 0.78, | |
| "charging_hours": 8.0, | |
| "description": "Hypothetical wearable augmented-listening technology; parameters are deliberately uncertain.", | |
| "evidence_tier": "C β hypothetical scenario", | |
| }, | |
| } | |
| # ============================================================ | |
| # LISTENING SCENES | |
| # ============================================================ | |
| SCENES = { | |
| "Quiet conversation": { | |
| "noise": 0.10, "reverb": 0.10, "distance": 0.20, "spatial": 0.30, | |
| "social": 0.75, "importance": 0.75, | |
| "target_azimuth": 10, "target_mobility": 0.10, "competing_speech": 0.05, | |
| }, | |
| "Family dinner": { | |
| "noise": 0.55, "reverb": 0.35, "distance": 0.45, "spatial": 0.65, | |
| "social": 0.90, "importance": 0.90, | |
| "target_azimuth": 35, "target_mobility": 0.35, "competing_speech": 0.55, | |
| }, | |
| "Busy restaurant": { | |
| "noise": 0.85, "reverb": 0.65, "distance": 0.65, "spatial": 0.80, | |
| "social": 0.90, "importance": 0.90, | |
| "target_azimuth": 70, "target_mobility": 0.55, "competing_speech": 0.80, | |
| }, | |
| "Work meeting": { | |
| "noise": 0.35, "reverb": 0.40, "distance": 0.55, "spatial": 0.75, | |
| "social": 0.80, "importance": 0.85, | |
| "target_azimuth": 20, "target_mobility": 0.25, "competing_speech": 0.45, | |
| }, | |
| "Walking outdoors": { | |
| "noise": 0.35, "reverb": 0.05, "distance": 0.70, "spatial": 0.95, | |
| "social": 0.35, "importance": 0.60, | |
| "target_azimuth": 110, "target_mobility": 0.75, "competing_speech": 0.10, | |
| }, | |
| "Public transport": { | |
| "noise": 0.75, "reverb": 0.55, "distance": 0.65, "spatial": 0.75, | |
| "social": 0.35, "importance": 0.55, | |
| "target_azimuth": 65, "target_mobility": 0.25, "competing_speech": 0.60, | |
| }, | |
| "Lecture / presentation": { | |
| "noise": 0.25, "reverb": 0.55, "distance": 0.85, "spatial": 0.60, | |
| "social": 0.65, "importance": 0.80, | |
| "target_azimuth": 5, "target_mobility": 0.10, "competing_speech": 0.05, | |
| }, | |
| "Social gathering": { | |
| "noise": 0.70, "reverb": 0.45, "distance": 0.60, "spatial": 0.85, | |
| "social": 1.00, "importance": 0.95, | |
| "target_azimuth": 55, "target_mobility": 0.60, "competing_speech": 0.75, | |
| }, | |
| } | |
| # ============================================================ | |
| # SYNTHETIC USER | |
| # ============================================================ | |
| class SyntheticUser: | |
| user_id: int | |
| hearing_loss: float | |
| noise_difficulty: float | |
| spatial_difficulty: float | |
| technology_tolerance: float | |
| comfort_sensitivity: float | |
| social_confidence: float | |
| engagement: float | |
| battery_sensitivity: float | |
| visibility_sensitivity: float | |
| baseline_use: float | |
| baseline_satisfaction: float | |
| acceptance: float = 0.50 | |
| fatigue: float = 0.0 | |
| cumulative_benefit: float = 0.0 | |
| def create_user(user_id): | |
| return SyntheticUser( | |
| user_id=user_id, | |
| hearing_loss=float(np.clip(np.random.normal(0.55, 0.18), 0.15, 0.95)), | |
| noise_difficulty=float(np.clip(np.random.normal(0.55, 0.20), 0.10, 0.95)), | |
| spatial_difficulty=float(np.clip(np.random.normal(0.45, 0.22), 0.05, 0.95)), | |
| technology_tolerance=float(np.clip(np.random.normal(0.60, 0.20), 0.10, 0.95)), | |
| comfort_sensitivity=float(np.clip(np.random.normal(0.55, 0.20), 0.10, 0.95)), | |
| social_confidence=float(np.clip(np.random.normal(0.65, 0.18), 0.10, 0.95)), | |
| engagement=float(np.clip(np.random.normal(0.65, 0.20), 0.10, 0.95)), | |
| battery_sensitivity=float(np.clip(np.random.normal(0.50, 0.20), 0.10, 0.95)), | |
| visibility_sensitivity=float(np.clip(np.random.normal(0.40, 0.25), 0.05, 0.95)), | |
| # Calibrated loosely around MarkeTrak's population-level daily-use anchor, | |
| # but individual values remain synthetic and are not prevalence estimates. | |
| baseline_use=float(np.clip(np.random.normal(0.69, 0.14), 0.25, 0.98)), | |
| baseline_satisfaction=float(np.clip(np.random.normal(0.83, 0.10), 0.45, 0.98)), | |
| ) | |
| # ============================================================ | |
| # TECHNOLOGY EFFECT | |
| # ============================================================ | |
| def _technology_profile(condition): | |
| """Return a technology profile. Unaided is an explicit zero-processing reference.""" | |
| if condition == "Unaided listening": | |
| return { | |
| "noise": 0.0, "spatial": 0.0, "target": 0.0, | |
| "naturalness": 1.0, "battery": 1.0, "complexity": 0.0, | |
| "snr_cap_db": 0.0, "spatial_selectivity": 0.0, | |
| "target_preservation": 1.0, "off_axis_robustness": 1.0, | |
| "wearability": 1.0, "visibility": 0.0, | |
| } | |
| return TECHNOLOGIES[condition] | |
| def _angle_alignment(target_azimuth): | |
| """Conventional forward-axis alignment, 1 at 0Β° and 0 at 90Β°/180Β°.""" | |
| angle = abs(float(target_azimuth)) | |
| # Use a smooth front-to-side/back decay rather than a negative cosine. | |
| return float(np.clip(np.cos(np.deg2rad(min(angle, 90))) ** 1.25, 0, 1)) | |
| def _scene_noise_type(scene): | |
| if scene in {"Busy restaurant", "Social gathering", "Family dinner"}: | |
| return "multi_talker" | |
| if scene in {"Public transport", "Walking outdoors"}: | |
| return "environmental" | |
| if scene in {"Work meeting", "Lecture / presentation"}: | |
| return "speech_dominant" | |
| return "low_competition" | |
| def _acoustic_scene_state(scene, user): | |
| s = SCENES[scene] | |
| # A transparent SNR proxy. It is intentionally not a clinical/laboratory | |
| # measurement; it creates a common acoustic state for the technology arms. | |
| unaided_snr = ( | |
| 8.0 | |
| - 7.0 * s["noise"] | |
| - 3.0 * s["reverb"] | |
| - 2.0 * s["distance"] | |
| - 2.0 * s["competing_speech"] | |
| - 2.5 * user.noise_difficulty | |
| ) | |
| return { | |
| "unaided_snr_db": float(unaided_snr), | |
| "target_azimuth": float(s["target_azimuth"]), | |
| "target_mobility": float(s["target_mobility"]), | |
| "competing_speech": float(s["competing_speech"]), | |
| "reverb": float(s["reverb"]), | |
| "distance": float(s["distance"]), | |
| "noise": float(s["noise"]), | |
| "spatial_difficulty": float(np.clip( | |
| 0.55 * s["spatial"] + 0.45 * user.spatial_difficulty, 0, 1 | |
| )), | |
| "noise_type": _scene_noise_type(scene), | |
| } | |
| def _psychometric_intelligibility(snr_db, hearing_loss): | |
| """ | |
| Logistic psychometric proxy. | |
| This is deliberately not labelled as a clinical speech-intelligibility | |
| function. Its purpose is to make SNR effects nonlinear: the same dB | |
| improvement can produce different percentage-point gains depending on | |
| starting difficulty. | |
| """ | |
| threshold = 1.5 + 5.0 * hearing_loss | |
| slope = 2.6 | |
| return float(1.0 / (1.0 + np.exp(-(snr_db - threshold) / slope))) | |
| def technology_effect(technology, scene, user): | |
| """ | |
| Evidence-informed layered model. | |
| Acoustic: | |
| potential SNR -> geometry -> reverberation -> movement -> scene type | |
| Functional: | |
| effective SNR -> nonlinear intelligibility -> listening access | |
| Experience: | |
| sound quality -> effort -> comfort/wearability/charging | |
| Technology values are simulation priors, not commercial specifications. | |
| """ | |
| tech = _technology_profile(technology) | |
| s = SCENES[scene] | |
| state = _acoustic_scene_state(scene, user) | |
| az = state["target_azimuth"] | |
| alignment = _angle_alignment(az) | |
| # Geometry: conventional directionality is strongest for frontal targets. | |
| geometry_factor = ( | |
| 0.30 | |
| + 0.70 * ( | |
| tech["off_axis_robustness"] | |
| + alignment * (1.0 - tech["off_axis_robustness"]) | |
| ) | |
| ) | |
| # Spatial opportunity is greatest when there is meaningful competing sound. | |
| spatial_opportunity = float(np.clip( | |
| 0.15 | |
| + 0.65 * s["noise"] | |
| + 0.35 * s["competing_speech"] | |
| + 0.15 * s["spatial"], | |
| 0, 1 | |
| )) | |
| # Reverberation reduces realised spatial separation. | |
| reverb_factor = float(np.clip( | |
| 1.0 - s["reverb"] * (1.0 - tech["reverb_robustness"]) * 0.85, | |
| 0.35, 1.0 | |
| )) | |
| mobility_factor = float(np.clip( | |
| 1.0 - s["target_mobility"] * (1.0 - tech["mobility_robustness"]) * 0.55, | |
| 0.40, 1.0 | |
| )) | |
| # DNNs are explicitly scene-dependent rather than universally superior. | |
| noise_type_factor = 1.0 | |
| if technology == "DNN beamformer": | |
| noise_type_factor = { | |
| "multi_talker": 1.05, | |
| "speech_dominant": 1.00, | |
| "environmental": 0.92, | |
| "low_competition": 0.65, | |
| }[state["noise_type"]] | |
| if technology == "Augmented listening glasses": | |
| # Hypothetical system: spatial access is a potential strength, but | |
| # placement/orientation and real-world coupling are uncertain. | |
| noise_type_factor = 0.92 if state["noise_type"] == "environmental" else 1.0 | |
| snr_gain_db = ( | |
| tech["potential_snr_db"] | |
| * spatial_opportunity | |
| * geometry_factor | |
| * reverb_factor | |
| * mobility_factor | |
| * noise_type_factor | |
| ) | |
| # Individual spatial difficulty makes complex scenes less predictable. | |
| snr_gain_db *= (0.92 - 0.15 * user.spatial_difficulty) | |
| # Unaided is a true zero-processing reference. | |
| if technology == "Unaided listening": | |
| snr_gain_db = 0.0 | |
| effective_snr_db = state["unaided_snr_db"] + snr_gain_db | |
| unaided_intelligibility = _psychometric_intelligibility( | |
| state["unaided_snr_db"], user.hearing_loss | |
| ) | |
| intelligibility = _psychometric_intelligibility( | |
| effective_snr_db, user.hearing_loss | |
| ) | |
| intelligibility_gain = float( | |
| np.clip(intelligibility - unaided_intelligibility, -1, 1) | |
| ) | |
| target_preservation = float(np.clip( | |
| tech["target_preservation"] | |
| - 0.12 * state["competing_speech"] * (1.0 - tech["competing_speech_robustness"]) | |
| - 0.08 * (1.0 - geometry_factor), | |
| 0, 1 | |
| )) | |
| spatial_robustness = float(np.clip( | |
| 0.35 | |
| + 0.40 * tech["off_axis_robustness"] | |
| + 0.25 * tech["mobility_robustness"] | |
| - 0.18 * state["spatial_difficulty"] * (1.0 - tech["off_axis_robustness"]), | |
| 0, 1 | |
| )) | |
| listening_access = float(np.clip( | |
| 0.55 * intelligibility | |
| + 0.20 * target_preservation | |
| + 0.15 * spatial_robustness | |
| + 0.10 * (1.0 - s["reverb"]), | |
| 0, 1 | |
| )) | |
| # Signal-quality trade-off: stronger processing can raise SNR while | |
| # introducing artefact / fidelity penalties. | |
| artefact_burden = float(np.clip( | |
| tech["processing_intensity"] | |
| * (0.35 + 0.65 * s["noise"]) | |
| * (1.05 - tech["naturalness"]) | |
| + 0.08 * tech["processing_intensity"] * s["competing_speech"], | |
| 0, 1 | |
| )) | |
| sound_quality = float(np.clip( | |
| tech["naturalness"] - artefact_burden, | |
| 0.35, 1.0 | |
| )) | |
| perceived_benefit = float(np.clip( | |
| 0.45 * listening_access | |
| + 0.25 * np.clip(0.50 + 1.6 * intelligibility_gain, 0, 1) | |
| + 0.15 * sound_quality | |
| + 0.15 * target_preservation, | |
| 0, 1 | |
| )) | |
| battery_burden = float(np.clip( | |
| (1.0 - tech["battery"]) | |
| * user.battery_sensitivity | |
| * (0.65 + 0.35 * s["noise"]), | |
| 0, 1 | |
| )) | |
| charging_burden = float(np.clip( | |
| battery_burden * (0.65 + 0.35 * tech["complexity"]), | |
| 0, 1 | |
| )) | |
| complexity_burden = float( | |
| tech["complexity"] * (1.0 - user.technology_tolerance) | |
| ) | |
| comfort = float(np.clip( | |
| 0.91 | |
| - 0.16 * complexity_burden | |
| - 0.12 * battery_burden | |
| - 0.18 * (1.0 - tech["wearability"]) * user.comfort_sensitivity | |
| - 0.12 * tech["visibility"] * user.visibility_sensitivity, | |
| 0.20, 0.98 | |
| )) | |
| wearability = float(np.clip( | |
| tech["wearability"] | |
| - 0.10 * user.comfort_sensitivity * (1.0 - tech["wearability"]) | |
| - 0.10 * tech["visibility"] * user.visibility_sensitivity, | |
| 0, 1 | |
| )) | |
| return { | |
| "benefit": perceived_benefit, | |
| "perceived_benefit": perceived_benefit, | |
| "snr_gain_db": float(max(0.0, snr_gain_db)), | |
| "effective_snr_db": float(effective_snr_db), | |
| "unaided_snr_db": float(state["unaided_snr_db"]), | |
| "intelligibility": float(np.clip(intelligibility, 0, 1)), | |
| "unaided_intelligibility": float(np.clip(unaided_intelligibility, 0, 1)), | |
| "intelligibility_gain": intelligibility_gain, | |
| "target_preservation": target_preservation, | |
| "spatial_robustness": spatial_robustness, | |
| "listening_access": listening_access, | |
| "sound_quality": sound_quality, | |
| "artefact_burden": artefact_burden, | |
| "comfort": comfort, | |
| "wearability": wearability, | |
| "battery_burden": battery_burden, | |
| "charging_burden": charging_burden, | |
| "complexity_burden": complexity_burden, | |
| "target_azimuth": az, | |
| "geometry_factor": float(geometry_factor), | |
| "reverb_factor": float(reverb_factor), | |
| "mobility_factor": float(mobility_factor), | |
| "noise_type": state["noise_type"], | |
| } | |
| def simulate_user_day( | |
| user, | |
| technology, | |
| scene, | |
| temperature=1.0, | |
| randomness=0.10 | |
| ): | |
| scene_data = SCENES[scene] | |
| effects = technology_effect(technology, scene, user) | |
| perceived_help = float(np.clip( | |
| effects["perceived_benefit"] | |
| * (0.65 + 0.45 * user.hearing_loss) | |
| * (0.70 + 0.30 * user.engagement), | |
| 0, 1 | |
| )) | |
| # Effort is driven by difficulty that remains after processing. | |
| listening_effort = float(np.clip( | |
| 0.48 * scene_data["noise"] * user.noise_difficulty | |
| + 0.22 * (1.0 - effects["intelligibility"]) | |
| + 0.12 * (1.0 - effects["spatial_robustness"]) | |
| + 0.08 * effects["artefact_burden"] | |
| + 0.10 * effects["complexity_burden"], | |
| 0, 1 | |
| )) | |
| fatigue = float(np.clip( | |
| 0.58 * listening_effort | |
| + 0.14 * (1.0 - effects["comfort"]) | |
| + 0.12 * (1.0 - effects["wearability"]) | |
| + 0.10 * effects["charging_burden"] | |
| + 0.06 * effects["artefact_burden"], | |
| 0, 1 | |
| )) | |
| social_participation = float(np.clip( | |
| 0.35 | |
| + 0.35 * perceived_help | |
| + 0.20 * user.social_confidence | |
| + 0.15 * effects["intelligibility"] | |
| - 0.35 * fatigue, | |
| 0, 1 | |
| )) | |
| # Daily use is behavioural: benefit helps, burden suppresses use. | |
| use_probability = ( | |
| user.baseline_use | |
| + 0.22 * perceived_help | |
| + 0.14 * effects["comfort"] | |
| + 0.10 * user.technology_tolerance | |
| - 0.16 * effects["charging_burden"] | |
| - 0.12 * effects["complexity_burden"] | |
| - 0.12 * user.visibility_sensitivity * (1.0 - effects["wearability"]) | |
| - 0.15 * fatigue | |
| ) | |
| if technology == "Unaided listening": | |
| use_probability = 1.0 | |
| use_probability = float(np.clip( | |
| use_probability / max(temperature, 0.05), 0, 1 | |
| )) | |
| used = int(np.random.random() < use_probability) | |
| # Approximate daily wear time. This is a behavioural simulation variable, | |
| # not a measured distribution for a specific device. | |
| if technology == "Unaided listening": | |
| wear_hours = 0.0 | |
| else: | |
| tech = _technology_profile(technology) | |
| expected_hours = ( | |
| 7.5 | |
| + 2.0 * use_probability | |
| + 0.8 * perceived_help | |
| - 1.5 * effects["charging_burden"] | |
| - 1.0 * fatigue | |
| ) | |
| max_hours = tech.get("charging_hours", 10.0) | |
| wear_hours = float(np.clip( | |
| expected_hours + np.random.normal(0, 0.65 * (1 + randomness)), | |
| 0, max_hours | |
| )) | |
| if not used: | |
| wear_hours *= 0.20 | |
| if not used and technology != "Unaided listening": | |
| return { | |
| "used": 0, | |
| "benefit": 0.0, | |
| "perceived_benefit": 0.0, | |
| "snr_gain_db": effects["snr_gain_db"], | |
| "effective_snr_db": effects["effective_snr_db"], | |
| "unaided_snr_db": effects["unaided_snr_db"], | |
| "intelligibility": effects["intelligibility"] * 0.65, | |
| "unaided_intelligibility": effects["unaided_intelligibility"], | |
| "intelligibility_gain": effects["intelligibility_gain"] * 0.65, | |
| "target_preservation": effects["target_preservation"], | |
| "spatial_robustness": effects["spatial_robustness"], | |
| "listening_access": effects["listening_access"] * 0.65, | |
| "sound_quality": effects["sound_quality"], | |
| "artefact_burden": effects["artefact_burden"], | |
| "comfort": effects["comfort"] * 0.85, | |
| "wearability": effects["wearability"] * 0.85, | |
| "battery_burden": effects["battery_burden"], | |
| "charging_burden": effects["charging_burden"], | |
| "complexity_burden": effects["complexity_burden"], | |
| "listening_effort": listening_effort, | |
| "fatigue": fatigue * 0.55, | |
| "social_participation": social_participation * 0.55, | |
| "listening_success": float(np.clip(0.35 * effects["intelligibility"], 0, 1)), | |
| "acceptance_change": -0.010 * fatigue, | |
| "wear_hours": wear_hours, | |
| "target_azimuth": effects["target_azimuth"], | |
| "geometry_factor": effects["geometry_factor"], | |
| } | |
| listening_success = float(np.clip( | |
| 0.12 | |
| + 0.58 * effects["intelligibility"] | |
| + 0.18 * effects["target_preservation"] | |
| + 0.12 * effects["spatial_robustness"] | |
| - 0.22 * listening_effort, | |
| 0, 1 | |
| )) | |
| acceptance_change = ( | |
| 0.020 * perceived_help | |
| + 0.012 * effects["comfort"] | |
| + 0.010 * effects["wearability"] | |
| + 0.006 * effects["sound_quality"] | |
| - 0.018 * fatigue | |
| - 0.010 * effects["charging_burden"] | |
| ) | |
| acceptance_change += np.random.normal( | |
| 0, randomness * 0.015 | |
| ) | |
| return { | |
| "used": 1, | |
| "benefit": perceived_help, | |
| "perceived_benefit": perceived_help, | |
| "snr_gain_db": effects["snr_gain_db"], | |
| "effective_snr_db": effects["effective_snr_db"], | |
| "unaided_snr_db": effects["unaided_snr_db"], | |
| "intelligibility": effects["intelligibility"], | |
| "unaided_intelligibility": effects["unaided_intelligibility"], | |
| "intelligibility_gain": effects["intelligibility_gain"], | |
| "target_preservation": effects["target_preservation"], | |
| "spatial_robustness": effects["spatial_robustness"], | |
| "listening_access": effects["listening_access"], | |
| "sound_quality": effects["sound_quality"], | |
| "artefact_burden": effects["artefact_burden"], | |
| "comfort": effects["comfort"], | |
| "wearability": effects["wearability"], | |
| "battery_burden": effects["battery_burden"], | |
| "charging_burden": effects["charging_burden"], | |
| "complexity_burden": effects["complexity_burden"], | |
| "listening_effort": listening_effort, | |
| "fatigue": fatigue, | |
| "social_participation": social_participation, | |
| "listening_success": listening_success, | |
| "acceptance_change": acceptance_change, | |
| "wear_hours": wear_hours, | |
| "target_azimuth": effects["target_azimuth"], | |
| "geometry_factor": effects["geometry_factor"], | |
| } | |
| # ============================================================ | |
| # MAIN SIMULATION | |
| # ============================================================ | |
| def generate_population(n_users, seed=42): | |
| """Create one synthetic population that can be reused across technology arms.""" | |
| np.random.seed(seed) | |
| random.seed(seed) | |
| return [create_user(i + 1) for i in range(int(n_users))] | |
| def run_simulation( | |
| n_users=20, | |
| days=14, | |
| technology="Adaptive directional", | |
| scenes=None, | |
| randomness=0.10, | |
| engagement_weight=0.65, | |
| social_weight=0.65, | |
| seed=42, | |
| users=None | |
| ): | |
| np.random.seed(seed) | |
| random.seed(seed) | |
| if not scenes: | |
| scenes = list(SCENES.keys()) | |
| if users is None: | |
| users = generate_population(n_users, seed) | |
| else: | |
| users = [SyntheticUser(**vars(u)) for u in users] | |
| records = [] | |
| for day in range(1, days + 1): | |
| for user in users: | |
| n_daily_scenes = min( | |
| len(scenes), | |
| max(2, int(len(scenes) * 0.45)) | |
| ) | |
| daily_scenes = random.sample(scenes, n_daily_scenes) | |
| day_results = [] | |
| for scene in daily_scenes: | |
| result = simulate_user_day( | |
| user=user, | |
| technology=technology, | |
| scene=scene, | |
| temperature=1.0 + (1.0 - engagement_weight) * 0.2, | |
| randomness=randomness | |
| ) | |
| user.acceptance = float(np.clip( | |
| user.acceptance + result["acceptance_change"], 0, 1 | |
| )) | |
| user.fatigue = float(np.clip( | |
| 0.65 * user.fatigue + 0.35 * result["fatigue"], 0, 1 | |
| )) | |
| user.cumulative_benefit += result["perceived_benefit"] | |
| day_results.append({"scene": scene, **result}) | |
| avg = pd.DataFrame(day_results).mean(numeric_only=True) | |
| user.engagement = float(np.clip( | |
| user.engagement | |
| + social_weight | |
| * (avg["social_participation"] - 0.5) | |
| * 0.03, | |
| 0, 1 | |
| )) | |
| records.append({ | |
| "day": day, | |
| "user": f"User {user.user_id}", | |
| "user_id": user.user_id, | |
| "technology": technology, | |
| "use": avg["used"], | |
| "wear_hours": avg["wear_hours"], | |
| "benefit": avg["perceived_benefit"], | |
| "perceived_benefit": avg["perceived_benefit"], | |
| "snr_gain_db": avg["snr_gain_db"], | |
| "unaided_snr_db": avg["unaided_snr_db"], | |
| "effective_snr_db": avg["effective_snr_db"], | |
| "intelligibility": avg["intelligibility"], | |
| "unaided_intelligibility": avg["unaided_intelligibility"], | |
| "intelligibility_gain": avg["intelligibility_gain"], | |
| "target_preservation": avg["target_preservation"], | |
| "spatial_robustness": avg["spatial_robustness"], | |
| "listening_access": avg["listening_access"], | |
| "sound_quality": avg["sound_quality"], | |
| "artefact_burden": avg["artefact_burden"], | |
| "listening_effort": avg["listening_effort"], | |
| "comfort": avg["comfort"], | |
| "wearability": avg["wearability"], | |
| "battery_burden": avg["battery_burden"], | |
| "charging_burden": avg["charging_burden"], | |
| "complexity_burden": avg["complexity_burden"], | |
| "fatigue": avg["fatigue"], | |
| "social_participation": avg["social_participation"], | |
| "listening_success": avg["listening_success"], | |
| "acceptance": user.acceptance, | |
| "engagement": user.engagement, | |
| "hearing_loss": user.hearing_loss, | |
| "noise_difficulty": user.noise_difficulty, | |
| "spatial_difficulty": user.spatial_difficulty, | |
| "technology_tolerance": user.technology_tolerance, | |
| "battery_sensitivity": user.battery_sensitivity, | |
| "visibility_sensitivity": user.visibility_sensitivity, | |
| "target_azimuth": avg["target_azimuth"], | |
| "geometry_factor": avg["geometry_factor"], | |
| }) | |
| return pd.DataFrame(records) | |
| # ============================================================ | |
| # METRICS | |
| # ============================================================ | |
| def summary_metrics(df): | |
| return { | |
| "use": float(df["use"].mean()), | |
| "benefit": float(df["perceived_benefit"].mean()), | |
| "comfort": float(df["comfort"].mean()), | |
| "fatigue": float(df["fatigue"].mean()), | |
| "engagement": float(df["engagement"].mean()), | |
| "acceptance": float(df["acceptance"].mean()), | |
| "success": float(df["listening_success"].mean()), | |
| "social": float(df["social_participation"].mean()), | |
| "snr_gain_db": float(df["snr_gain_db"].mean()), | |
| "intelligibility": float(df["intelligibility"].mean()), | |
| "intelligibility_gain": float(df["intelligibility_gain"].mean()), | |
| "target_preservation": float(df["target_preservation"].mean()), | |
| "spatial_robustness": float(df["spatial_robustness"].mean()), | |
| "listening_access": float(df["listening_access"].mean()), | |
| "wearability": float(df["wearability"].mean()), | |
| "charging_burden": float(df["charging_burden"].mean()), | |
| "effective_snr_db": float(df["effective_snr_db"].mean()), | |
| "listening_effort": float(df["listening_effort"].mean()), | |
| "sound_quality": float(df["sound_quality"].mean()), | |
| "artefact_burden": float(df["artefact_burden"].mean()), | |
| "wear_hours": float(df["wear_hours"].mean()), | |
| } | |
| def make_summary_table(df): | |
| m = summary_metrics(df) | |
| rows = [ | |
| ("Daily condition use", m["use"], "0β1"), | |
| ("Wear time (hours/day)", m["wear_hours"], "hours"), | |
| ("Perceived benefit", m["benefit"], "0β1"), | |
| ("Comfort", m["comfort"], "0β1"), | |
| ("Listening success", m["success"], "0β1"), | |
| ("Estimated intelligibility", m["intelligibility"], "0β1"), | |
| ("Intelligibility gain vs unaided", m["intelligibility_gain"], "percentage-point proxy"), | |
| ("SNR enhancement (dB)", m["snr_gain_db"], "dB"), | |
| ("Unaided SNR proxy (dB)", float(df["unaided_snr_db"].mean()), "model proxy"), | |
| ("Effective SNR (dB)", m["effective_snr_db"], "model proxy"), | |
| ("Target preservation", m["target_preservation"], "0β1"), | |
| ("Spatial robustness", m["spatial_robustness"], "0β1"), | |
| ("Listening access", m["listening_access"], "0β1"), | |
| ("Listening effort", m["listening_effort"], "0β1; lower is better"), | |
| ("Sound quality", m["sound_quality"], "0β1"), | |
| ("Artefact burden", m["artefact_burden"], "0β1; lower is better"), | |
| ("Social participation", m["social"], "0β1"), | |
| ("Fatigue", m["fatigue"], "0β1; lower is better"), | |
| ("Engagement", m["engagement"], "0β1"), | |
| ("Acceptance", m["acceptance"], "0β1"), | |
| ("Wearability", m["wearability"], "0β1"), | |
| ("Charging burden", m["charging_burden"], "0β1; lower is better"), | |
| ] | |
| return pd.DataFrame({ | |
| "Measure": [r[0] for r in rows], | |
| "Score": [round(float(r[1]), 3) for r in rows], | |
| "Interpretation": [r[2] for r in rows], | |
| }) | |
| # ============================================================ | |
| # RADAR / BEHAVIOURAL PROFILE | |
| # ============================================================ | |
| def radar_plot(df): | |
| """Create the behavioural profile radar chart used by the Overview tab. | |
| The radar deliberately uses downstream user-experience measures rather | |
| than mixing acoustic dB values with normalised behavioural scores. | |
| Lower-is-better measures such as fatigue and listening effort are inverted | |
| so that a larger radar value consistently represents a more favourable | |
| profile. | |
| """ | |
| m = summary_metrics(df) | |
| categories = [ | |
| "Use", | |
| "Perceived benefit", | |
| "Comfort", | |
| "Listening success", | |
| "Social participation", | |
| "Acceptance", | |
| "Engagement", | |
| "Low fatigue", | |
| "Low effort", | |
| "Wearability", | |
| ] | |
| values = [ | |
| m["use"], | |
| m["benefit"], | |
| m["comfort"], | |
| m["success"], | |
| m["social"], | |
| m["acceptance"], | |
| m["engagement"], | |
| 1.0 - m["fatigue"], | |
| 1.0 - m["listening_effort"], | |
| m["wearability"], | |
| ] | |
| # Close the polygon. | |
| theta = categories + [categories[0]] | |
| r = values + [values[0]] | |
| fig = go.Figure() | |
| fig.add_trace( | |
| go.Scatterpolar( | |
| r=r, | |
| theta=theta, | |
| fill="toself", | |
| name=str(df["technology"].iloc[0]), | |
| hovertemplate="%{theta}: %{r:.0%}<extra></extra>", | |
| ) | |
| ) | |
| fig.update_layout( | |
| title="Behavioural and user-experience profile", | |
| polar=dict( | |
| radialaxis=dict( | |
| visible=True, | |
| range=[0, 1], | |
| tickformat=".0f", | |
| ) | |
| ), | |
| showlegend=False, | |
| margin=dict(l=55, r=55, t=70, b=55), | |
| ) | |
| return fig | |
| # ============================================================ | |
| # TREND | |
| # ============================================================ | |
| def trend_plot(df): | |
| grouped = ( | |
| df.groupby("day") | |
| .agg({ | |
| "use": "mean", | |
| "perceived_benefit": "mean", | |
| "comfort": "mean", | |
| "fatigue": "mean", | |
| "engagement": "mean", | |
| "acceptance": "mean", | |
| "listening_success": "mean", | |
| "intelligibility": "mean", | |
| "listening_effort": "mean", | |
| "wear_hours": "mean", | |
| }) | |
| .reset_index() | |
| ) | |
| long = grouped.melt( | |
| id_vars="day", | |
| var_name="Measure", | |
| value_name="Value" | |
| ) | |
| label_map = { | |
| "use": "Use", | |
| "perceived_benefit": "Perceived benefit", | |
| "comfort": "Comfort", | |
| "fatigue": "Fatigue", | |
| "engagement": "Engagement", | |
| "acceptance": "Acceptance", | |
| "listening_success": "Listening success", | |
| "intelligibility": "Intelligibility", | |
| "listening_effort": "Listening effort", | |
| "wear_hours": "Wear time (h/day)", | |
| } | |
| long["Measure"] = long["Measure"].map(label_map) | |
| fig = px.line( | |
| long, x="day", y="Value", color="Measure", | |
| markers=True, title="Behaviour and functional outcomes over time" | |
| ) | |
| fig.update_yaxes(range=[0, 1], title="Normalised score") | |
| fig.update_xaxes(title="Simulation day") | |
| return fig | |
| # ============================================================ | |
| # SCENE ANALYSIS | |
| # ============================================================ | |
| def scene_plot(df): | |
| technology = df["technology"].iloc[0] | |
| rows = [] | |
| n_users = min(100, df["user_id"].nunique()) | |
| users = generate_population(n_users, seed=123) | |
| for scene in SCENES: | |
| values = [ | |
| simulate_user_day( | |
| user=user, | |
| technology=technology, | |
| scene=scene, | |
| randomness=0 | |
| ) | |
| for user in users | |
| ] | |
| temp = pd.DataFrame(values) | |
| rows.append({ | |
| "Scene": scene, | |
| "Listening success": temp["listening_success"].mean(), | |
| "Intelligibility": temp["intelligibility"].mean(), | |
| "Comfort": temp["comfort"].mean(), | |
| "Social participation": temp["social_participation"].mean(), | |
| "Fatigue": temp["fatigue"].mean(), | |
| "SNR gain (normalised)": np.clip( | |
| temp["snr_gain_db"].mean() / 10.0, 0, 1 | |
| ), | |
| }) | |
| scene_df = pd.DataFrame(rows) | |
| fig = px.bar( | |
| scene_df, | |
| x="Scene", | |
| y=[ | |
| "Listening success", | |
| "Intelligibility", | |
| "Comfort", | |
| "Social participation" | |
| ], | |
| barmode="group", | |
| title=f"Expected experience across scenes β {technology}" | |
| ) | |
| fig.update_yaxes(range=[0, 1]) | |
| return fig | |
| # ============================================================ | |
| # INDIVIDUAL VARIABILITY | |
| # ============================================================ | |
| def variability_plot(df): | |
| latest = ( | |
| df.sort_values("day") | |
| .groupby("user_id") | |
| .tail(1) | |
| ) | |
| fig = px.scatter( | |
| latest, | |
| x="noise_difficulty", | |
| y="acceptance", | |
| size="engagement", | |
| hover_data=[ | |
| "user", | |
| "hearing_loss", | |
| "spatial_difficulty", | |
| "technology_tolerance" | |
| ], | |
| title="Individual variability" | |
| ) | |
| fig.update_xaxes( | |
| title="Noise difficulty" | |
| ) | |
| fig.update_yaxes( | |
| title="Technology acceptance", | |
| range=[0, 1] | |
| ) | |
| return fig | |
| # ============================================================ | |
| # HEATMAP | |
| # ============================================================ | |
| def heatmap_plot(df): | |
| pivot = ( | |
| df.groupby( | |
| ["user", "day"] | |
| )["perceived_benefit"] | |
| .mean() | |
| .unstack() | |
| ) | |
| fig = px.imshow( | |
| pivot, | |
| aspect="auto", | |
| title="Individual perceived-benefit trajectories" | |
| ) | |
| return fig | |
| # ============================================================ | |
| # TECHNOLOGY COMPARISON | |
| # ============================================================ | |
| def run_comparison( | |
| n_users, | |
| days, | |
| technologies, | |
| scenes, | |
| randomness, | |
| seed, | |
| reference_condition="Unaided listening" | |
| ): | |
| """ | |
| Paired comparison: one synthetic population is generated once and | |
| reused across all arms. This prevents population composition from | |
| being confounded with technology condition. | |
| """ | |
| if not scenes: | |
| scenes = list(SCENES.keys()) | |
| population = generate_population(int(n_users), int(seed)) | |
| conditions = [reference_condition] + [ | |
| t for t in technologies if t != reference_condition | |
| ] | |
| results = [] | |
| for condition in conditions: | |
| df = run_simulation( | |
| n_users=n_users, | |
| days=days, | |
| technology=condition, | |
| scenes=scenes, | |
| randomness=randomness, | |
| seed=seed, | |
| users=population | |
| ) | |
| df["arm"] = condition | |
| df["reference_condition"] = reference_condition | |
| df["is_reference"] = condition == reference_condition | |
| results.append(df) | |
| return pd.concat(results, ignore_index=True) | |
| def comparison_effect_table(df, reference_condition): | |
| metric_specs = [ | |
| ("Listening success", "listening_success"), | |
| ("Estimated intelligibility", "intelligibility"), | |
| ("Intelligibility gain vs unaided", "intelligibility_gain"), | |
| ("SNR enhancement (dB)", "snr_gain_db"), | |
| ("Comfort", "comfort"), | |
| ("Wearability", "wearability"), | |
| ("Listening access", "listening_access"), | |
| ("Target preservation", "target_preservation"), | |
| ("Spatial robustness", "spatial_robustness"), | |
| ("Social participation", "social_participation"), | |
| ("Fatigue", "fatigue"), | |
| ("Engagement", "engagement"), | |
| ("Acceptance", "acceptance"), | |
| ("Charging burden", "charging_burden"), | |
| ] | |
| grouped = df.groupby("technology") | |
| reference = grouped.mean(numeric_only=True).loc[reference_condition] | |
| rows = [] | |
| for technology in grouped.groups: | |
| mean_row = grouped.mean(numeric_only=True).loc[technology] | |
| row = { | |
| "Technology": technology, | |
| "Reference": reference_condition, | |
| "Same as reference": technology == reference_condition, | |
| } | |
| for label, col in metric_specs: | |
| if technology == reference_condition: | |
| row[f"{label} β absolute"] = round(float(mean_row[col]), 3) | |
| row[f"{label} β effect"] = 0.0 | |
| else: | |
| row[f"{label} β absolute"] = round(float(mean_row[col]), 3) | |
| row[f"{label} β effect"] = round( | |
| float(mean_row[col] - reference[col]), 3 | |
| ) | |
| rows.append(row) | |
| return pd.DataFrame(rows) | |
| def comparison_plot(df, reference_condition="Unaided listening"): | |
| comparison = ( | |
| df.groupby("technology") | |
| .agg({ | |
| "listening_success": "mean", | |
| "intelligibility": "mean", | |
| "comfort": "mean", | |
| "social_participation": "mean", | |
| "fatigue": "mean", | |
| "acceptance": "mean" | |
| }) | |
| .reset_index() | |
| ) | |
| long = comparison.melt( | |
| id_vars="technology", | |
| var_name="Measure", | |
| value_name="Value" | |
| ) | |
| label_map = { | |
| "listening_success": "Listening success", | |
| "intelligibility": "Intelligibility", | |
| "comfort": "Comfort", | |
| "social_participation": "Social participation", | |
| "fatigue": "Fatigue", | |
| "acceptance": "Acceptance", | |
| } | |
| long["Measure"] = long["Measure"].map(label_map) | |
| fig = px.bar( | |
| long, | |
| x="technology", | |
| y="Value", | |
| color="Measure", | |
| barmode="group", | |
| title=f"Technology comparison β reference: {reference_condition}" | |
| ) | |
| fig.update_yaxes(range=[0, 1]) | |
| return fig | |
| # ============================================================ | |
| # ZERO GPU LLM | |
| # ============================================================ | |
| ANALYST_MODEL = None | |
| ANALYST_TOKENIZER = None | |
| def gpu_analyst( | |
| summary_text, | |
| technology, | |
| evidence_text | |
| ): | |
| global ANALYST_MODEL | |
| global ANALYST_TOKENIZER | |
| try: | |
| from transformers import ( | |
| AutoTokenizer, | |
| AutoModelForSeq2SeqLM | |
| ) | |
| model_name = "google/flan-t5-small" | |
| if ANALYST_MODEL is None: | |
| ANALYST_TOKENIZER = ( | |
| AutoTokenizer.from_pretrained( | |
| model_name | |
| ) | |
| ) | |
| ANALYST_MODEL = ( | |
| AutoModelForSeq2SeqLM.from_pretrained( | |
| model_name | |
| ) | |
| ) | |
| ANALYST_MODEL.eval() | |
| prompt = f""" | |
| You are a research assistant analysing a synthetic | |
| hearing technology behaviour simulation. | |
| Technology: | |
| {technology} | |
| Simulation summary: | |
| {summary_text} | |
| Evidence context: | |
| {evidence_text} | |
| Provide a concise research interpretation. | |
| Structure your response as: | |
| 1. Simulation finding | |
| 2. Possible behavioural explanation | |
| 3. Important limitation | |
| 4. Suggested next experiment | |
| Do not claim that these results predict real patients. | |
| """ | |
| inputs = ANALYST_TOKENIZER( | |
| prompt, | |
| return_tensors="pt", | |
| truncation=True, | |
| max_length=512 | |
| ) | |
| outputs = ANALYST_MODEL.generate( | |
| **inputs, | |
| max_new_tokens=180, | |
| do_sample=False | |
| ) | |
| return ANALYST_TOKENIZER.decode( | |
| outputs[0], | |
| skip_special_tokens=True | |
| ) | |
| except Exception as e: | |
| return ( | |
| "### AI analyst unavailable\n\n" | |
| "The ZeroGPU allocation could not complete " | |
| "the LLM analysis.\n\n" | |
| f"Technical message: `{e}`" | |
| ) | |
| # ============================================================ | |
| # EXPERIMENT CALLBACK | |
| # ============================================================ | |
| def run_experiment( | |
| n_users, | |
| days, | |
| technology, | |
| scenes, | |
| randomness, | |
| engagement_weight, | |
| social_weight, | |
| seed | |
| ): | |
| if not scenes: | |
| scenes = list(SCENES.keys()) | |
| df = run_simulation( | |
| n_users=int(n_users), | |
| days=int(days), | |
| technology=technology, | |
| scenes=scenes, | |
| randomness=float(randomness), | |
| engagement_weight=float(engagement_weight), | |
| social_weight=float(social_weight), | |
| seed=int(seed) | |
| ) | |
| m = summary_metrics(df) | |
| headline = f""" | |
| ### Experiment complete | |
| **{int(n_users)} synthetic users** Β· **{int(days)} days** Β· | |
| **{technology}** Β· **{len(scenes)} environments** | |
| The simulation generated **{len(df):,} user-day observations**. | |
| **Acoustic β functional β behavioural chain:** mean SNR enhancement | |
| **{m['snr_gain_db']:.1f} dB** Β· estimated intelligibility | |
| **{m['intelligibility']:.0%}** Β· listening success | |
| **{m['success']:.0%}**. | |
| Perceived benefit is a downstream experience measure, not a direct | |
| SNR or intelligibility measurement. | |
| Results describe synthetic behaviour under the assumptions of this model. | |
| They should not be interpreted as predictions of real users. | |
| """ | |
| return ( | |
| df, | |
| df.round(3), | |
| make_summary_table(df), | |
| f"""<div class="kpi"><div class="kpi-label">Daily use</div> | |
| <div class="kpi-value">{m['use']:.0%}</div></div>""", | |
| f"""<div class="kpi"><div class="kpi-label">Perceived benefit</div> | |
| <div class="kpi-value">{m['benefit']:.0%}</div></div>""", | |
| f"""<div class="kpi"><div class="kpi-label">Comfort</div> | |
| <div class="kpi-value">{m['comfort']:.0%}</div></div>""", | |
| f"""<div class="kpi"><div class="kpi-label">Fatigue</div> | |
| <div class="kpi-value">{m['fatigue']:.0%}</div></div>""", | |
| f"""<div class="kpi"><div class="kpi-label">Engagement</div> | |
| <div class="kpi-value">{m['engagement']:.0%}</div></div>""", | |
| f"""<div class="kpi"><div class="kpi-label">Acceptance</div> | |
| <div class="kpi-value">{m['acceptance']:.0%}</div></div>""", | |
| f"""<div class="kpi"><div class="kpi-label">Listening success</div> | |
| <div class="kpi-value">{m['success']:.0%}</div></div>""", | |
| f"""<div class="kpi"><div class="kpi-label">Social participation</div> | |
| <div class="kpi-value">{m['social']:.0%}</div></div>""", | |
| radar_plot(df), | |
| trend_plot(df), | |
| scene_plot(df), | |
| variability_plot(df), | |
| heatmap_plot(df), | |
| headline | |
| ) | |
| # ============================================================ | |
| # COMPARISON CALLBACK | |
| # ============================================================ | |
| def run_comparison_ui( | |
| n_users, | |
| days, | |
| technologies, | |
| scenes, | |
| randomness, | |
| seed, | |
| reference_condition | |
| ): | |
| if not technologies: | |
| return None, None, "Select at least one experimental technology." | |
| if not scenes: | |
| scenes = list(SCENES.keys()) | |
| df = run_comparison( | |
| int(n_users), | |
| int(days), | |
| technologies, | |
| scenes, | |
| float(randomness), | |
| int(seed), | |
| reference_condition=reference_condition | |
| ) | |
| table = comparison_effect_table(df, reference_condition) | |
| same = [ | |
| t for t in technologies if t == reference_condition | |
| ] | |
| if same: | |
| status = ( | |
| f"**Reference:** {reference_condition}. " | |
| "The same technology is included as an arm; its technology " | |
| "effect is fixed at 0 by definition." | |
| ) | |
| else: | |
| status = ( | |
| f"**Comparison:** {', '.join(technologies)} " | |
| f"vs **{reference_condition}**. " | |
| "Effects are paired against the same synthetic population." | |
| ) | |
| return ( | |
| table, | |
| comparison_plot(df, reference_condition), | |
| status | |
| ) | |
| # ============================================================ | |
| # AI ANALYST CALLBACK | |
| # ============================================================ | |
| def ask_ai( | |
| df, | |
| technology | |
| ): | |
| if df is None: | |
| return ( | |
| """ | |
| ### No simulation available | |
| Run an experiment first. | |
| The AI analyst needs the simulation results before | |
| it can interpret anything. | |
| """ | |
| ) | |
| summary = ( | |
| make_summary_table(df) | |
| .to_string(index=False) | |
| ) | |
| evidence = "\n".join( | |
| f"- {v['label']}: " | |
| f"{v['description']} " | |
| f"({v['source']})" | |
| for v in EVIDENCE.values() | |
| ) | |
| return gpu_analyst( | |
| summary, | |
| technology, | |
| evidence | |
| ) | |
| # ============================================================ | |
| # CSS | |
| # ============================================================ | |
| CSS = """ | |
| /* ============================================================ | |
| GLOBAL | |
| ============================================================ */ | |
| body { | |
| background: #f4f7fb !important; | |
| } | |
| .gradio-container { | |
| max-width: 1480px !important; | |
| margin: auto !important; | |
| } | |
| /* ============================================================ | |
| HEADER | |
| ============================================================ */ | |
| .lab-header { | |
| background: | |
| linear-gradient( | |
| 135deg, | |
| #0f2747 0%, | |
| #173e68 55%, | |
| #245b88 100% | |
| ); | |
| border-radius: 18px; | |
| padding: 30px 34px; | |
| margin-bottom: 20px; | |
| color: white; | |
| box-shadow: | |
| 0 8px 28px | |
| rgba( | |
| 15, | |
| 39, | |
| 71, | |
| 0.16 | |
| ); | |
| } | |
| .lab-header h1 { | |
| font-size: 36px !important; | |
| font-weight: 750 !important; | |
| color: #ffffff !important; | |
| margin: | |
| 0 0 10px 0 !important; | |
| line-height: 1.15 !important; | |
| } | |
| .lab-header p { | |
| margin: | |
| 0 !important; | |
| font-size: 17px !important; | |
| font-weight: 400 !important; | |
| color: #f4f8fc !important; | |
| opacity: 1 !important; | |
| max-width: 950px !important; | |
| line-height: 1.55 !important; | |
| } | |
| .lab-author { | |
| margin-top: 16px !important; | |
| font-size: 15px !important; | |
| font-weight: 600 !important; | |
| color: #ffffff !important; | |
| opacity: 1 !important; | |
| letter-spacing: 0.01em; | |
| } | |
| /* ============================================================ | |
| SECTION CARDS | |
| ============================================================ */ | |
| .card { | |
| background: white; | |
| border: | |
| 1px solid #e1e7ef; | |
| border-radius: 15px; | |
| padding: 20px; | |
| box-shadow: | |
| 0 3px 12px | |
| rgba( | |
| 15, | |
| 39, | |
| 71, | |
| 0.05 | |
| ); | |
| } | |
| .card-title { | |
| font-size: 19px; | |
| font-weight: 650; | |
| color: #15324f; | |
| margin-bottom: 5px; | |
| } | |
| .card-subtitle { | |
| color: #65758b; | |
| font-size: 13px; | |
| margin-bottom: 16px; | |
| } | |
| /* ============================================================ | |
| KPI CARDS | |
| ============================================================ */ | |
| .kpi { | |
| background: white; | |
| border: | |
| 1px solid #e1e7ef; | |
| border-radius: 14px; | |
| padding: 16px; | |
| min-height: 92px; | |
| box-shadow: | |
| 0 3px 12px | |
| rgba( | |
| 15, | |
| 39, | |
| 71, | |
| 0.04 | |
| ); | |
| } | |
| .kpi-label { | |
| font-size: 12px; | |
| color: #708096; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .kpi-value { | |
| font-size: 27px; | |
| font-weight: 700; | |
| color: #15324f; | |
| margin-top: 6px; | |
| } | |
| /* ============================================================ | |
| WORKFLOW | |
| ============================================================ */ | |
| .workflow { | |
| display: flex; | |
| gap: 10px; | |
| margin: | |
| 4px 0 20px 0; | |
| } | |
| .workflow-step { | |
| flex: 1; | |
| background: white; | |
| border: | |
| 1px solid #dfe6ee; | |
| border-radius: 11px; | |
| padding: 12px 15px; | |
| } | |
| .workflow-number { | |
| font-size: 11px; | |
| color: #3f6f9f; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| } | |
| .workflow-title { | |
| font-size: 14px; | |
| font-weight: 650; | |
| color: #183653; | |
| } | |
| /* ============================================================ | |
| HELP BOX | |
| ============================================================ */ | |
| .help-box { | |
| background: #eef5fb; | |
| border-left: | |
| 4px solid #3974a8; | |
| border-radius: 8px; | |
| padding: 13px 15px; | |
| color: #34536f; | |
| font-size: 13px; | |
| margin: | |
| 10px 0 16px 0; | |
| } | |
| /* ============================================================ | |
| AI STATUS | |
| ============================================================ */ | |
| .ai-status-card { | |
| background: #f7fafc; | |
| border: | |
| 1px solid #dce5ee; | |
| border-radius: 12px; | |
| padding: 16px 18px; | |
| min-height: 100px; | |
| } | |
| .ai-status-title { | |
| font-size: 11px; | |
| font-weight: 700; | |
| letter-spacing: 0.08em; | |
| color: #6b7c90; | |
| margin-bottom: 5px; | |
| } | |
| .ai-status-main { | |
| font-size: 17px; | |
| font-weight: 700; | |
| color: #173e68; | |
| margin-bottom: 5px; | |
| } | |
| .ai-status-description { | |
| font-size: 12px; | |
| line-height: 1.45; | |
| color: #68798c; | |
| } | |
| /* ============================================================ | |
| DISCLAIMER | |
| ============================================================ */ | |
| .disclaimer { | |
| background: #f8fafc; | |
| border: | |
| 1px solid #dce3eb; | |
| border-radius: 10px; | |
| padding: 12px 15px; | |
| font-size: 12px; | |
| color: #64748b; | |
| } | |
| /* ============================================================ | |
| BUTTONS | |
| ============================================================ */ | |
| .primary-button { | |
| min-height: 48px !important; | |
| font-size: 15px !important; | |
| font-weight: 650 !important; | |
| } | |
| /* ============================================================ | |
| FOOTER | |
| ============================================================ */ | |
| .footer { | |
| text-align: center; | |
| color: #8290a1; | |
| font-size: 12px; | |
| padding: | |
| 22px 0 8px 0; | |
| } | |
| /* ============================================================ | |
| MOBILE | |
| ============================================================ */ | |
| @media (max-width: 800px) { | |
| .lab-header { | |
| padding: 24px; | |
| } | |
| .lab-header h1 { | |
| font-size: 28px !important; | |
| } | |
| .lab-header p { | |
| font-size: 15px !important; | |
| } | |
| .workflow { | |
| flex-direction: column; | |
| } | |
| } | |
| """ | |
| # ============================================================ | |
| # USER INTERFACE | |
| # ============================================================ | |
| with gr.Blocks( | |
| title=APP_TITLE | |
| ) as demo: | |
| # ======================================================== | |
| # HEADER | |
| # ======================================================== | |
| gr.HTML( | |
| """ | |
| <div class="lab-header"> | |
| <h1> | |
| Hearing Technology Behaviour Lab | |
| </h1> | |
| <p> | |
| A synthetic-world laboratory for exploring how | |
| hearing technology, everyday environments and | |
| individual characteristics interact to shape | |
| technology use and behavioural experience. | |
| </p> | |
| <div class="lab-author"> | |
| An independent research project by Jorge Mejia, PhD | |
| </div> | |
| </div> | |
| """ | |
| ) | |
| # ======================================================== | |
| # WORKFLOW | |
| # ======================================================== | |
| gr.HTML( | |
| """ | |
| <div class="workflow"> | |
| <div class="workflow-step"> | |
| <div class="workflow-number"> | |
| Step 1 | |
| </div> | |
| <div class="workflow-title"> | |
| Build your population | |
| </div> | |
| </div> | |
| <div class="workflow-step"> | |
| <div class="workflow-number"> | |
| Step 2 | |
| </div> | |
| <div class="workflow-title"> | |
| Create the listening world | |
| </div> | |
| </div> | |
| <div class="workflow-step"> | |
| <div class="workflow-number"> | |
| Step 3 | |
| </div> | |
| <div class="workflow-title"> | |
| Run & explore | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| ) | |
| # ======================================================== | |
| # EXPERIMENT DESIGN | |
| # ======================================================== | |
| with gr.Row(): | |
| with gr.Column( | |
| scale=1, | |
| elem_classes="card" | |
| ): | |
| gr.HTML( | |
| """ | |
| <div class="card-title"> | |
| Experiment design | |
| </div> | |
| <div class="card-subtitle"> | |
| Define your virtual population and technology. | |
| </div> | |
| """ | |
| ) | |
| n_users = gr.Slider( | |
| minimum=5, | |
| maximum=100, | |
| value=20, | |
| step=5, | |
| label="Synthetic population", | |
| info="Number of virtual users" | |
| ) | |
| days = gr.Slider( | |
| minimum=3, | |
| maximum=60, | |
| value=14, | |
| step=1, | |
| label="Simulation duration", | |
| info="Number of simulated days" | |
| ) | |
| technology = gr.Dropdown( | |
| choices=list( | |
| TECHNOLOGIES.keys() | |
| ), | |
| value="Adaptive directional", | |
| label="Primary technology" | |
| ) | |
| gr.Markdown( | |
| """ | |
| **Single-arm simulation** | |
| This experiment reports absolute acoustic, functional and | |
| behavioural outcomes. A technology effect is only calculated | |
| when you run a comparison with an explicit reference condition. | |
| """ | |
| ) | |
| gr.Markdown( | |
| """ | |
| **Technology hypothesis** | |
| Technology parameters are simplified, | |
| engineering-inspired abstractions. They are | |
| not specifications for commercial devices. | |
| """ | |
| ) | |
| with gr.Accordion( | |
| "Advanced behavioural settings", | |
| open=False | |
| ): | |
| randomness = gr.Slider( | |
| 0, | |
| 0.30, | |
| value=0.10, | |
| step=0.01, | |
| label="Behavioural randomness", | |
| info="Higher values increase individual variation." | |
| ) | |
| engagement_weight = gr.Slider( | |
| 0.1, | |
| 1.0, | |
| value=0.65, | |
| step=0.05, | |
| label="Engagement sensitivity" | |
| ) | |
| social_weight = gr.Slider( | |
| 0.1, | |
| 1.0, | |
| value=0.65, | |
| step=0.05, | |
| label="Social participation sensitivity" | |
| ) | |
| seed = gr.Number( | |
| value=42, | |
| precision=0, | |
| label="Random seed" | |
| ) | |
| # ==================================================== | |
| # WORLD | |
| # ==================================================== | |
| with gr.Column( | |
| scale=2, | |
| elem_classes="card" | |
| ): | |
| gr.HTML( | |
| """ | |
| <div class="card-title"> | |
| Build the everyday world | |
| </div> | |
| <div class="card-subtitle"> | |
| Choose the situations your synthetic users | |
| encounter during the simulation. | |
| </div> | |
| """ | |
| ) | |
| scenes = gr.CheckboxGroup( | |
| choices=list( | |
| SCENES.keys() | |
| ), | |
| value=list( | |
| SCENES.keys() | |
| ), | |
| label="Listening environments" | |
| ) | |
| gr.HTML( | |
| """ | |
| <div class="help-box"> | |
| <strong>Why scenes matter</strong><br> | |
| A technology is not experienced in a vacuum. | |
| The same user can have a very different experience | |
| in a quiet conversation, restaurant, meeting or | |
| outdoor environment. | |
| The model therefore treats context as a core part | |
| of the simulation. | |
| </div> | |
| """ | |
| ) | |
| gr.Markdown( | |
| """ | |
| ### How the simulation works | |
| Each synthetic user encounters a sample of the selected | |
| environments. The model first estimates **scene-dependent | |
| acoustic performance** β including SNR enhancement, target | |
| preservation and spatial robustness β then translates that | |
| into estimated intelligibility, listening success and | |
| behavioural experience. | |
| **Target location matters:** a directional technology can | |
| perform differently when the target moves off-axis. | |
| """ | |
| ) | |
| run_button = gr.Button( | |
| "Run behavioural simulation", | |
| variant="primary", | |
| elem_classes="primary-button" | |
| ) | |
| # ======================================================== | |
| # STATUS | |
| # ======================================================== | |
| status = gr.Markdown() | |
| # ======================================================== | |
| # TABS | |
| # ======================================================== | |
| with gr.Tabs(): | |
| # ==================================================== | |
| # OVERVIEW | |
| # ==================================================== | |
| with gr.Tab("Overview"): | |
| gr.Markdown( | |
| """ | |
| ## Population overview | |
| These headline measures summarise the simulated population. | |
| Most behavioural scores are 0β1; acoustic outcomes such as SNR | |
| enhancement are reported in dB and wear time in hours/day. | |
| """ | |
| ) | |
| with gr.Row(): | |
| kpi_use = gr.HTML() | |
| kpi_benefit = gr.HTML() | |
| kpi_comfort = gr.HTML() | |
| kpi_fatigue = gr.HTML() | |
| with gr.Row(): | |
| kpi_engagement = gr.HTML() | |
| kpi_acceptance = gr.HTML() | |
| kpi_success = gr.HTML() | |
| kpi_social = gr.HTML() | |
| gr.Markdown( | |
| "### Behavioural profile" | |
| ) | |
| with gr.Row(): | |
| radar = gr.Plot() | |
| trend = gr.Plot() | |
| gr.Markdown( | |
| """ | |
| ### How to interpret this | |
| The goal is not to identify a single "best" | |
| technology. | |
| Look for **trade-offs and heterogeneous responses**. | |
| A technology may improve listening success while | |
| increasing fatigue or reducing comfort for some | |
| synthetic users. | |
| """ | |
| ) | |
| # ==================================================== | |
| # CONTEXT | |
| # ==================================================== | |
| with gr.Tab("Context & behaviour"): | |
| gr.Markdown( | |
| """ | |
| ## The simulated world | |
| Explore how the technology behaves across different | |
| everyday contexts and how much variation exists | |
| between individuals. | |
| """ | |
| ) | |
| with gr.Row(): | |
| scene_chart = gr.Plot() | |
| variability_chart = gr.Plot() | |
| gr.Markdown( | |
| "### Individual benefit trajectories" | |
| ) | |
| heatmap = gr.Plot() | |
| # ==================================================== | |
| # POPULATION | |
| # ==================================================== | |
| with gr.Tab("Synthetic population"): | |
| gr.Markdown( | |
| """ | |
| ## User-day observations | |
| Each row represents one synthetic user on one | |
| simulated day. | |
| The population is deliberately heterogeneous. | |
| Two users exposed to the same technology can behave | |
| differently because their simulated characteristics | |
| differ. | |
| """ | |
| ) | |
| population_table = gr.Dataframe( | |
| interactive=False, | |
| wrap=True, | |
| max_height=520 | |
| ) | |
| gr.Markdown( | |
| "### Population summary" | |
| ) | |
| summary_table_output = gr.Dataframe( | |
| interactive=False | |
| ) | |
| # ==================================================== | |
| # COMPARISON | |
| # ==================================================== | |
| with gr.Tab("Technology comparison"): | |
| gr.Markdown( | |
| """ | |
| ## Multi-arm experiment | |
| Put different technologies into parallel synthetic | |
| populations and compare their behavioural profiles. | |
| This is where the application starts behaving more | |
| like an experimental laboratory. | |
| """ | |
| ) | |
| reference_condition = gr.Dropdown( | |
| choices=REFERENCE_CONDITIONS, | |
| value="Unaided listening", | |
| label="Reference condition", | |
| info="The condition against which technology effects are calculated." | |
| ) | |
| comparison_technologies = gr.CheckboxGroup( | |
| choices=list(TECHNOLOGIES.keys()), | |
| value=[ | |
| "Omnidirectional", | |
| "Adaptive directional", | |
| "Binaural beamformer", | |
| "DNN beamformer" | |
| ], | |
| label="Experimental technology arms", | |
| info="Select one or more technologies to compare with the reference." | |
| ) | |
| gr.Markdown( | |
| """ | |
| **Comparison logic** | |
| `Technology effect = experimental outcome β reference outcome` | |
| If the same condition is selected on both sides, its technology | |
| effect is **0 by definition**. The same synthetic population is | |
| reused across arms so population composition is not confounded | |
| with technology. | |
| """ | |
| ) | |
| comparison_button = gr.Button( | |
| "Run technology comparison", | |
| variant="primary" | |
| ) | |
| comparison_status = gr.Markdown() | |
| comparison_table_output = gr.Dataframe( | |
| interactive=False | |
| ) | |
| comparison_chart_output = gr.Plot() | |
| # ==================================================== | |
| # AI ANALYST | |
| # ==================================================== | |
| with gr.Tab("AI analyst"): | |
| gr.Markdown( | |
| """ | |
| ## AI research analyst | |
| The AI analyst examines the results of your simulation | |
| and turns them into a short research-oriented | |
| interpretation. | |
| **The simulation comes first. The LLM comes second.** | |
| The LLM does not create the synthetic users or determine | |
| their behaviour. It receives the completed simulation, | |
| selected technology, and evidence context, then | |
| interprets the observed patterns. | |
| """ | |
| ) | |
| gr.HTML( | |
| """ | |
| <div class="help-box"> | |
| <strong>What happens when you click the button?</strong> | |
| <ol style="margin-top:8px; margin-bottom:4px;"> | |
| <li> | |
| <strong>Simulation results are collected</strong> | |
| from the experiment you just ran. | |
| </li> | |
| <li> | |
| <strong>Evidence context is supplied</strong> | |
| covering behavioural dimensions such as comfort, | |
| listening effort, social participation and use. | |
| </li> | |
| <li> | |
| <strong>A small LLM is activated on ZeroGPU</strong> | |
| to analyse the results. | |
| </li> | |
| <li> | |
| <strong>The LLM produces an interpretation</strong> | |
| highlighting findings, possible explanations, | |
| limitations and a suggested next experiment. | |
| </li> | |
| </ol> | |
| </div> | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column( | |
| scale=2 | |
| ): | |
| ai_button = gr.Button( | |
| "βΆ Analyse this simulation with AI", | |
| variant="primary", | |
| elem_classes="primary-button" | |
| ) | |
| with gr.Column( | |
| scale=1 | |
| ): | |
| gr.HTML( | |
| """ | |
| <div class="ai-status-card"> | |
| <div class="ai-status-title"> | |
| AI ANALYST | |
| </div> | |
| <div class="ai-status-main"> | |
| Small LLM Β· ZeroGPU | |
| </div> | |
| <div class="ai-status-description"> | |
| Activated only when analysis is requested. | |
| This keeps the application lightweight | |
| on the Hugging Face free tier. | |
| </div> | |
| </div> | |
| """ | |
| ) | |
| gr.Markdown( | |
| """ | |
| ### What the analyst looks for | |
| The AI is asked to look beyond a single average and | |
| consider: | |
| - **Behavioural trade-offs** β for example improved | |
| listening success versus comfort or fatigue. | |
| - **Context effects** β whether experience changes | |
| between different listening scenes. | |
| - **Population heterogeneity** β whether different | |
| synthetic users respond differently. | |
| - **Technology implications** β what the pattern might | |
| suggest for a follow-up experiment. | |
| - **Model limitations** β what should not be inferred | |
| from the simulation. | |
| The output is deliberately framed as **research | |
| interpretation, not clinical advice or prediction**. | |
| """ | |
| ) | |
| ai_output = gr.Markdown( | |
| """ | |
| ### Waiting for an experiment | |
| Run a simulation first. | |
| Once results are available, click | |
| **βAnalyse this simulation with AIβ**. | |
| The ZeroGPU LLM will then interpret the results. | |
| """ | |
| ) | |
| gr.Markdown( | |
| """ | |
| ### Why use an LLM here? | |
| This demonstrates a useful division of labour: | |
| **Behaviour engine** | |
| β generates the synthetic observations | |
| **Visual analytics** | |
| β reveals patterns and variability | |
| **LLM** | |
| β interprets those patterns and proposes the next question | |
| This makes the LLM an **analytical research assistant** | |
| rather than a black-box generator of the underlying data. | |
| """ | |
| ) | |
| # ==================================================== | |
| # METHODS | |
| # ==================================================== | |
| with gr.Tab("Methods & assumptions"): | |
| gr.Markdown( | |
| """ | |
| ## Model architecture | |
| ```text | |
| USER | |
| β | |
| βΌ | |
| EXPERIMENT DESIGN | |
| β | |
| βββ Population | |
| βββ Technology | |
| βββ Listening scenes | |
| β | |
| βΌ | |
| βββββββββββββββββββββββ | |
| β SYNTHETIC USER MODELβ | |
| β β | |
| β hearing profile β | |
| β noise difficulty β | |
| β spatial difficulty β | |
| β comfort sensitivity β | |
| β social confidence β | |
| β technology toleranceβ | |
| ββββββββββββ¬βββββββββββ | |
| β | |
| βΌ | |
| βββββββββββββββββββββββ | |
| β ACOUSTIC / FUNCTIONALβ | |
| β ENGINE β | |
| β β | |
| β scene geometry β | |
| β SNR enhancement β | |
| β target preservation β | |
| β spatial robustness β | |
| β intelligibility β | |
| β listening effort β | |
| ββββββββββββ¬ββββββββββββ | |
| β | |
| βΌ | |
| βββββββββββββββββββββββ | |
| β BEHAVIOUR ENGINE β | |
| β β | |
| β use β | |
| β perceived benefit β | |
| β comfort β | |
| β fatigue β | |
| β engagement β | |
| β social participationβ | |
| β acceptance β | |
| ββββββββββββ¬βββββββββββ | |
| β | |
| βΌ | |
| SIMULATED DATA | |
| β | |
| ββββββββββββΌβββββββββββ | |
| βΌ βΌ βΌ | |
| Trends Profiles Variability | |
| β | |
| ββββββββββββ¬βββββββββββ | |
| βΌ | |
| AI ANALYST | |
| ZeroGPU | |
| β | |
| βΌ | |
| Research interpretation | |
| β | |
| βΌ | |
| Next experiment | |
| ``` | |
| ### Evidence grounding | |
| Behavioural dimensions are informed by themes | |
| appearing in hearing-aid user research, including: | |
| - benefit | |
| - comfort | |
| - performance in noise | |
| - directionality | |
| - battery burden | |
| - social participation | |
| - listening effort | |
| - continued use | |
| These are translated into explicit modelling | |
| assumptions rather than hidden inside an opaque | |
| machine-learning model. | |
| ### Layered outcome model | |
| The simulation separates: | |
| 1. **Acoustic performance** β realised SNR enhancement, | |
| effective SNR, target preservation and spatial robustness. | |
| 2. **Functional listening** β estimated intelligibility, | |
| listening access, listening success and effort. | |
| 3. **User experience** β comfort, wearability, charging burden, | |
| fatigue, engagement, acceptance and social participation. | |
| SNR enhancement is therefore not treated as synonymous with | |
| intelligibility or behavioural benefit. Scene geometry, | |
| target location, competing speech and individual differences | |
| can change the realised outcome. | |
| All acoustic parameters are simplified engineering-inspired | |
| assumptions and are not measurements of commercial devices. | |
| ### Important limitation | |
| The synthetic users are **not simulated copies of | |
| real people**. | |
| The model is useful for: | |
| - hypothesis generation | |
| - exploring interactions | |
| - demonstrating AI methods | |
| - testing experimental designs | |
| - visualising behavioural trade-offs | |
| It is not appropriate for: | |
| - clinical prediction | |
| - individual patient recommendations | |
| - estimating real-world prevalence | |
| - claiming efficacy of a commercial technology | |
| """ | |
| ) | |
| gr.Markdown( | |
| "### Evidence dimensions" | |
| ) | |
| evidence_df = pd.DataFrame( | |
| [ | |
| { | |
| "Dimension": | |
| value["label"], | |
| "Role": | |
| value["description"], | |
| "Basis": | |
| value["source"] | |
| } | |
| for value in EVIDENCE.values() | |
| ] | |
| ) | |
| gr.Dataframe( | |
| value=evidence_df, | |
| interactive=False | |
| ) | |
| # ======================================================== | |
| # DISCLAIMER | |
| # ======================================================== | |
| gr.HTML( | |
| f""" | |
| <div class="disclaimer"> | |
| <strong>Research-use notice</strong><br> | |
| {DISCLAIMER} | |
| </div> | |
| <div class="footer"> | |
| Hearing Technology Behaviour Lab Β· | |
| Synthetic population modelling Β· | |
| Architecture {APP_VERSION} | |
| </div> | |
| """ | |
| ) | |
| # ======================================================== | |
| # STATE | |
| # ======================================================== | |
| latest_results = gr.State( | |
| value=None | |
| ) | |
| # ======================================================== | |
| # RUN EXPERIMENT | |
| # ======================================================== | |
| run_button.click( | |
| fn=run_experiment, | |
| inputs=[ | |
| n_users, | |
| days, | |
| technology, | |
| scenes, | |
| randomness, | |
| engagement_weight, | |
| social_weight, | |
| seed | |
| ], | |
| outputs=[ | |
| latest_results, | |
| population_table, | |
| summary_table_output, | |
| kpi_use, | |
| kpi_benefit, | |
| kpi_comfort, | |
| kpi_fatigue, | |
| kpi_engagement, | |
| kpi_acceptance, | |
| kpi_success, | |
| kpi_social, | |
| radar, | |
| trend, | |
| scene_chart, | |
| variability_chart, | |
| heatmap, | |
| status | |
| ] | |
| ) | |
| # ======================================================== | |
| # TECHNOLOGY COMPARISON | |
| # ======================================================== | |
| comparison_button.click( | |
| fn=run_comparison_ui, | |
| inputs=[ | |
| n_users, | |
| days, | |
| comparison_technologies, | |
| scenes, | |
| randomness, | |
| seed, | |
| reference_condition | |
| ], | |
| outputs=[ | |
| comparison_table_output, | |
| comparison_chart_output, | |
| comparison_status | |
| ] | |
| ) | |
| # ======================================================== | |
| # AI ANALYST | |
| # ======================================================== | |
| ai_button.click( | |
| fn=ask_ai, | |
| inputs=[ | |
| latest_results, | |
| technology | |
| ], | |
| outputs=[ | |
| ai_output | |
| ] | |
| ) | |
| # ============================================================ | |
| # LAUNCH | |
| # ============================================================ | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| css=CSS, | |
| theme=gr.themes.Soft( | |
| primary_hue="blue", | |
| secondary_hue="slate", | |
| neutral_hue="slate" | |
| ) | |
| ) |