Doan-NLP's picture
Update app.py
2344c48 verified
Raw
History Blame Contribute Delete
17 kB
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)
@st.cache_resource
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)
}
)