# -*- coding: utf-8 -*- """ Hugging Face Spaces / Gradio app 步驟導引:資料 → 切分/訓練 → 評估(Accuracy+混淆矩陣) → 測試介面 資料欄位需為: text, label (label 為 0/1) """ import io import numpy as np import pandas as pd import matplotlib.pyplot as plt import re import gradio as gr from sklearn.model_selection import train_test_split from sklearn.pipeline import Pipeline from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, classification_report, confusion_matrix # ------------------------- # 內建示範資料(30 筆) # ------------------------- SAMPLE_DATA = [ ("這家餐廳超好吃,我下次還要來!", 1), ("服務人員很親切,體驗很棒", 1), ("環境乾淨、氣氛舒服,推!", 1), ("口味不錯,份量也剛好", 1), ("真的超難吃,後悔來了", 0), ("太鹹又冷掉,失望", 0), ("等很久,服務態度也不好", 0), ("分量很少,完全不值這個價錢", 0), ("便宜又好吃,CP值很高", 1), ("味道一般般,下次可能不會再來", 0), ("甜點驚艷!好吃到想哭", 1), ("餐點很油膩,吃完不舒服", 0), ("飲料清爽解膩,搭配主餐剛好", 1), ("今天的餐點都冷掉了…", 0), ("出餐速度快,餐點擺盤也很漂亮", 1), ("座位太擁擠,聊天很吵", 0), ("牛肉很嫩、湯頭很香", 1), ("海鮮新鮮沒有腥味,值得再訪", 1), ("價格偏高,內容卻普通", 0), ("外帶包裝用心,回家吃也很好吃", 1), ("收銀動線很亂,結帳排超久", 0), ("主廚特餐驚喜連連,口味層次豐富", 1), ("烤物有點焦味,失望", 0), ("店內油煙味太重", 0), ("小菜與主餐搭配出色,份量足", 1), ("白飯硬到像沒熟", 0), ("服務貼心會主動加水與關心口味", 1), ("臨時加點又等了二十分鐘", 0), ("週年限定套餐超值,朋友都喜歡", 1), ("衛生紙與餐具不足,需要一直跟店員拿", 0), ] SAMPLE_DF = pd.DataFrame(SAMPLE_DATA, columns=["text", "label"]) # ------------------------- # 工具函式(仍保留,雖目前 UI 不再使用上傳/貼上) # ------------------------- def read_csv_file(file_obj) -> pd.DataFrame: return pd.read_csv(file_obj, encoding_errors="ignore") def read_csv_text(text_block: str) -> pd.DataFrame: return pd.read_csv(io.StringIO(text_block)) def check_df(df: pd.DataFrame) -> str: cols = set(df.columns.str.lower()) need = {"text", "label"} if not need.issubset(cols): return "❌ CSV 需要兩個欄位:text, label(0/1)。" if df.empty: return "❌ 資料為空。" return "" def normalize(text: str) -> str: t = text t = re.sub(r"(太|超|非常)?好吃(死了|到爆|極了)?", " POS_GOOD ", t) t = re.sub(r"(太|超|非常)?難吃(死了|到爆|極了)?", " NEG_BAD ", t) t = re.sub(r"(不|沒|沒有)", " NOT ", t) return t def build_pipeline() -> Pipeline: """字 n-gram + TF-IDF + Logistic Regression""" return Pipeline([ ("vect", CountVectorizer(preprocessor=normalize, analyzer="char", ngram_range=(2, 5))), ("tfidf", TfidfTransformer()), ("clf", LogisticRegression(max_iter=200, class_weight="balanced", C=0.8, solver="liblinear")), ]) def plot_confusion_matrix(y_true, y_pred, labels=("負面(0)", "正面(1)")): cm = confusion_matrix(y_true, y_pred) fig, ax = plt.subplots(figsize=(4, 3)) im = ax.imshow(cm, cmap="Blues") ax.set_title("Confusion Matrix - Logistic Regression") ax.set_xlabel("Predicted") ax.set_ylabel("True") ax.set_xticks([0, 1]); ax.set_yticks([0, 1]) ax.set_xticklabels(labels); ax.set_yticklabels(labels) for i in range(cm.shape[0]): for j in range(cm.shape[1]): ax.text(j, i, cm[i, j], ha="center", va="center", color="black") fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) fig.tight_layout() return fig # ------------------------- # Step1:載入/追加 # ------------------------- def use_sample_dataset(): df = SAMPLE_DF.copy() msg = f"✅ 已載入內建示範資料,共 {len(df)} 筆。" return df, df.iloc[::-1], msg def append_example(df, new_text, new_label): """把一筆 (text, label) 追加到現有資料集""" if df is None or not isinstance(df, pd.DataFrame) or df.empty: df = SAMPLE_DF.copy() # 保險:尚未載入時以內建資料起始 if not new_text or not str(new_text).strip(): return df, df.iloc[::-1], "❌ 請先輸入文字。" try: y = int(new_label) if y not in (0, 1): raise ValueError except Exception: return df, df.iloc[::-1], "❌ 標籤須為 0 或 1。" new_row = pd.DataFrame([[str(new_text).strip(), y]], columns=["text", "label"]) df2 = pd.concat([df, new_row], ignore_index=True) msg = f"✅ 已加入 1 筆,目前共 {len(df2)} 筆。" return df2, df2.iloc[::-1],msg # ------------------------- # Step2:訓練與評估 # ------------------------- def train_and_eval(df, test_ratio): if df is None: return None, "❌ 請先載入資料。", None, None df = df.dropna(subset=["text", "label"]).copy() df["label"] = df["label"].astype(int) X_train, X_test, y_train, y_test = train_test_split( df["text"], df["label"], test_size=test_ratio, random_state=42, stratify=df["label"] ) model = build_pipeline() model.fit(X_train, y_train) y_pred = model.predict(X_test) acc = accuracy_score(y_test, y_pred) rpt = classification_report(y_test, y_pred, digits=3) rpt_text = f"Accuracy: {acc:.3f}\n\n{rpt}" fig = plot_confusion_matrix(y_test, y_pred) dist = df["label"].value_counts().sort_index().to_dict() tip = f"資料分布:0→{dist.get(0,0)} , 1→{dist.get(1,0)}" return model, rpt_text + "\n" + tip, fig, "✅ 訓練完成,可以到下一步做測試。" # ------------------------- # Step3:單句推論 # ------------------------- def predict_one(text, model): if model is None: return "❌ 尚未訓練模型。" if not text or not text.strip(): return "請先輸入文字。" proba = model.predict_proba([text])[0][1] label = "正面(1)" if proba >= 0.5 else "負面(0)" return f"{label}(信心 {proba:.2f})" # ------------------------- # 介面 # ------------------------- with gr.Blocks(title="情感分類小幫手(LogReg + 字 n-gram)", theme=gr.themes.Soft()) as demo: gr.Markdown("## 情感分類小幫手(LogReg + 字 n-gram)\n一步一步完成:資料 → 訓練 → 評估 → 測試") # 讓學生進入頁面就有 30 筆資料 state_df = gr.State(SAMPLE_DF.copy()) state_model = gr.State() with gr.Tab("Step 1|建立/擴充資料集"): gr.Markdown( "本活動不需上傳檔案,**直接用內建 30 筆資料**,並可在下方 **新增一筆 (text,label)** 進行擴充。\n" "- label:0=負面、1=正面\n" ) # 操作列:載入預設資料 + 新增一筆 with gr.Row(): btn_use_sample = gr.Button("重新載入內建示範資料(30筆)") with gr.Row(): add_text = gr.Textbox(label="新增一筆文字", placeholder="例:上菜超快,服務又好!", lines=2) add_label = gr.Radio(choices=[0, 1], value=1, label="標籤 (0=負面, 1=正面)") btn_add = gr.Button("加入到資料集") msg1 = gr.Markdown() df_preview = gr.Dataframe( headers=["text", "label"], label="資料預覽(全部資料)", wrap=True, interactive=False, value=SAMPLE_DF.iloc[::-1] ) # 綁定事件 btn_use_sample.click(fn=use_sample_dataset, outputs=[state_df, df_preview, msg1]) btn_add.click(fn=append_example, inputs=[state_df, add_text, add_label], outputs=[state_df, df_preview, msg1]) with gr.Tab("Step 2|切分與訓練"): gr.Markdown("設定測試集比例,按下 **開始訓練**。") split = gr.Slider(0.1, 0.5, value=0.3, step=0.05, label="測試集比例 test_size") btn_train = gr.Button("開始訓練") train_msg = gr.Markdown() report_box = gr.Textbox(label="評估報告(Accuracy / Precision / Recall / F1)", lines=12) cm_plot = gr.Plot(label="混淆矩陣") btn_train.click( fn=train_and_eval, inputs=[state_df, split], outputs=[state_model, report_box, cm_plot, train_msg] ) with gr.Tab("Step 3|測試/推論"): gr.Markdown("單句測試") with gr.Row(): test_text = gr.Textbox(label="單句輸入", placeholder="例:這家好吃到爆!", lines=2) btn_pred = gr.Button("預測") pred_out = gr.Textbox(label="結果", interactive=False) btn_pred.click(fn=predict_one, inputs=[test_text, state_model], outputs=[pred_out]) if __name__ == "__main__": demo.launch()