Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import pandas as pd | |
| import plotly.express as px | |
| import torch | |
| import torch.nn as nn # Nạp thư viện PyTorch để định nghĩa lớp Custom | |
| from transformers import AutoModel, AutoTokenizer | |
| import os | |
| import re | |
| # Nạp module tiền xử lý độc lập từ file preprocessing.py của bạn | |
| from preprocessing import preprocess_text_for_Visobert | |
| # ========================================================================= | |
| # 1. CẤU HÌNH GIAO DIỆN STREAMLIT TOÀN MÀN HÌNH & CSS NỚI RỘNG SIDEBAR | |
| # ========================================================================= | |
| st.set_page_config(page_title="Hệ thống Phát hiện Nội dung Độc hại", layout="wide") | |
| st.markdown(""" | |
| <style> | |
| html, body, [data-testid="stAppViewContainer"] { zoom: 0.95; } | |
| .block-container { padding-top: 3.5rem !important; padding-bottom: 1rem; } | |
| .stMetric { background-color: #f8f9fa; padding: 12px; border-radius: 8px; border: 1px solid #e9ecef; } | |
| div[data-testid="stMetricValue"] { font-size: 26px; font-weight: bold; } | |
| /* CSS RÚT NGẮN KHOẢNG CÁCH GIỮA BIỂU ĐỒ VÀ BẢNG TỔNG HỢP */ | |
| .stPlotlyChart { margin-bottom: 0px !important; padding-bottom: 0px !important; } | |
| div[data-testid="stVerticalBlock"] > div { padding-bottom: 0rem !important; margin-bottom: -15px !important; } | |
| .stSubheader { margin-top: -15px !important; margin-bottom: -10px !important; padding: 0px !important; } | |
| hr { margin-top: 0.2rem !important; margin-bottom: 0.5rem !important; } | |
| /* KÉO RỘNG SIDEBAR THEO CHIỀU NGANG: Tăng kích thước mặc định thông thoáng */ | |
| [data-testid="stSidebar"] { | |
| min-width: 360px !important; | |
| max-width: 420px !important; | |
| } | |
| /* KÉO GIÃN CHIỀU CAO SIDEBAR */ | |
| [data-testid="stSidebarUserContent"] { | |
| padding-top: 1.5rem !important; | |
| padding-bottom: 3rem !important; | |
| height: auto !important; | |
| } | |
| /* Cấu hình bắt buộc thanh cuộn luôn hiển thị cố định trong Streamlit Dataframe */ | |
| div[data-testid="stTable"]::-webkit-scrollbar, | |
| div[data-testid="stDataFrameData"]::-webkit-scrollbar, | |
| div[data-testid="data-grid-container"]::-webkit-scrollbar, | |
| .glide-data-grid::-webkit-scrollbar, | |
| div::-webkit-scrollbar { | |
| width: 10px !important; | |
| height: 10px !important; | |
| display: block !important; | |
| } | |
| div::-webkit-scrollbar-track { background: #f1f1f1 !important; border-radius: 5px !important; } | |
| div::-webkit-scrollbar-thumb { background: #c1c1c1 !important; border-radius: 5px !important; } | |
| div::-webkit-scrollbar-thumb:hover { background: #a8a8a8 !important; } | |
| .stProgress > div > div > div > div { | |
| border-radius: 4px !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ========================================================================= | |
| # 2. ĐỊNH NGHĨA LỚP TÙY CHỈNH ĐỒNG BỘ 100% VỚI CODE HUẤN LUYỆN CỦA BẠN | |
| # ========================================================================= | |
| class ViSoBERTClassifier(nn.Module): | |
| def __init__(self, visobert, num_labels=3): | |
| super().__init__() | |
| self.visobert = visobert | |
| self.dropout = nn.Dropout(0.1) | |
| self.fc = nn.Sequential( | |
| nn.Linear(768, 256), | |
| nn.ReLU(), | |
| nn.Dropout(0.1), | |
| nn.Linear(256, num_labels) | |
| ) | |
| def forward(self, input_ids, attention_mask): | |
| outputs = self.visobert( | |
| input_ids=input_ids, | |
| attention_mask=attention_mask | |
| ) | |
| cls = outputs.last_hidden_state[:, 0, :] | |
| cls = self.dropout(cls) | |
| return self.fc(cls) | |
| def load_sentiment_model(): | |
| model_path = "./best_model" | |
| pt_file = os.path.join(model_path, "best_Visobert_mlp.pt") | |
| if not os.path.exists(model_path) or not os.path.exists(pt_file): | |
| st.error(f"❌ Không tìm thấy thư mục hoặc file trọng số PyTorch '{pt_file}'.") | |
| st.stop() | |
| tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False) | |
| visobert_backbone = AutoModel.from_pretrained("uitnlp/visobert") | |
| model = ViSoBERTClassifier(visobert=visobert_backbone, num_labels=3) | |
| # Nạp ma trận nơ-ron lớp MLP 3 nhãn chính xác lên CPU | |
| state_dict = torch.load(pt_file, map_location=torch.device('cpu')) | |
| model.load_state_dict(state_dict) | |
| model.float() | |
| model.eval() | |
| id2label = {0: "🟢 CLEAN", 1: "🔴 TOXIC", 2: "🟠 OFFENSIVE"} | |
| return tokenizer, model, id2label | |
| tokenizer, model, id2label = load_sentiment_model() | |
| # Hàm tính xác suất phần trăm khi người dùng gõ câu mới ở Sidebar | |
| def predict_toxicity_probas(text): | |
| text_processed = preprocess_text_for_Visobert(text) | |
| # Đồng bộ khoảng trắng đầu vào tránh lệch nhãn | |
| text_processed = text_processed.strip() | |
| text_processed = re.sub(r"\s+", " ", text_processed) | |
| if not text_processed.strip() or text_processed.lower() == 'nan': | |
| return "🟢 CLEAN", {"CLEAN": 1.0, "TOXIC": 0.0, "OFFENSIVE": 0.0} | |
| try: | |
| inputs = tokenizer(text_processed, return_tensors="pt", padding=True, truncation=True, max_length=256) | |
| with torch.no_grad(): | |
| logits = model(input_ids=inputs['input_ids'], attention_mask=inputs['attention_mask']) | |
| probs = torch.softmax(logits, dim=1).squeeze().tolist() | |
| predicted_class_id = torch.argmax(logits, dim=1).item() | |
| if isinstance(probs, float): | |
| probs = [probs] | |
| probas_dict = { | |
| "CLEAN": float(probs[0]) if len(probs) > 0 else 0.0, | |
| "TOXIC": float(probs[1]) if len(probs) > 1 else 0.0, | |
| "OFFENSIVE": float(probs[2]) if len(probs) > 2 else 0.0 | |
| } | |
| return id2label[predicted_class_id], probas_dict | |
| except Exception: | |
| return "🟢 CLEAN", {"CLEAN": 1.0, "TOXIC": 0.0, "OFFENSIVE": 0.0} | |
| # ========================================================================= | |
| # 3. ĐỌC DỮ LIỆU ĐÃ GÁN NHÃN SẴN TỪ COLAB (TẬP TEST 3 CỘT: free_text, label_id, Pred) | |
| # ========================================================================= | |
| def load_simulation_data_fixed(): | |
| file_path = "data_test_new.csv" | |
| if os.path.exists(file_path): | |
| try: | |
| df = pd.read_csv(file_path, encoding='utf-8-sig') | |
| df = df.dropna(subset=['free_text']) | |
| df['free_text'] = df['free_text'].astype(str) | |
| df = df[df['free_text'].str.lower() != 'nan'] | |
| df['comment'] = df['free_text'].apply(lambda x: re.sub(r'[\r\n\t]+', ' ', x)) | |
| def normalize_to_emoji_label(x): | |
| x_str = str(x).strip().upper() | |
| if x_str in ["0", "CLEAN"]: return "🟢 CLEAN" | |
| if x_str in ["1", "TOXIC"]: return "🔴 TOXIC" | |
| if x_str in ["2", "OFFENSIVE"]: return "🟠 OFFENSIVE" | |
| return "🟢 CLEAN" | |
| df['label'] = df['label_id'].apply(normalize_to_emoji_label) | |
| df['Dự đoán'] = df['Pred'].apply(normalize_to_emoji_label) | |
| df['is_manual'] = False | |
| return df[['comment', 'label', 'Dự đoán', 'is_manual']] | |
| except Exception: | |
| pass | |
| return pd.DataFrame(columns=['comment', 'label', 'Dự đoán', 'is_manual']) | |
| # Khối lệnh nạp dữ liệu vào Session State | |
| if "df_data" not in st.session_state: | |
| with st.spinner("🔄 Hệ thống đang nạp tập dữ liệu..."): | |
| st.session_state.df_data = load_simulation_data_fixed() | |
| if "last_analysis" not in st.session_state: | |
| st.session_state.last_analysis = None | |
| if "show_sidebar_result" not in st.session_state: | |
| st.session_state.show_sidebar_result = False | |
| df_active = st.session_state.df_data | |
| # ========================================================================= | |
| # 4. THIẾT KẾ GIAO DIỆN CHÍNH & SIDEBAR TỐI GIẢN CHUẨN ĐỊNH DẠNG MỚI | |
| # ========================================================================= | |
| st.title("HỆ THỐNG PHÁT HIỆN NỘI DUNG ĐỘC HẠI") | |
| st.caption("Đồ án môn học: Xử lý ngôn ngữ tự nhiên | Mô hình: VisoBERT| Đề tài: Phát hiện Toxic Comment") | |
| # 🔥 ĐÃ SỬA: Ép phân cấp nút mẫu câu xếp dọc thông thoáng, sửa lỗi phân lớp ghi đè st.sidebar | |
| st.sidebar.markdown("### Danh sách Mẫu Thử Nhanh") | |
| sample_1 = "Hôm nay trời đẹp quá, chúc mọi người một ngày tốt lành!" | |
| sample_2 = "thằng ad làm ăn như hạch, bực cả mình" | |
| sample_3 = "xạo lol vừa thôi" | |
| if st.sidebar.button(sample_1, use_container_width=True): | |
| st.session_state.box_1_input = sample_1 | |
| st.session_state.box_2_input = sample_1 | |
| if st.sidebar.button(sample_2, use_container_width=True): | |
| st.session_state.box_1_input = sample_2 | |
| st.session_state.box_2_input = sample_2 | |
| if st.sidebar.button(sample_3, use_container_width=True): | |
| st.session_state.box_1_input = sample_3 | |
| st.session_state.box_2_input = sample_3 | |
| st.sidebar.markdown("---") | |
| st.sidebar.markdown("### Demo phân loại toxic") | |
| user_comment_1 = st.sidebar.text_area("Nhập nội dung kiểm tra (Lưu thẳng vào bảng):", height=80, key="box_1_input") | |
| if st.sidebar.button("GỬI & XEM KẾT QUẢ", use_container_width=True): | |
| if not user_comment_1.strip(): | |
| st.sidebar.error("⚠️ Vui lòng nhập nội dung!") | |
| else: | |
| pred_label, probas = predict_toxicity_probas(user_comment_1) | |
| st.session_state.last_analysis = {"text": user_comment_1, "label": pred_label, "probas": probas} | |
| st.session_state.show_sidebar_result = True | |
| comment_cleaned = preprocess_text_for_Visobert(user_comment_1) | |
| new_row = pd.DataFrame([{"comment": comment_cleaned, "label": "Phân loại (Demo)", "Dự đoán": pred_label, "is_manual": True}]) | |
| st.session_state.df_data = pd.concat([new_row, st.session_state.df_data], ignore_index=True) | |
| st.rerun() | |
| st.sidebar.markdown("---") | |
| st.sidebar.markdown("### Demo tính năng Chặn độc hại") | |
| user_comment_2 = st.sidebar.text_area("Nhập nội dung đăng tải (Mô phỏng bộ lọc bài):", height=80, key="box_2_input") | |
| if st.sidebar.button("ĐĂNG BÌNH LUẬN", use_container_width=True): | |
| if not user_comment_2.strip(): | |
| st.sidebar.error("⚠️ Vui lòng nhập nội dung!") | |
| else: | |
| pred_label, probas = predict_toxicity_probas(user_comment_2) | |
| st.session_state.last_analysis = {"text": user_comment_2, "label": pred_label, "probas": probas} | |
| st.session_state.show_sidebar_result = True | |
| if "🔴 TOXIC" in pred_label or "🟠 OFFENSIVE" in pred_label: | |
| st.sidebar.error("CHẶN BÀI: Nội dung bình luận vi phạm tiêu chuẩn cộng đồng! Hệ thống từ chối đăng tải. Vui lòng nhập lại câu từ lịch sự hơn.") | |
| else: | |
| comment_cleaned = preprocess_text_for_Visobert(user_comment_2) | |
| new_row = pd.DataFrame([{"comment": comment_cleaned, "label": "Chặn độc hại (Demo)", "Dự đoán": pred_label, "is_manual": True}]) | |
| st.session_state.df_data = pd.concat([new_row, st.session_state.df_data], ignore_index=True) | |
| st.sidebar.success("DUYỆT ĐĂNG: Bình luận an toàn! Đã cập nhật lên hệ thống.") | |
| st.rerun() | |
| # Khối hiển thị kết quả phân tích bằng hàm progress (Có màu sắc theo nhãn cảm xúc) | |
| if st.session_state.show_sidebar_result and st.session_state.last_analysis: | |
| res = st.session_state.last_analysis | |
| lbl_text = res["label"].replace("🟢 ", "").replace("🔴 ", "").replace("🟠 ", "") | |
| max_proba = float(res["probas"][lbl_text]) * 100 | |
| st.sidebar.markdown("---") | |
| st.sidebar.markdown(f"#### KẾT QUẢ PHÂN TÍCH") | |
| st.sidebar.markdown(f"**Trạng thái:** {res['label']}") | |
| st.sidebar.markdown(f"**Độ tin cậy:** `{max_proba:.2f}%`") | |
| st.sidebar.markdown("<br>", unsafe_allow_html=True) | |
| st.sidebar.markdown(f"<small><b>CLEAN: {res['probas']['CLEAN']*100:.1f}%</b></small>", unsafe_allow_html=True) | |
| st.sidebar.markdown(f""" | |
| <div style="background-color:#e9ecef; border-radius:4px; height:8px; margin-bottom:12px;"> | |
| <div style="background-color:#2ecc71; width:{res['probas']['CLEAN']*100}%; height:8px; border-radius:4px;"></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.sidebar.markdown(f"<small><b>TOXIC: {res['probas']['TOXIC']*100:.1f}%</b></small>", unsafe_allow_html=True) | |
| st.sidebar.markdown(f""" | |
| <div style="background-color:#e9ecef; border-radius:4px; height:8px; margin-bottom:12px;"> | |
| <div style="background-color:#e74c3c; width:{res['probas']['TOXIC']*100}%; height:8px; border-radius:4px;"></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.sidebar.markdown(f"<small><b>OFFENSIVE: {res['probas']['OFFENSIVE']*100:.1f}%</b></small>", unsafe_allow_html=True) | |
| st.sidebar.markdown(f""" | |
| <div style="background-color:#e9ecef; border-radius:4px; height:8px; margin-bottom:12px;"> | |
| <div style="background-color:#e67e22; width:{res['probas']['OFFENSIVE']*100}%; height:8px; border-radius:4px;"></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- KHỐI KPI THỐNG KÊ TỔNG HỢP --- | |
| total_cnt = len(df_active) | |
| clean_cnt = len(df_active[df_active["Dự đoán"] == "🟢 CLEAN"]) | |
| toxic_cnt = len(df_active[df_active["Dự đoán"] == "🔴 TOXIC"]) | |
| off_cnt = len(df_active[df_active["Dự đoán"] == "🟠 OFFENSIVE"]) | |
| m1, m2, m3, m4 = st.columns(4) | |
| m1.metric("Tổng số bình luận", f"{total_cnt:,} câu") | |
| m2.metric("Số lượng CLEAN", f"{clean_cnt:,} câu", f"{(clean_cnt/total_cnt)*100:.1f}%" if total_cnt else "0%") | |
| m3.metric("Số lượng TOXIC", f"{toxic_cnt:,} câu", f"{(toxic_cnt/total_cnt)*100:.1f}%" if total_cnt else "0%", delta_color="inverse") | |
| m4.metric("Số lượng OFFENSIVE", f"{off_cnt:,} câu", f"{(off_cnt/total_cnt)*100:.1f}%" if total_cnt else "0%") | |
| # ========================================================================= | |
| # BẢNG 1: CẢNH BÁO KHẨN CẤP | |
| # ========================================================================= | |
| st.markdown("---") | |
| df_emergency = df_active[(df_active["is_manual"] == True) & (df_active["Dự đoán"].isin(["🔴 TOXIC", "🟠 OFFENSIVE"]))] | |
| st.subheader(f"⚠️ CẢNH BẢO NỘI DUNG ĐỘC HẠI: {len(df_emergency)}") | |
| if not df_emergency.empty: | |
| df_disp_emergency = df_emergency.copy()[["comment", "Dự đoán"]] | |
| else: | |
| df_disp_emergency = pd.DataFrame(columns=["comment", "Dự đoán"]) | |
| st.dataframe( | |
| df_disp_emergency, use_container_width=True, hide_index=True, | |
| column_config={ | |
| "comment": st.column_config.TextColumn("Comment", width="large"), | |
| "Dự đoán": st.column_config.TextColumn("nhãn dự đoán", width="small") | |
| } | |
| ) | |
| # ========================================================================= | |
| # KHỐI BIỂU ĐỒ TRỰC QUAN | |
| # ========================================================================= | |
| st.markdown("---") | |
| st.subheader("Biểu đồ kết quả phân loại") | |
| if not df_active.empty: | |
| color_map_global = {"🟢 CLEAN": "#2ecc71", "🔴 TOXIC": "#e74c3c", "🟠 OFFENSIVE": "#e67e22"} | |
| sentiment_counts = df_active["Dự đoán"].value_counts() | |
| sentiment_df = pd.DataFrame({"Phân lớp": sentiment_counts.index, "Solo": sentiment_counts.values}) | |
| fig_bar = px.bar(sentiment_df, x="Phân lớp", y="Solo", color="Phân lớp", color_discrete_map=color_map_global) | |
| fig_bar.update_layout(xaxis=dict(title=dict(text="Nhãn phân loại", standoff=45), automargin=True), yaxis=dict(title_text="Tổng số dòng"), margin=dict(b=100), legend_title_text="Phân lớp") | |
| st.plotly_chart(fig_bar, use_container_width=True) | |
| # ========================================================================= | |
| # BẢNG 2: BẢNG TỔNG HỢP BÌNH LUẬN VÀ PHÂN LOẠI | |
| # ========================================================================= | |
| st.markdown("---") | |
| st.subheader("Bảng tổng hợp bình luận và phân loại") | |
| if not df_active.empty: | |
| df_display_all = df_active.copy()[["comment", "label", "Dự đoán", "is_manual"]] | |
| else: | |
| df_display_all = pd.DataFrame(columns=["comment", "label", "Dự đoán", "is_manual"]) | |
| st.dataframe( | |
| df_display_all, use_container_width=True, hide_index=True, | |
| column_config={ | |
| "comment": st.column_config.TextColumn("Comment", width="large"), | |
| "label": st.column_config.TextColumn("Nhãn gốc thực tế", width="small"), | |
| "Dự đoán": st.column_config.TextColumn("nhãn dự đoán", width="small"), | |
| "is_manual": st.column_config.CheckboxColumn("is_manual", width=80) | |
| } | |
| ) |