CoolFace
Apppublic

Kh0128/Aphasia_Classification__Lang

sourceHugging Faceupdated 11mo agoView on Hugging Face
0likes
output.py660 linesDownload Raw Back to root
1# -*- coding: utf-8 -*-2"""3Aphasia classification inference (cleaned).4- Respects model_dir argument5- Correctly parses durations like ["word", 300] and [start, end]6- Removes duplicate load_state_dict7- Adds predict_from_chajson(json_path, ...) helper8"""9 10import json as json11import os12import math13from dataclasses import dataclass14from typing import Dict, List, Optional, Tuple15from collections import defaultdict16 17 18import numpy as np19import torch20import torch.nn as nn21import torch.nn.functional as F22import pandas as pd23from transformers import AutoTokenizer, AutoModel24 25 26# =========================27# Model definition (unchanged shape)28# =========================29 30@dataclass31class ModelConfig:32    model_name: str = "microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract-fulltext"33    max_length: int = 51234    hidden_size: int = 76835    pos_vocab_size: int = 15036    pos_emb_dim: int = 6437    grammar_dim: int = 338    grammar_hidden_dim: int = 6439    duration_hidden_dim: int = 12840    prosody_dim: int = 3241    num_attention_heads: int = 842    attention_dropout: float = 0.343    classifier_hidden_dims: List[int] = None44    dropout_rate: float = 0.345    def __post_init__(self):46        if self.classifier_hidden_dims is None:47            self.classifier_hidden_dims = [512, 256]48 49class StablePositionalEncoding(nn.Module):50    def __init__(self, d_model: int, max_len: int = 5000):51        super().__init__()52        self.d_model = d_model53        pe = torch.zeros(max_len, d_model)54        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)55        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))56        pe[:, 0::2] = torch.sin(position * div_term)57        pe[:, 1::2] = torch.cos(position * div_term)58        self.register_buffer('pe', pe.unsqueeze(0))59        self.learnable_pe = nn.Parameter(torch.randn(max_len, d_model) * 0.01)60    def forward(self, x):61        seq_len = x.size(1)62        sinusoidal = self.pe[:, :seq_len, :].to(x.device)63        learnable = self.learnable_pe[:seq_len, :].unsqueeze(0).expand(x.size(0), -1, -1)64        return x + 0.1 * (sinusoidal + learnable)65 66class StableMultiHeadAttention(nn.Module):67    def __init__(self, feature_dim: int, num_heads: int = 4, dropout: float = 0.3):68        super().__init__()69        self.num_heads = num_heads70        self.feature_dim = feature_dim71        self.head_dim = feature_dim // num_heads72        assert feature_dim % num_heads == 073        self.query = nn.Linear(feature_dim, feature_dim)74        self.key = nn.Linear(feature_dim, feature_dim)75        self.value = nn.Linear(feature_dim, feature_dim)76        self.dropout = nn.Dropout(dropout)77        self.output_proj = nn.Linear(feature_dim, feature_dim)78        self.layer_norm = nn.LayerNorm(feature_dim)79    def forward(self, x, mask=None):80        b, t, _ = x.size()81        Q = self.query(x).view(b, t, self.num_heads, self.head_dim).transpose(1, 2)82        K = self.key(x).view(b, t, self.num_heads, self.head_dim).transpose(1, 2)83        V = self.value(x).view(b, t, self.num_heads, self.head_dim).transpose(1, 2)84        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.head_dim)85        if mask is not None:86            if mask.dim() == 2:87                mask = mask.unsqueeze(1).unsqueeze(1)88            scores.masked_fill_(mask == 0, -1e9)89        attn = F.softmax(scores, dim=-1)90        attn = self.dropout(attn)91        ctx = torch.matmul(attn, V)92        ctx = ctx.transpose(1, 2).contiguous().view(b, t, self.feature_dim)93        out = self.output_proj(ctx)94        return self.layer_norm(out + x)95 96class StableLinguisticFeatureExtractor(nn.Module):97    def __init__(self, config: ModelConfig):98        super().__init__()99        self.config = config100        self.pos_embedding = nn.Embedding(config.pos_vocab_size, config.pos_emb_dim, padding_idx=0)101        self.pos_attention = StableMultiHeadAttention(config.pos_emb_dim, num_heads=4)102        self.grammar_projection = nn.Sequential(103            nn.Linear(config.grammar_dim, config.grammar_hidden_dim),104            nn.Tanh(),105            nn.LayerNorm(config.grammar_hidden_dim),106            nn.Dropout(config.dropout_rate * 0.3)107        )108        self.duration_projection = nn.Sequential(109            nn.Linear(1, config.duration_hidden_dim),110            nn.Tanh(),111            nn.LayerNorm(config.duration_hidden_dim)112        )113        self.prosody_projection = nn.Sequential(114            nn.Linear(config.prosody_dim, config.prosody_dim),115            nn.ReLU(),116            nn.LayerNorm(config.prosody_dim)117        )118        total_feature_dim = (config.pos_emb_dim + config.grammar_hidden_dim +119                             config.duration_hidden_dim + config.prosody_dim)120        self.feature_fusion = nn.Sequential(121            nn.Linear(total_feature_dim, total_feature_dim // 2),122            nn.Tanh(),123            nn.LayerNorm(total_feature_dim // 2),124            nn.Dropout(config.dropout_rate)125        )126    def forward(self, pos_ids, grammar_ids, durations, prosody_features, attention_mask):127        b, t = pos_ids.size()128        pos_ids = pos_ids.clamp(0, self.config.pos_vocab_size - 1)129        pos_emb = self.pos_embedding(pos_ids)130        pos_feat = self.pos_attention(pos_emb, attention_mask)131        gra_feat = self.grammar_projection(grammar_ids.float())132        dur_feat = self.duration_projection(durations.unsqueeze(-1).float())133        pro_feat = self.prosody_projection(prosody_features.float())134        combined = torch.cat([pos_feat, gra_feat, dur_feat, pro_feat], dim=-1)135        fused = self.feature_fusion(combined)136        mask_exp = attention_mask.unsqueeze(-1).float()137        pooled = torch.sum(fused * mask_exp, dim=1) / torch.sum(mask_exp, dim=1)138        return pooled139 140class StableAphasiaClassifier(nn.Module):141    def __init__(self, config: ModelConfig, num_labels: int):142        super().__init__()143        self.config = config144        self.num_labels = num_labels145        self.bert = AutoModel.from_pretrained(config.model_name)146        self.bert_config = self.bert.config147        self.positional_encoder = StablePositionalEncoding(d_model=self.bert_config.hidden_size,148                                                           max_len=config.max_length)149        self.linguistic_extractor = StableLinguisticFeatureExtractor(config)150        bert_dim = self.bert_config.hidden_size151        lingu_dim = (config.pos_emb_dim + config.grammar_hidden_dim +152                     config.duration_hidden_dim + config.prosody_dim) // 2153        self.feature_fusion = nn.Sequential(154            nn.Linear(bert_dim + lingu_dim, bert_dim),155            nn.LayerNorm(bert_dim),156            nn.Tanh(),157            nn.Dropout(config.dropout_rate)158        )159        self.classifier = self._build_classifier(bert_dim, num_labels)160        self.severity_head = nn.Sequential(nn.Linear(bert_dim, 4), nn.Softmax(dim=-1))161        self.fluency_head = nn.Sequential(nn.Linear(bert_dim, 1), nn.Sigmoid())162    def _build_classifier(self, input_dim: int, num_labels: int):163        layers, cur = [], input_dim164        for h in self.config.classifier_hidden_dims:165            layers += [nn.Linear(cur, h), nn.LayerNorm(h), nn.Tanh(), nn.Dropout(self.config.dropout_rate)]166            cur = h167        layers.append(nn.Linear(cur, num_labels))168        return nn.Sequential(*layers)169    def _attention_pooling(self, seq_out, attn_mask):170        attn_w = torch.softmax(torch.sum(seq_out, dim=-1, keepdim=True), dim=1)171        attn_w = attn_w * attn_mask.unsqueeze(-1).float()172        attn_w = attn_w / (torch.sum(attn_w, dim=1, keepdim=True) + 1e-9)173        return torch.sum(seq_out * attn_w, dim=1)174    def forward(self, input_ids, attention_mask, labels=None,175                word_pos_ids=None, word_grammar_ids=None, word_durations=None,176                prosody_features=None, **kwargs):177        bert_out = self.bert(input_ids=input_ids, attention_mask=attention_mask)178        seq_out = bert_out.last_hidden_state179        pos_enh = self.positional_encoder(seq_out)180        pooled = self._attention_pooling(pos_enh, attention_mask)181        if all(x is not None for x in [word_pos_ids, word_grammar_ids, word_durations]):182            if prosody_features is None:183                b, t = input_ids.size()184                prosody_features = torch.zeros(b, t, self.config.prosody_dim, device=input_ids.device)185            ling = self.linguistic_extractor(word_pos_ids, word_grammar_ids, word_durations,186                                             prosody_features, attention_mask)187        else:188            ling = torch.zeros(input_ids.size(0),189                               (self.config.pos_emb_dim + self.config.grammar_hidden_dim +190                                self.config.duration_hidden_dim + self.config.prosody_dim) // 2,191                               device=input_ids.device)192        fused = self.feature_fusion(torch.cat([pooled, ling], dim=1))193        logits = self.classifier(fused)194        severity_pred = self.severity_head(fused)195        fluency_pred = self.fluency_head(fused)196        return {"logits": logits, "severity_pred": severity_pred, "fluency_pred": fluency_pred, "loss": None}197 198 199# =========================200# Inference system (fixed wiring)201# =========================202 203class AphasiaInferenceSystem:204    """失語症分類推理系統"""205 206    def __init__(self, model_dir: str):207        self.model_dir = model_dir  # <— honor the argument208        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")209 210        # Descriptions (unchanged)211        self.aphasia_descriptions = {212            "BROCA": {"name": "Broca's Aphasia (Non-fluent)", "description":213                      "Characterized by limited speech output, difficulty with grammar and sentence formation, but relatively preserved comprehension. Speech is typically effortful and halting.",214                      "features": ["Non-fluent speech", "Preserved comprehension", "Grammar difficulties", "Word-finding problems"]},215            "TRANSMOTOR": {"name": "Trans-cortical Motor Aphasia", "description":216                           "Similar to Broca's aphasia but with preserved repetition abilities. Speech is non-fluent with good comprehension.",217                           "features": ["Non-fluent speech", "Good repetition", "Preserved comprehension", "Grammar difficulties"]},218            "NOTAPHASICBYWAB": {"name": "Not Aphasic by WAB", "description":219                                "Individuals who do not meet the criteria for aphasia according to the Western Aphasia Battery assessment.",220                                "features": ["Normal language function", "No significant language impairment", "Good comprehension", "Fluent speech"]},221            "CONDUCTION": {"name": "Conduction Aphasia", "description":222                           "Characterized by fluent speech with good comprehension but severely impaired repetition. Often involves phonemic paraphasias.",223                           "features": ["Fluent speech", "Good comprehension", "Poor repetition", "Phonemic errors"]},224            "WERNICKE": {"name": "Wernicke's Aphasia (Fluent)", "description":225                         "Fluent but often meaningless speech with poor comprehension. Speech may contain neologisms and jargon.",226                         "features": ["Fluent speech", "Poor comprehension", "Jargon speech", "Neologisms"]},227            "ANOMIC": {"name": "Anomic Aphasia", "description":228                       "Primarily characterized by word-finding difficulties with otherwise relatively preserved language abilities.",229                       "features": ["Word-finding difficulties", "Good comprehension", "Fluent speech", "Circumlocution"]},230            "GLOBAL": {"name": "Global Aphasia", "description":231                       "Severe impairment in all language modalities - comprehension, production, repetition, and naming.",232                       "features": ["Severe comprehension deficit", "Non-fluent speech", "Poor repetition", "Severe naming difficulties"]},233            "ISOLATION": {"name": "Isolation Syndrome", "description":234                          "Rare condition with preserved repetition but severely impaired comprehension and spontaneous speech.",235                          "features": ["Good repetition", "Poor comprehension", "Limited spontaneous speech", "Echolalia"]},236            "TRANSSENSORY": {"name": "Trans-cortical Sensory Aphasia", "description":237                              "Fluent speech with good repetition but impaired comprehension, similar to Wernicke's but with preserved repetition.",238                              "features": ["Fluent speech", "Good repetition", "Poor comprehension", "Semantic errors"]}239        }240 241        self.load_configuration()242        self.load_model()243        print(f"推理系統初始化完成,使用設備: {self.device}")244 245    def load_configuration(self):246        cfg_path = os.path.join(self.model_dir, "config.json")247        if os.path.exists(cfg_path):248            with open(cfg_path, "r", encoding="utf-8") as f:249                cfg = json.load(f)250            self.aphasia_types_mapping = cfg.get("aphasia_types_mapping", {251                "BROCA": 0, "TRANSMOTOR": 1, "NOTAPHASICBYWAB": 2,252                "CONDUCTION": 3, "WERNICKE": 4, "ANOMIC": 5,253                "GLOBAL": 6, "ISOLATION": 7, "TRANSSENSORY": 8254            })255            self.num_labels = cfg.get("num_labels", 9)256            self.model_name = cfg.get("model_name", "microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract-fulltext")257        else:258            self.aphasia_types_mapping = {259                "BROCA": 0, "TRANSMOTOR": 1, "NOTAPHASICBYWAB": 2,260                "CONDUCTION": 3, "WERNICKE": 4, "ANOMIC": 5,261                "GLOBAL": 6, "ISOLATION": 7, "TRANSSENSORY": 8262            }263            self.num_labels = 9264            self.model_name = "microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract-fulltext"265        self.id_to_aphasia_type = {v: k for k, v in self.aphasia_types_mapping.items()}266 267    def load_model(self):268        self.tokenizer = AutoTokenizer.from_pretrained(self.model_dir, use_fast=True)269        # pad token fix270        if self.tokenizer.pad_token is None:271            if self.tokenizer.eos_token is not None:272                self.tokenizer.pad_token = self.tokenizer.eos_token273            elif self.tokenizer.unk_token is not None:274                self.tokenizer.pad_token = self.tokenizer.unk_token275            else:276                self.tokenizer.add_special_tokens({"pad_token": "[PAD]"})277        # optional added tokens278        add_path = os.path.join(self.model_dir, "added_tokens.json")279        if os.path.exists(add_path):280            with open(add_path, "r", encoding="utf-8") as f:281                data = json.load(f)282            tokens = list(data.keys()) if isinstance(data, dict) else data283            if tokens:284                self.tokenizer.add_tokens(tokens)285 286        self.config = ModelConfig()287        self.config.model_name = self.model_name288 289        self.model = StableAphasiaClassifier(self.config, self.num_labels)290        self.model.bert.resize_token_embeddings(len(self.tokenizer))291 292        model_path = os.path.join(self.model_dir, "pytorch_model.bin")293        if not os.path.exists(model_path):294            raise FileNotFoundError(f"模型權重文件不存在: {model_path}")295        state = torch.load(model_path, map_location=self.device)296        self.model.load_state_dict(state)  # (once)297 298        self.model.to(self.device)299        self.model.eval()300 301    # ---------- helpers ----------302 303    def _dur_to_float(self, d) -> float:304        """Robustly parse duration from various shapes:305        - number306        - ["word", ms]307        - [start, end]308        - {"dur": ms} (future-proof)309        """310        if isinstance(d, (int, float)):311            return float(d)312        if isinstance(d, list):313            if len(d) == 2:314                # ["word", 300] or [start, end]315                a, b = d[0], d[1]316                # case 1: word + ms317                if isinstance(a, str) and isinstance(b, (int, float)):318                    return float(b)319                # case 2: start, end320                if isinstance(a, (int, float)) and isinstance(b, (int, float)):321                    return float(b) - float(a)322        if isinstance(d, dict):323            for k in ("dur", "duration", "ms"):324                if k in d and isinstance(d[k], (int, float)):325                    return float(d[k])326        return 0.0327 328    def _extract_prosodic_features(self, durations, tokens):329        vals = []330        for d in durations:331            vals.append(self._dur_to_float(d))332        vals = [v for v in vals if v > 0]333        if not vals:334            return [0.0] * self.config.prosody_dim335        features = [336            float(np.mean(vals)),337            float(np.std(vals)),338            float(np.median(vals)),339            float(len([v for v in vals if v > (np.mean(vals) * 1.5)])),340        ]341        while len(features) < self.config.prosody_dim:342            features.append(0.0)343        return features[:self.config.prosody_dim]344 345    def _align_features(self, tokens, pos_ids, grammar_ids, durations, encoded):346        # map subtoken -> original token index347        subtoken_to_token = []348        for idx, tok in enumerate(tokens):349            subtoks = self.tokenizer.tokenize(tok)350            subtoken_to_token.extend([idx] * max(1, len(subtoks)))351 352        aligned_pos = [0]                # [CLS]353        aligned_grammar = [[0, 0, 0]]    # [CLS]354        aligned_durations = [0.0]        # [CLS]355 356        # reserve last slot for [SEP]357        max_body = self.config.max_length - 2358        for st_idx in range(max_body):359            if st_idx < len(subtoken_to_token):360                orig = subtoken_to_token[st_idx]361                aligned_pos.append(pos_ids[orig] if orig < len(pos_ids) else 0)362                aligned_grammar.append(grammar_ids[orig] if orig < len(grammar_ids) else [0, 0, 0])363                aligned_durations.append(self._dur_to_float(durations[orig]) if orig < len(durations) else 0.0)364            else:365                aligned_pos.append(0)366                aligned_grammar.append([0, 0, 0])367                aligned_durations.append(0.0)368 369        aligned_pos.append(0)                 # [SEP]370        aligned_grammar.append([0, 0, 0])     # [SEP]371        aligned_durations.append(0.0)         # [SEP]372        return aligned_pos, aligned_grammar, aligned_durations373 374    def preprocess_sentence(self, sentence_data: dict) -> Optional[dict]:375        all_tokens, all_pos, all_grammar, all_durations = [], [], [], []376        for d_idx, dialogue in enumerate(sentence_data.get("dialogues", [])):377            if d_idx > 0:378                all_tokens.append("[DIALOGUE]")379                all_pos.append(0)380                all_grammar.append([0, 0, 0])381                all_durations.append(0.0)382            for par in dialogue.get("PAR", []):383                if "tokens" in par and par["tokens"]:384                    toks = par["tokens"]385                    pos_ids = par.get("word_pos_ids", [0] * len(toks))386                    gra_ids = par.get("word_grammar_ids", [[0, 0, 0]] * len(toks))387                    durs = par.get("word_durations", [0.0] * len(toks))388                    all_tokens.extend(toks)389                    all_pos.extend(pos_ids)390                    all_grammar.extend(gra_ids)391                    all_durations.extend(durs)392        if not all_tokens:393            return None394 395        text = " ".join(all_tokens)396        enc = self.tokenizer(text, max_length=self.config.max_length, padding="max_length",397                             truncation=True, return_tensors="pt")398        aligned_pos, aligned_gra, aligned_dur = self._align_features(399            all_tokens, all_pos, all_grammar, all_durations, enc400        )401        prosody = self._extract_prosodic_features(all_durations, all_tokens)402        prosody_tensor = torch.tensor(prosody).unsqueeze(0).repeat(self.config.max_length, 1)403 404        return {405            "input_ids": enc["input_ids"].squeeze(0),406            "attention_mask": enc["attention_mask"].squeeze(0),407            "word_pos_ids": torch.tensor(aligned_pos, dtype=torch.long),408            "word_grammar_ids": torch.tensor(aligned_gra, dtype=torch.long),409            "word_durations": torch.tensor(aligned_dur, dtype=torch.float),410            "prosody_features": prosody_tensor.float(),411            "sentence_id": sentence_data.get("sentence_id", "unknown"),412            "original_tokens": all_tokens,413            "text": text414        }415 416    def predict_single(self, sentence_data: dict) -> dict:417        proc = self.preprocess_sentence(sentence_data)418        if proc is None:419            return {"error": "無法處理輸入數據", "sentence_id": sentence_data.get("sentence_id", "unknown")}420        inp = {421            "input_ids": proc["input_ids"].unsqueeze(0).to(self.device),422            "attention_mask": proc["attention_mask"].unsqueeze(0).to(self.device),423            "word_pos_ids": proc["word_pos_ids"].unsqueeze(0).to(self.device),424            "word_grammar_ids": proc["word_grammar_ids"].unsqueeze(0).to(self.device),425            "word_durations": proc["word_durations"].unsqueeze(0).to(self.device),426            "prosody_features": proc["prosody_features"].unsqueeze(0).to(self.device),427        }428        with torch.no_grad():429            out = self.model(**inp)430            logits = out["logits"]431            probs = F.softmax(logits, dim=1).cpu().numpy()[0]432            pred_id = int(np.argmax(probs))433            sev = out["severity_pred"].cpu().numpy()[0]434            flu = float(out["fluency_pred"].cpu().numpy()[0][0])435 436        pred_type = self.id_to_aphasia_type[pred_id]437        conf = float(probs[pred_id])438 439        dist = {}440        for a_type, t_id in self.aphasia_types_mapping.items():441            dist[a_type] = {"probability": float(probs[t_id]), "percentage": f"{probs[t_id]*100:.2f}%"}442 443        sorted_dist = dict(sorted(dist.items(), key=lambda x: x[1]["probability"], reverse=True))444        return {445            "sentence_id": proc["sentence_id"],446            "input_text": proc["text"],447            "original_tokens": proc["original_tokens"],448            "prediction": {449                "predicted_class": pred_type,450                "confidence": conf,451                "confidence_percentage": f"{conf*100:.2f}%"452            },453            "class_description": self.aphasia_descriptions.get(pred_type, {454                "name": pred_type, "description": "Description not available", "features": []455            }),456            "probability_distribution": sorted_dist,457            "additional_predictions": {458                "severity_distribution": {459                    "level_0": float(sev[0]), "level_1": float(sev[1]),460                    "level_2": float(sev[2]), "level_3": float(sev[3])461                },462                "predicted_severity_level": int(np.argmax(sev)),463                "fluency_score": flu,464                "fluency_rating": "High" if flu > 0.7 else ("Medium" if flu > 0.4 else "Low"),465            }466        }467 468    def predict_batch(self, input_file: str, output_file: Optional[str] = None) -> Dict:469        with open(input_file, "r", encoding="utf-8") as f:470            data = json.load(f)471        sentences = data.get("sentences", [])472        results = []473        print(f"開始處理 {len(sentences)} 個句子...")474        for i, s in enumerate(sentences):475            print(f"處理第 {i+1}/{len(sentences)} 個句子...")476            results.append(self.predict_single(s))477        summary = self._generate_summary(results)478        final = {"summary": summary, "total_sentences": len(results), "predictions": results}479        if output_file:480            with open(output_file, "w", encoding="utf-8") as f:481                json.dump(final, f, ensure_ascii=False, indent=2)482            print(f"結果已保存到: {output_file}")483        return final484 485    def _generate_summary(self, results: List[dict]) -> dict:486        if not results:487            return {}488        class_counts = defaultdict(int)489        confs, flus = [], []490        sev_counts = defaultdict(int)491        for r in results:492            if "error" in r:493                continue494            c = r["prediction"]["predicted_class"]495            class_counts[c] += 1496            confs.append(r["prediction"]["confidence"])497            flus.append(r["additional_predictions"]["fluency_score"])498            sev_counts[r["additional_predictions"]["predicted_severity_level"]] += 1499        avg_conf = float(np.mean(confs)) if confs else 0.0500        avg_flu = float(np.mean(flus)) if flus else 0.0501        return {502            "classification_distribution": dict(class_counts),503            "classification_percentages": {k: f"{v/len(results)*100:.1f}%" for k, v in class_counts.items()},504            "average_confidence": f"{avg_conf:.3f}",505            "average_fluency_score": f"{avg_flu:.3f}",506            "severity_distribution": dict(sev_counts),507            "confidence_statistics": {} if not confs else {508                "mean": f"{np.mean(confs):.3f}",509                "std": f"{np.std(confs):.3f}",510                "min": f"{np.min(confs):.3f}",511                "max": f"{np.max(confs):.3f}",512            },513            "most_common_prediction": max(class_counts.items(), key=lambda x: x[1])[0] if class_counts else "None",514        }515 516    def generate_detailed_report(self, results: List[dict], output_dir: str = "./inference_results"):517        os.makedirs(output_dir, exist_ok=True)518        rows = []519        for r in results:520            if "error" in r:521                continue522            row = {523                "sentence_id": r["sentence_id"],524                "predicted_class": r["prediction"]["predicted_class"],525                "confidence": r["prediction"]["confidence"],526                "class_name": r["class_description"]["name"],527                "severity_level": r["additional_predictions"]["predicted_severity_level"],528                "fluency_score": r["additional_predictions"]["fluency_score"],529                "fluency_rating": r["additional_predictions"]["fluency_rating"],530                "input_text": r["input_text"],531            }532            for a_type, info in r["probability_distribution"].items():533                row[f"prob_{a_type}"] = info["probability"]534            rows.append(row)535        if not rows:536            return None537        df = pd.DataFrame(rows)538        df.to_csv(os.path.join(output_dir, "detailed_predictions.csv"), index=False, encoding="utf-8")539        summary_stats = {540            "total_predictions": int(len(rows)),541            "class_distribution": df["predicted_class"].value_counts().to_dict(),542            "average_confidence": float(df["confidence"].mean()),543            "confidence_std": float(df["confidence"].std()),544            "average_fluency": float(df["fluency_score"].mean()),545            "fluency_std": float(df["fluency_score"].std()),546            "severity_distribution": df["severity_level"].value_counts().to_dict(),547        }548        with open(os.path.join(output_dir, "summary_statistics.json"), "w", encoding="utf-8") as f:549            json.dump(summary_stats, f, ensure_ascii=False, indent=2)550        print(f"詳細報告已生成並保存到: {output_dir}")551        return df552 553 554# =========================555# Convenience: run directly or from pipeline556# =========================557 558def predict_from_chajson(model_dir: str, chajson_path: str, output_file: Optional[str] = None) -> Dict:559    """560    Convenience entry:561    - Accepts the JSON produced by cha_json.py562    - If it contains 'sentences', runs per-sentence like before563    - If it only contains 'text_all', creates a single pseudo-sentence564    """565    with open(chajson_path, "r", encoding="utf-8") as f:566        data = json.load(f)567 568    inf = AphasiaInferenceSystem(model_dir)569 570    # If there are sentences, use the full path571    if data.get("sentences"):572        return inf.predict_batch(chajson_path, output_file=output_file)573 574    # Else, fall back to a single synthetic sentence using text_all575    text_all = data.get("text_all", "")576    fake = {577        "sentences": [{578            "sentence_id": "S1",579            "dialogues": [{580                "INV": [],581                "PAR": [{"tokens": text_all.split(),582                         "word_pos_ids": [0]*len(text_all.split()),583                         "word_grammar_ids": [[0,0,0]]*len(text_all.split()),584                         "word_durations": [0.0]*len(text_all.split())}]585            }]586        }]587    }588    tmp_path = chajson_path + "._synthetic.json"589    with open(tmp_path, "w", encoding="utf-8") as f:590        json.dump(fake, f, ensure_ascii=False, indent=2)591    out = inf.predict_batch(tmp_path, output_file=output_file)592    try:593        os.remove(tmp_path)594    except Exception:595        pass596    return out597 598def format_result(pred: dict, style: str = "json") -> str:599    """Back-compat formatter. 'pred' is the dict returned by predict_*."""600    if style == "json":601        return json.dumps(pred, ensure_ascii=False, indent=2)602    # simple text summary603    if isinstance(pred, dict) and "summary" in pred:604        s = pred["summary"]605        lines = [606            f"Total sentences: {pred.get('total_sentences', 0)}",607            f"Avg confidence: {s.get('average_confidence', 'N/A')}",608            f"Avg fluency: {s.get('average_fluency_score', 'N/A')}",609            f"Most common: {s.get('most_common_prediction', 'N/A')}",610        ]611        return "\n".join(lines)612    return str(pred)613 614 615# ---------- CLI ----------616 617def main():618    import argparse619    p = argparse.ArgumentParser(description="失語症分類推理系統")620    p.add_argument("--model_dir", type=str, required=False, default="./adaptive_aphasia_model",621                   help="訓練好的模型目錄路徑")622    p.add_argument("--input_file", type=str, required=True,623                   help="輸入JSON文件(cha_json 的輸出)")624    p.add_argument("--output_file", type=str, default="./aphasia_predictions.json",625                   help="輸出JSON文件路徑")626    p.add_argument("--report_dir", type=str, default="./inference_results",627                   help="詳細報告輸出目錄")628    p.add_argument("--generate_report", action="store_true",629                   help="是否生成詳細的CSV報告")630    args = p.parse_args()631 632    try:633        print("正在初始化推理系統...")634        sys = AphasiaInferenceSystem(args.model_dir)635 636        print("開始執行批次預測...")637        results = sys.predict_batch(args.input_file, args.output_file)638 639        if args.generate_report:640            print("生成詳細報告...")641            sys.generate_detailed_report(results["predictions"], args.report_dir)642 643        print("\n=== 預測摘要 ===")644        s = results["summary"]645        print(f"總句子數: {results['total_sentences']}")646        print(f"平均信心度: {s.get('average_confidence', 'N/A')}")647        print(f"平均流利度: {s.get('average_fluency_score', 'N/A')}")648        print(f"最常見預測: {s.get('most_common_prediction', 'N/A')}")649        print("\n類別分佈:")650        for name, count in s.get("classification_distribution", {}).items():651            pct = s.get("classification_percentages", {}).get(name, "0%")652            print(f"  {name}: {count} ({pct})")653        print(f"\n結果已保存到: {args.output_file}")654    except Exception as e:655        print(f"錯誤: {str(e)}")656        import traceback; traceback.print_exc()657 658if __name__ == "__main__":659    main()660