Spaces:
Sleeping
Sleeping
| # -*- 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() | |