NhanChanDeo
Upload Vietnamese Toxic Comment Classification project
8058cb3
Raw
History Blame Contribute Delete
28.8 kB
from __future__ import annotations
import json
import re
import warnings
from collections import Counter
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
import joblib
import numpy as np
import pandas as pd
import streamlit as st
import torch
import torch.nn as nn
from pyvi import ViTokenizer
from sklearn.metrics import accuracy_score, precision_recall_fscore_support
from torch.utils.data import DataLoader, Dataset
try:
from teencode_normalizer import normalize_teencode
except ImportError:
def normalize_teencode(t: str) -> str:
return t
warnings.filterwarnings("ignore")
st.set_page_config(
page_title="DLNLP Demo",
page_icon="🧠",
layout="wide",
initial_sidebar_state="collapsed",
)
PROJECT_ROOT = Path(__file__).resolve().parent
DATA_DIR = PROJECT_ROOT / "data"
MODEL_DIR = PROJECT_ROOT / "outputs" / "models"
RESULT_DIR = PROJECT_ROOT / "outputs" / "results"
FIG_DIR = PROJECT_ROOT / "outputs" / "figures"
PHOBERT_DIR = MODEL_DIR / "phobert_base"
DEBERTA_DIR = MODEL_DIR / "deberta"
LABEL_MAP = {0: "CLEAN", 1: "OFFENSIVE", 2: "HATE"}
LABEL_VI = {0: "Bình thường", 1: "Công kích", 2: "Thù ghét"}
LABEL_COLOR = {0: "#16a34a", 1: "#f59e0b", 2: "#dc2626"}
LABEL_BG = {0: "#dcfce7", 1: "#ffedd5", 2: "#fee2e2"}
LABEL_TEXT = {0: "#14532d", 1: "#7c2d12", 2: "#7f1d1d"}
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
SAMPLES = {
"Câu tích cực": "Bài viết này rất hữu ích, cảm ơn bạn đã chia sẻ.",
"Câu công kích nhẹ": "Đọc mà chán thật, nói năng kiểu này thì ai chịu nổi.",
"Câu thù ghét": "Mấy đứa đó đúng là phải biến khỏi đây ngay.",
"Câu trung tính": "Hôm nay trời khá mát, mình vừa đi học về.",
}
class BiLSTMClassifier(nn.Module):
def __init__(self, vocab_size: int, embed_dim: int, hidden_dim: int, num_classes: int, num_layers: int = 2, dropout: float = 0.4):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.emb_dropout = nn.Dropout(dropout)
self.lstm = nn.LSTM(
input_size=embed_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
bidirectional=True,
dropout=dropout if num_layers > 1 else 0.0,
)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_dim * 4, num_classes)
def forward(self, input_ids, lengths):
emb = self.emb_dropout(self.embedding(input_ids))
packed = nn.utils.rnn.pack_padded_sequence(
emb,
lengths.cpu(),
batch_first=True,
enforce_sorted=False,
)
packed_out, _ = self.lstm(packed)
output, _ = nn.utils.rnn.pad_packed_sequence(
packed_out,
batch_first=True,
total_length=input_ids.size(1),
)
mask = (input_ids != 0).unsqueeze(-1)
output_masked = output.masked_fill(~mask, -1e9)
max_pool = output_masked.max(dim=1).values
sum_pool = (output * mask).sum(dim=1)
valid_len = mask.sum(dim=1).clamp(min=1)
mean_pool = sum_pool / valid_len
feat = torch.cat([max_pool, mean_pool], dim=1)
logits = self.fc(self.dropout(feat))
return logits
class TextDataset(Dataset):
def __init__(self, texts, vocab, max_len):
self.texts = list(texts)
self.vocab = vocab
self.max_len = max_len
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
input_ids, length = encode_text(self.texts[idx], self.vocab, self.max_len)
return {
"input_ids": torch.tensor(input_ids, dtype=torch.long),
"length": torch.tensor(length, dtype=torch.long),
}
@st.cache_resource(show_spinner=False)
def load_svm_artifacts() -> Tuple[Any, Any]:
vectorizer = joblib.load(MODEL_DIR / "tfidf_vectorizer.joblib")
model = joblib.load(MODEL_DIR / "svm_model.joblib")
return vectorizer, model
@st.cache_resource(show_spinner=False)
def load_bilstm_artifacts() -> Optional[Dict[str, Any]]:
vocab_path = MODEL_DIR / "bilstm_vocab.json"
model_path = MODEL_DIR / "bilstm_best.pt"
metrics_path = RESULT_DIR / "bilstm_metrics.csv"
if not vocab_path.exists() or not model_path.exists() or not metrics_path.exists():
return None
vocab = json.loads(vocab_path.read_text(encoding="utf-8"))
metrics_df = pd.read_csv(metrics_path)
if metrics_df.empty:
return None
row = metrics_df.iloc[0]
max_len = int(row.get("max_len", 64))
embed_dim = int(row.get("embed_dim", 300))
hidden_dim = int(row.get("hidden_dim", 128))
num_layers = int(row.get("num_layers", 2))
model = BiLSTMClassifier(
vocab_size=len(vocab),
embed_dim=embed_dim,
hidden_dim=hidden_dim,
num_classes=3,
num_layers=num_layers,
dropout=0.4,
).to(DEVICE)
state_dict = torch.load(model_path, map_location=DEVICE)
model.load_state_dict(state_dict)
model.eval()
return {
"model": model,
"vocab": vocab,
"max_len": max_len,
"embed_dim": embed_dim,
"hidden_dim": hidden_dim,
"num_layers": num_layers,
}
@st.cache_resource(show_spinner=False)
def find_phobert_model_dir() -> Optional[Path]:
if not PHOBERT_DIR.exists():
return None
candidates = []
for pattern in ("**/config.json", "**/pytorch_model.bin", "**/model.safetensors"):
for file_path in PHOBERT_DIR.glob(pattern):
candidates.append(file_path.parent)
if not candidates:
return None
unique_dirs = []
seen = set()
for candidate in candidates:
resolved = candidate.resolve()
if resolved not in seen:
seen.add(resolved)
unique_dirs.append(candidate)
return max(unique_dirs, key=lambda item: item.stat().st_mtime)
@st.cache_resource(show_spinner=False)
def load_phobert_artifacts() -> Optional[Dict[str, Any]]:
model_dir = find_phobert_model_dir()
if model_dir is None:
return None
try:
from transformers.models.auto.modeling_auto import AutoModelForSequenceClassification
from transformers.models.auto.tokenization_auto import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(model_dir)
model = AutoModelForSequenceClassification.from_pretrained(model_dir).to(DEVICE)
model.eval()
return {"tokenizer": tokenizer, "model": model, "model_dir": model_dir}
except Exception:
return None
@st.cache_resource(show_spinner=False)
def find_deberta_model_dir() -> Optional[Path]:
if not DEBERTA_DIR.exists():
return None
candidates = []
for pattern in ("**/config.json", "**/pytorch_model.bin", "**/model.safetensors"):
for file_path in DEBERTA_DIR.glob(pattern):
candidates.append(file_path.parent)
if not candidates:
return None
unique_dirs = []
seen = set()
for candidate in candidates:
resolved = candidate.resolve()
if resolved not in seen:
seen.add(resolved)
unique_dirs.append(candidate)
return max(unique_dirs, key=lambda item: item.stat().st_mtime)
@st.cache_resource(show_spinner=False)
def load_deberta_artifacts() -> Optional[Dict[str, Any]]:
model_dir = find_deberta_model_dir()
if model_dir is None:
return None
try:
from transformers import AutoModelForSequenceClassification, DebertaV2Tokenizer
tokenizer = DebertaV2Tokenizer.from_pretrained(model_dir)
model = AutoModelForSequenceClassification.from_pretrained(model_dir).to(DEVICE)
model.eval()
return {"tokenizer": tokenizer, "model": model, "model_dir": model_dir}
except Exception:
return None
@st.cache_data(show_spinner=False)
def clean_text(text: str) -> str:
text = "" if text is None else str(text)
text = text.replace("\n", " ").replace("\t", " ")
text = re.sub(r"https?://\S+|www\.\S+", "[URL]", text)
text = re.sub(r"\s+", " ", text).strip()
return text
@st.cache_data(show_spinner=False)
def segment_text(text: str) -> str:
text = "" if text is None else str(text)
text = re.sub(r"\s+", " ", text.strip().lower())
return ViTokenizer.tokenize(text)
@st.cache_data(show_spinner=False)
def tokenize_text(text: str):
return str(text).split()
@st.cache_data(show_spinner=False)
def encode_text(text: str, vocab: Dict[str, int], max_len: int):
tokens = tokenize_text(text)[:max_len]
ids = [vocab.get(token, 1) for token in tokens]
if len(ids) == 0:
ids = [1]
length = len(ids)
if length < max_len:
ids += [0] * (max_len - length)
return ids, length
@torch.no_grad()
def predict_bilstm(text: str, artifacts: Dict[str, Any]):
model = artifacts["model"]
vocab = artifacts["vocab"]
max_len = artifacts["max_len"]
seg_text = segment_text(clean_text(text))
input_ids, length = encode_text(seg_text, vocab, max_len)
batch = {
"input_ids": torch.tensor([input_ids], dtype=torch.long, device=DEVICE),
"length": torch.tensor([length], dtype=torch.long, device=DEVICE),
}
logits = model(batch["input_ids"], batch["length"])
probs = torch.softmax(logits, dim=1).squeeze(0).detach().cpu().numpy()
pred_id = int(np.argmax(probs))
return pred_id, probs
@torch.no_grad()
def predict_phobert(text: str, artifacts: Dict[str, Any]):
tokenizer = artifacts["tokenizer"]
model = artifacts["model"]
seg_text = segment_text(clean_text(text))
encoding = tokenizer(
[seg_text],
truncation=True,
padding=True,
max_length=128,
return_tensors="pt",
)
encoding = {key: value.to(DEVICE) for key, value in encoding.items()}
outputs = model(**encoding)
logits = outputs.logits
probs = torch.softmax(logits, dim=1).squeeze(0).detach().cpu().numpy()
pred_id = int(np.argmax(probs))
return pred_id, probs
@torch.no_grad()
def predict_deberta(text: str, artifacts: Dict[str, Any]):
tokenizer = artifacts["tokenizer"]
model = artifacts["model"]
# DeBERTa uses teencode normalizer instead of ViTokenizer
normalized_text = normalize_teencode(text)
clean_t = clean_text(normalized_text)
encoding = tokenizer(
[clean_t],
truncation=True,
padding=False,
max_length=256,
return_tensors="pt",
)
encoding = {key: value.to(DEVICE) for key, value in encoding.items()}
outputs = model(**encoding)
logits = outputs.logits
probs = torch.softmax(logits, dim=1).squeeze(0).detach().cpu().numpy()
pred_id = int(np.argmax(probs))
return pred_id, probs
@st.cache_resource(show_spinner=False)
def load_models():
svm_vectorizer, svm_model = load_svm_artifacts()
bilstm_artifacts = load_bilstm_artifacts()
phobert_artifacts = load_phobert_artifacts()
deberta_artifacts = load_deberta_artifacts()
return {
"svm": (svm_vectorizer, svm_model),
"bilstm": bilstm_artifacts,
"phobert": phobert_artifacts,
"deberta": deberta_artifacts,
}
def predict_svm(text: str, artifacts: Tuple[Any, Any]):
vectorizer, model = artifacts
cleaned = clean_text(text)
features = vectorizer.transform([cleaned])
pred_id = int(model.predict(features)[0])
if hasattr(model, "predict_proba"):
probs = model.predict_proba(features)[0]
elif hasattr(model, "decision_function"):
scores = model.decision_function(features)
if np.ndim(scores) == 1:
scores = np.column_stack([-scores, scores])
probs = torch.softmax(torch.tensor(scores, dtype=torch.float32), dim=1).squeeze(0).detach().cpu().numpy()
else:
probs = None
return pred_id, probs
def top2_labels(probs: Optional[np.ndarray]) -> str:
if probs is None:
return "Top 2: N/A"
probs = np.asarray(probs, dtype=float)
if probs.size == 0:
return "Top 2: N/A"
order = probs.argsort()[::-1][:2]
parts = [f"{LABEL_MAP.get(int(index), 'Unknown')} {probs[int(index)]:.1%}" for index in order]
return f"Top 2: {' | '.join(parts)}"
def _safe_label_id(label_id: Optional[int]) -> Optional[int]:
if label_id is None:
return None
try:
label_id = int(label_id)
except Exception:
return None
return label_id if label_id in LABEL_MAP else None
def _format_confidence(conf: Optional[float]) -> str:
if conf is None:
return "N/A"
try:
return f"{float(conf) * 100:.1f}%"
except Exception:
return "N/A"
def confidence_from_probs(pred_id: Optional[int], probs: Optional[np.ndarray]) -> Optional[float]:
safe_id = _safe_label_id(pred_id)
if safe_id is None or probs is None:
return None
try:
probs = np.asarray(probs, dtype=float)
if probs.size == 0:
return None
return float(probs[safe_id])
except Exception:
return None
def majority_vote(pred_ids: list[int]) -> int:
valid = [pred for pred in pred_ids if pred in LABEL_MAP]
if not valid:
return 0
return Counter(valid).most_common(1)[0][0]
def consensus_status(pred_ids: list[int]) -> bool:
valid = [pred for pred in pred_ids if pred in LABEL_MAP]
if len(valid) <= 1:
return True
return len(set(valid)) == 1
def render_model_card(model_name: str, label_id: Optional[int], confidence: Optional[float], *, available: bool):
safe_id = _safe_label_id(label_id)
if not available:
label_code = "-"
label_primary = "KHÔNG KHẢ DỤNG"
elif safe_id is None:
label_code = "-"
label_primary = "CHƯA DỰ ĐOÁN"
else:
label_code = LABEL_MAP[safe_id]
label_primary = label_code
bg = "#f1f5f9" if safe_id is None else LABEL_BG[safe_id]
border = "#94a3b8" if safe_id is None else LABEL_COLOR[safe_id]
text = "#0f172a" if safe_id is None else LABEL_TEXT[safe_id]
conf_text = _format_confidence(confidence) if available and safe_id is not None else "N/A"
st.markdown(
f"""
<div class="model-card" style="background: {bg}; border: 1px solid rgba(148,163,184,0.35);">
<div class="model-card__head">
<div class="model-card__name">{model_name}</div>
<div class="model-card__status">{'Sẵn sàng' if available else 'Thiếu artifact'}</div>
</div>
<div class="model-card__label" style="color: {text};">{label_primary}</div>
<div class="model-card__meta">
<span class="pill" style="border-color:{border}; color:{border};">{label_code}</span>
<span class="confidence">Độ tin cậy: <b>{conf_text}</b></span>
</div>
</div>
""",
unsafe_allow_html=True,
)
def render_final_conclusion(label_id: int, confidence: Optional[float] = None, note: Optional[str] = None):
safe_id = _safe_label_id(label_id) or 0
bg = LABEL_BG[safe_id]
border = LABEL_COLOR[safe_id]
text = LABEL_TEXT[safe_id]
conf_text = _format_confidence(confidence)
note_html = "" if not note else f"<div class='final-note'>{note}</div>"
st.markdown(
f"""
<div class="final-card" style="background:{bg}; border: 1px solid rgba(148,163,184,0.35);">
<div class="final-card__head">
<div class="final-card__title">Kết luận cuối</div>
<span class="pill" style="background:{border}; color:white; border-color:{border};">{LABEL_MAP[safe_id]}</span>
</div>
<div class="final-card__label" style="color:{text};">{LABEL_MAP[safe_id]}</div>
<div class="final-card__meta">Độ tin cậy (tham khảo): <b>{conf_text}</b></div>
{note_html}
</div>
""",
unsafe_allow_html=True,
)
def inject_ui_css():
st.markdown(
"""
<style>
.stApp {
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
}
.block-container {
padding-top: 1.2rem;
padding-bottom: 2.2rem;
}
section[data-testid="stSidebar"] .block-container {
padding-top: 1.2rem;
}
.sidebar-card {
padding: 0.9rem 0.95rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.25);
}
.status-row {
display: flex;
justify-content: space-between;
gap: 0.6rem;
margin: 0.3rem 0;
font-size: 0.92rem;
color: #0f172a;
}
.status-row span {
color: #475569;
}
.hero {
padding: 1.15rem 1.25rem;
border-radius: 22px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(148, 163, 184, 0.25);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
margin-bottom: 1rem;
}
.hero h1 {
margin: 0;
font-size: 1.85rem;
color: #0f172a;
}
.hero p {
margin: 0.4rem 0 0 0;
color: #475569;
font-size: 1rem;
line-height: 1.55;
}
.model-card {
padding: 1.0rem 1.05rem;
border-radius: 18px;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.06);
min-height: 140px;
}
.model-card__head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 0.55rem;
}
.model-card__name {
font-size: 0.95rem;
font-weight: 800;
color: #0f172a;
letter-spacing: 0.02em;
}
.model-card__status {
font-size: 0.82rem;
color: #64748b;
}
.model-card__label {
font-size: 1.25rem;
font-weight: 900;
margin-bottom: 0.55rem;
}
.model-card__meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 999px;
padding: 0.18rem 0.55rem;
font-weight: 800;
font-size: 0.82rem;
background: rgba(255, 255, 255, 0.5);
}
.confidence {
color: #0f172a;
font-size: 0.9rem;
}
.final-card {
padding: 1.15rem 1.2rem;
border-radius: 20px;
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.09);
}
.final-card__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
margin-bottom: 0.45rem;
}
.final-card__title {
font-size: 1.0rem;
font-weight: 900;
color: #0f172a;
}
.final-card__label {
font-size: 1.55rem;
font-weight: 950;
margin-bottom: 0.25rem;
}
.final-card__meta {
color: #334155;
font-size: 0.95rem;
}
.final-note {
margin-top: 0.6rem;
color: #475569;
font-size: 0.92rem;
line-height: 1.55;
}
div[data-testid="stTextArea"] textarea {
border-radius: 16px;
}
button[data-testid="baseButton-primary"] {
border-radius: 14px !important;
padding: 0.55rem 1rem !important;
border: 1px solid rgba(59, 130, 246, 0.35) !important;
background: linear-gradient(90deg, #2563eb 0%, #4f46e5 100%) !important;
color: white !important;
font-weight: 800 !important;
}
button[data-testid="baseButton-secondary"] {
border-radius: 14px !important;
}
</style>
""",
unsafe_allow_html=True,
)
inject_ui_css()
st.markdown(
"""
<div class="hero">
<h1>Phân loại bình luận độc hại tiếng Việt</h1>
<p>Nhập một bình luận bất kỳ và so sánh dự đoán từ các mô hình: TF‑IDF + SVM, BiLSTM, PhoBERT và DeBERTa.</p>
</div>
""",
unsafe_allow_html=True,
)
models = None
with st.spinner("Đang tải mô hình đã lưu..."):
try:
models = load_models()
except Exception as exc:
st.error(f"Không thể tải model: {exc}")
st.stop()
svm_available = (MODEL_DIR / "tfidf_vectorizer.joblib").exists() and (MODEL_DIR / "svm_model.joblib").exists()
bilstm_available = models["bilstm"] is not None
phobert_available = models["phobert"] is not None
deberta_available = models["deberta"] is not None
with st.sidebar:
st.markdown("### Demo")
st.markdown(
f"""
<div class="sidebar-card">
<div class="status-row"><b>Thiết bị</b><span>{DEVICE}</span></div>
<div class="status-row"><b>SVM</b><span>{'Sẵn sàng' if svm_available else 'Thiếu artifact'}</span></div>
<div class="status-row"><b>BiLSTM</b><span>{'Sẵn sàng' if bilstm_available else 'Thiếu artifact'}</span></div>
<div class="status-row"><b>PhoBERT</b><span>{'Sẵn sàng' if phobert_available else 'Thiếu artifact'}</span></div>
<div class="status-row"><b>DeBERTa</b><span>{'Sẵn sàng' if deberta_available else 'Thiếu artifact'}</span></div>
</div>
""",
unsafe_allow_html=True,
)
st.markdown("\n")
with st.expander("Văn bản mẫu", expanded=True):
if "input_text" not in st.session_state:
st.session_state["input_text"] = ""
for label, sample_text in SAMPLES.items():
if st.button(label, use_container_width=True):
st.session_state["input_text"] = sample_text
left_col, right_col = st.columns([0.93, 1.47], gap="large")
with left_col:
st.markdown("### Nhập nội dung")
input_text = st.text_area(
"Nội dung bình luận",
key="input_text",
height=220,
placeholder="Nhập một câu bình luận tiếng Việt ở đây...",
label_visibility="collapsed",
)
action_col, helper_col = st.columns([0.33, 0.67], gap="medium")
with action_col:
predict_clicked = st.button("Dự đoán", use_container_width=True, type="primary")
with helper_col:
if st.button("Xóa nội dung", use_container_width=True):
st.session_state["input_text"] = ""
st.session_state.pop("last_results", None)
st.session_state.pop("last_vote", None)
st.rerun()
cleaned_preview = clean_text(input_text)
segmented_preview = segment_text(cleaned_preview)
with st.expander("Tiền xử lý đầu vào", expanded=False):
st.markdown("**Văn bản sau chuẩn hóa**")
st.code(cleaned_preview or "(trống)")
st.markdown("**Văn bản sau tách từ**")
st.code(segmented_preview or "(trống)")
with right_col:
st.markdown("### Kết quả dự đoán")
if "last_results" not in st.session_state:
st.session_state["last_results"] = None
if "last_vote" not in st.session_state:
st.session_state["last_vote"] = None
if predict_clicked and input_text.strip():
results: Dict[str, Dict[str, Any]] = {
"svm": {"name": "TF‑IDF + SVM", "available": svm_available, "pred": None, "probs": None},
"bilstm": {"name": "BiLSTM", "available": bilstm_available, "pred": None, "probs": None},
"phobert": {"name": "PhoBERT", "available": phobert_available, "pred": None, "probs": None},
"deberta": {"name": "DeBERTa", "available": deberta_available, "pred": None, "probs": None},
}
if svm_available:
pred, probs = predict_svm(input_text, models["svm"])
results["svm"].update({"pred": pred, "probs": probs})
if bilstm_available:
pred, probs = predict_bilstm(input_text, models["bilstm"])
results["bilstm"].update({"pred": pred, "probs": probs})
if phobert_available:
pred, probs = predict_phobert(input_text, models["phobert"])
results["phobert"].update({"pred": pred, "probs": probs})
if deberta_available:
pred, probs = predict_deberta(input_text, models["deberta"])
results["deberta"].update({"pred": pred, "probs": probs})
preds_for_vote = [
results["svm"]["pred"],
results["bilstm"]["pred"],
results["phobert"]["pred"],
results["deberta"]["pred"],
]
preds_for_vote = [p for p in preds_for_vote if p is not None]
vote = majority_vote([int(p) for p in preds_for_vote]) if preds_for_vote else 0
st.session_state["last_results"] = results
st.session_state["last_vote"] = vote
results = st.session_state.get("last_results")
vote = st.session_state.get("last_vote")
cards = st.columns(4, gap="large")
if results is None:
with cards[0]:
render_model_card("TF‑IDF + SVM", None, None, available=svm_available)
with cards[1]:
render_model_card("BiLSTM", None, None, available=bilstm_available)
with cards[2]:
render_model_card("PhoBERT", None, None, available=phobert_available)
with cards[3]:
render_model_card("DeBERTa", None, None, available=deberta_available)
st.info("Nhập một bình luận và bấm **Dự đoán** để xem kết quả.")
else:
svm_pred = results["svm"]["pred"]
bilstm_pred = results["bilstm"]["pred"]
phobert_pred = results["phobert"]["pred"]
deberta_pred = results["deberta"]["pred"]
svm_conf = confidence_from_probs(svm_pred, results["svm"]["probs"])
bilstm_conf = confidence_from_probs(bilstm_pred, results["bilstm"]["probs"])
phobert_conf = confidence_from_probs(phobert_pred, results["phobert"]["probs"])
deberta_conf = confidence_from_probs(deberta_pred, results["deberta"]["probs"])
with cards[0]:
render_model_card("TF‑IDF + SVM", svm_pred, svm_conf, available=bool(results["svm"]["available"]))
with cards[1]:
render_model_card("BiLSTM", bilstm_pred, bilstm_conf, available=bool(results["bilstm"]["available"]))
with cards[2]:
render_model_card("PhoBERT", phobert_pred, phobert_conf, available=bool(results["phobert"]["available"]))
with cards[3]:
render_model_card("DeBERTa", deberta_pred, deberta_conf, available=bool(results["deberta"]["available"]))
st.caption("Diễn giải nhãn: CLEAN = Bình thường · OFFENSIVE = Công kích · HATE = Thù ghét")
pred_ids = [
int(svm_pred) if svm_pred is not None else -1,
int(bilstm_pred) if bilstm_pred is not None else -1,
int(phobert_pred) if phobert_pred is not None else -1,
int(deberta_pred) if deberta_pred is not None else -1,
]
is_consensus = consensus_status(pred_ids)
st.markdown("\n")
if not is_consensus:
st.warning("Đây là trường hợp khó, các mô hình đưa ra dự đoán khác nhau.")
st.markdown("\n")
note = "Theo nguyên tắc đa số phiếu từ các mô hình khả dụng." if vote is not None else None
final_label = int(vote or 0)
final_conf_candidates: list[float] = []
for pred, conf in (
(svm_pred, svm_conf),
(bilstm_pred, bilstm_conf),
(phobert_pred, phobert_conf),
(deberta_pred, deberta_conf),
):
if pred is None or conf is None:
continue
try:
if int(pred) == final_label:
final_conf_candidates.append(float(conf))
except Exception:
continue
final_conf = float(np.mean(final_conf_candidates)) if final_conf_candidates else None
render_final_conclusion(final_label, final_conf, note)
st.caption("Độ tin cậy là xác suất lớp dự đoán (nếu mô hình có cung cấp).")