Spaces:
Sleeping
Sleeping
Create triage.py
Browse files- core/triage.py +179 -0
core/triage.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import numpy as np
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import List, Dict, Tuple
|
| 5 |
+
|
| 6 |
+
# ==========================================
|
| 7 |
+
# 1. CONSTANTS (Aligned with V7/V5 Specs)
|
| 8 |
+
# ==========================================
|
| 9 |
+
PHONEMES = [
|
| 10 |
+
'p', 'pʰ', 'b', 'bʰ', 't', 'tʰ', 'd', 'dʰ', 't̪', 't̪ʰ', 'd̪', 'd̪ʱ',
|
| 11 |
+
'ʈ', 'ɖ', 'k', 'kʰ', 'g', 'ɡʰ', 'q', 'ʔ', 'f', 'v', 's', 'z',
|
| 12 |
+
'ʃ', 'ʒ', 'ʂ', 'ʐ', 'x', 'ɣ', 'h', 'θ', 'ð', 'tʃ', 'dʒ', 'm',
|
| 13 |
+
'mʰ', 'n', 'nʰ', 'ŋ', 'ɳ', 'l', 'lʰ', 'r', 'rʰ', 'ɽ', 'ɽʰ', 'w',
|
| 14 |
+
'j', 'a', 'aː', 'i', 'iː', 'u', 'uː', 'e', 'eː', 'o', 'oː',
|
| 15 |
+
'ə', 'æ', 'ɔ', 'ɪ', 'ʊ'
|
| 16 |
+
]
|
| 17 |
+
P2I = {p: i for i, p in enumerate(PHONEMES)}
|
| 18 |
+
|
| 19 |
+
ERRORS = ["Sub", "Front", "Stop", "Glide", "Clust", "Epen", "Block", "Prolong", "Repeat"]
|
| 20 |
+
|
| 21 |
+
# Mapping Error Index (0-8) to Major Type Category
|
| 22 |
+
# 0-4 = Articulation, 5 = Motor, 6-8 = Fluency
|
| 23 |
+
MAJOR_MAP = {
|
| 24 |
+
0: "Artic", 1: "Artic", 2: "Artic", 3: "Artic", 4: "Artic",
|
| 25 |
+
5: "Motor", 6: "Fluency", 7: "Fluency", 8: "Fluency"
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
# Clinical Priority: Treat Motor issues first, then Fluency, then Artic
|
| 29 |
+
PRIORITY = {"Motor": 3, "Fluency": 2, "Artic": 1}
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class ClinicalDiagnosis:
|
| 33 |
+
id: int
|
| 34 |
+
error_idx: int
|
| 35 |
+
error_name: str
|
| 36 |
+
major_type: str
|
| 37 |
+
severity: float
|
| 38 |
+
active_indices: List[int]
|
| 39 |
+
active_phonemes: List[str]
|
| 40 |
+
position_idx: int
|
| 41 |
+
|
| 42 |
+
class TriageEngineV2:
|
| 43 |
+
def __init__(self, device='cpu'):
|
| 44 |
+
self.device = device
|
| 45 |
+
|
| 46 |
+
def scan_tensor(self, tensor_3d: np.ndarray) -> List[ClinicalDiagnosis]:
|
| 47 |
+
"""
|
| 48 |
+
Scans the 64x9x3 AMLM output map for clinical syndromes.
|
| 49 |
+
Returns a sorted list of diagnoses.
|
| 50 |
+
"""
|
| 51 |
+
diagnoses = []
|
| 52 |
+
uid_counter = 0
|
| 53 |
+
|
| 54 |
+
# tensor_3d shape: (3, 64, 9) -> (Channels, Phonemes, Errors)
|
| 55 |
+
|
| 56 |
+
for err_idx in range(9):
|
| 57 |
+
# Check maximum probability for this error type across all phonemes/positions
|
| 58 |
+
slice_max = np.max(tensor_3d[:, :, err_idx])
|
| 59 |
+
|
| 60 |
+
# STRICT FILTERING: Threshold > 0.6 means the AMLM is confident
|
| 61 |
+
if slice_max > 0.6:
|
| 62 |
+
# Find which phonemes are affected
|
| 63 |
+
# We collapse the channel dim (positions) to see if the phoneme is affected anywhere
|
| 64 |
+
hits = np.where(tensor_3d[:, :, err_idx].max(axis=0) > 0.6)[0]
|
| 65 |
+
|
| 66 |
+
# Filter specific weird edge cases (e.g. Gliding on vowels) if necessary
|
| 67 |
+
# Logic from Integration.ipynb: valid_hits = [int(h) for h in hits if not (err_idx == 3 and h >= 48)]
|
| 68 |
+
valid_hits = [int(h) for h in hits]
|
| 69 |
+
|
| 70 |
+
# NOISE REDUCTION: Cap at Top 10 worst errors per category
|
| 71 |
+
if len(valid_hits) > 10:
|
| 72 |
+
severity_scores = []
|
| 73 |
+
for h in valid_hits:
|
| 74 |
+
score = tensor_3d[:, h, err_idx].max()
|
| 75 |
+
severity_scores.append((score, h))
|
| 76 |
+
severity_scores.sort(key=lambda x: x[0], reverse=True)
|
| 77 |
+
valid_hits = [x[1] for x in severity_scores[:10]]
|
| 78 |
+
|
| 79 |
+
valid_hits.sort()
|
| 80 |
+
|
| 81 |
+
if not valid_hits: continue
|
| 82 |
+
|
| 83 |
+
# Determine dominant position (Initial, Medial, or Final)
|
| 84 |
+
pos_sums = tensor_3d[:, valid_hits, err_idx].sum(axis=1)
|
| 85 |
+
dom_pos = np.argmax(pos_sums)
|
| 86 |
+
|
| 87 |
+
diag = ClinicalDiagnosis(
|
| 88 |
+
id=uid_counter,
|
| 89 |
+
error_idx=err_idx,
|
| 90 |
+
error_name=ERRORS[err_idx],
|
| 91 |
+
major_type=MAJOR_MAP[err_idx],
|
| 92 |
+
severity=float(slice_max),
|
| 93 |
+
active_indices=valid_hits,
|
| 94 |
+
active_phonemes=[PHONEMES[i] for i in valid_hits],
|
| 95 |
+
position_idx=int(dom_pos)
|
| 96 |
+
)
|
| 97 |
+
diagnoses.append(diag)
|
| 98 |
+
uid_counter += 1
|
| 99 |
+
|
| 100 |
+
# Sort by Priority (Motor > Fluency > Artic) then Severity
|
| 101 |
+
return sorted(diagnoses, key=lambda x: (PRIORITY[x.major_type], x.severity), reverse=True)
|
| 102 |
+
|
| 103 |
+
def prepare_aeg_input(self, focus_diag: ClinicalDiagnosis, all_diagnoses: List[ClinicalDiagnosis], user_stats: List[float]) -> Dict[str, torch.Tensor]:
|
| 104 |
+
"""
|
| 105 |
+
Generates the 162-Dim Input Vector for the AEG Neural Network.
|
| 106 |
+
Adheres strictly to 'Data Generator.py' logic.
|
| 107 |
+
"""
|
| 108 |
+
# 1. Cluster Mask (Targets)
|
| 109 |
+
# Which sounds should we target in therapy? (The ones in the focus diagnosis)
|
| 110 |
+
cluster_mask = [0.0] * 64
|
| 111 |
+
target_set = set(focus_diag.active_indices)
|
| 112 |
+
for idx in focus_diag.active_indices:
|
| 113 |
+
cluster_mask[idx] = 1.0
|
| 114 |
+
|
| 115 |
+
# 2. Trap Mask (Forbidden Sounds)
|
| 116 |
+
# Sounds we must NOT use to avoid negative reinforcement.
|
| 117 |
+
trap_mask = [0.0] * 64
|
| 118 |
+
|
| 119 |
+
def attempt_trap(idx):
|
| 120 |
+
# Logic: A sound cannot be a Trap if it is currently a Target.
|
| 121 |
+
if idx not in target_set:
|
| 122 |
+
trap_mask[idx] = 1.0
|
| 123 |
+
|
| 124 |
+
# A. Standard Traps (Rule-based from Data Generator)
|
| 125 |
+
if focus_diag.error_name == "Front": # If Fronting K->T
|
| 126 |
+
# Trap T/D so we don't ask user to say them while trying to learn K
|
| 127 |
+
if any(p in ['k', 'g', 'kʰ', 'ɡʰ'] for p in focus_diag.active_phonemes):
|
| 128 |
+
for x in ['t', 'd', 'tʰ', 'dʰ']:
|
| 129 |
+
if x in P2I: attempt_trap(P2I[x])
|
| 130 |
+
|
| 131 |
+
# B. Comorbidity Traps (Dynamic)
|
| 132 |
+
# If user also has a Lisp (Sub S), Trap 'S' even if we are treating Fronting.
|
| 133 |
+
for other in all_diagnoses:
|
| 134 |
+
if other.id != focus_diag.id:
|
| 135 |
+
for idx in other.active_indices:
|
| 136 |
+
attempt_trap(idx)
|
| 137 |
+
|
| 138 |
+
# 3. Phonetic Features (8-Dim Vector)
|
| 139 |
+
# Describes the target sound (e.g., +Stop, +Velar)
|
| 140 |
+
feat = [0.0] * 8
|
| 141 |
+
t_str = "".join(focus_diag.active_phonemes)
|
| 142 |
+
|
| 143 |
+
if any(x in t_str for x in "pbtdkgʈɖʔ"): feat[0] = 1.0 # Stop
|
| 144 |
+
if any(x in t_str for x in "fvszʃʒxhθð"): feat[1] = 1.0 # Fricative
|
| 145 |
+
if any(x in t_str for x in "lrw"): feat[2] = 1.0 # Liquid
|
| 146 |
+
if any(x in t_str for x in "mnŋ"): feat[3] = 1.0 # Nasal
|
| 147 |
+
if any(x in t_str for x in "bdgvzʒðmnlrw"): feat[4] = 1.0 # Voiced
|
| 148 |
+
if any(x in t_str for x in "pbfvmθð"): feat[5] = 1.0 # Front
|
| 149 |
+
if any(x in t_str for x in "kgŋxɣhʔ"): feat[6] = 1.0 # Back
|
| 150 |
+
if len(focus_diag.active_indices) > 1 or focus_diag.error_name == "Clust": feat[7] = 1.0 # Cluster
|
| 151 |
+
|
| 152 |
+
# 4. Flags & Stats
|
| 153 |
+
trap_count = sum(trap_mask)
|
| 154 |
+
# user_stats expects: [Severity, Fatigue, Age, Delta]
|
| 155 |
+
sev, fat, age, delta = user_stats
|
| 156 |
+
|
| 157 |
+
# Binary Flags for the Network
|
| 158 |
+
is_systemic = 1.0 if len(focus_diag.active_indices) > 1 else 0.0
|
| 159 |
+
is_trap = 1.0 if trap_count > 0 else 0.0
|
| 160 |
+
is_comorb = 1.0 if len(all_diagnoses) > 1 else 0.0
|
| 161 |
+
is_child = 1.0 if age < 0.3 else 0.0 # Normalized age (0.3 ~ 7 years old)
|
| 162 |
+
is_maint = 1.0 if sev <= 0.2 else 0.0
|
| 163 |
+
is_fatigued = 1.0 if fat > 0.8 else 0.0
|
| 164 |
+
|
| 165 |
+
flags = [is_systemic, is_trap, is_comorb, is_child, is_maint, is_fatigued]
|
| 166 |
+
|
| 167 |
+
maj_int = {"Artic": 0, "Fluency": 1, "Motor": 2}[focus_diag.major_type]
|
| 168 |
+
|
| 169 |
+
# Convert to PyTorch Tensors on correct Device
|
| 170 |
+
return {
|
| 171 |
+
"error_idx": torch.tensor([focus_diag.error_idx]).to(self.device),
|
| 172 |
+
"major_idx": torch.tensor([maj_int]).to(self.device),
|
| 173 |
+
"pos_idx": torch.tensor([focus_diag.position_idx]).to(self.device),
|
| 174 |
+
"cluster": torch.tensor([cluster_mask], dtype=torch.float32).to(self.device),
|
| 175 |
+
"trap": torch.tensor([trap_mask], dtype=torch.float32).to(self.device),
|
| 176 |
+
"feat": torch.tensor([feat], dtype=torch.float32).to(self.device),
|
| 177 |
+
"flags": torch.tensor([flags], dtype=torch.float32).to(self.device),
|
| 178 |
+
"stats": torch.tensor([user_stats], dtype=torch.float32).to(self.device)
|
| 179 |
+
}
|