Lavender825's picture
Deploy 3W 0624 BERT metadata fusion model
38136ed
Raw
History Blame Contribute Delete
29.4 kB
"""Polished Hugging Face Spaces frontend for ACSA Clothing.
Two-end design:
1. Customer-facing review analyzer.
2. Research dashboard for metrics, ablation, and explanation evidence.
"""
from __future__ import annotations
import csv
import html
import json
import re
import traceback
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, Iterable, List, Tuple
# Keep the app robust across Spaces images where hub/gradio versions differ.
try:
import huggingface_hub as _hf_hub
if not hasattr(_hf_hub, "HfFolder"):
class _HfFolderCompat:
@staticmethod
def get_token():
try:
return _hf_hub.get_token()
except Exception:
return None
@staticmethod
def save_token(token):
return None
@staticmethod
def delete_token():
return None
_hf_hub.HfFolder = _HfFolderCompat
except Exception:
pass
import gradio as gr
from src import config as cfg
from src.inference import AspectPredictor
ROOT = Path(__file__).resolve().parent
REPORT_DIR = ROOT / "reports"
CHECKPOINT_DIR = ROOT / "checkpoints" / "meta_acsa"
CHECKPOINT_PATH = CHECKPOINT_DIR / "best.pt"
META_ENCODER_PATH = ROOT / "data" / "meta_encoder.pkl"
ASPECT_DESCRIPTIONS = {
"SIZE": "Fit, length, sizing accuracy, runs large or small.",
"MATERIAL": "Fabric feel, thickness, breathability, comfort.",
"QUALITY": "Workmanship, durability, seams, washing performance.",
"APPEARANCE": "Color, pattern, image consistency, visual look.",
"STYLE": "Cut, silhouette, fashionability, styling appeal.",
"VALUE": "Price fairness, worthiness, return or repurchase intent.",
}
LABEL_COLORS = {
"Positive": "#15803d",
"Negative": "#b91c1c",
"Not_Mentioned": "#64748b",
}
LABEL_BG = {
"Positive": "#dcfce7",
"Negative": "#fee2e2",
"Not_Mentioned": "#f1f5f9",
}
ASPECT_KEYWORDS = {
"SIZE": ["size", "fit", "fits", "fitting", "small", "large", "big", "tight", "loose", "xl", "medium", "waist", "length", "runs small", "runs large", "too small", "too large"],
"MATERIAL": ["material", "fabric", "cotton", "polyester", "soft", "scratchy", "thin", "thick", "stretch", "breathable", "comfortable"],
"QUALITY": ["quality", "stitch", "stitching", "seam", "wash", "washed", "durable", "cheap", "broke", "broken", "ripped", "tear", "torn", "defect", "zipper", "button"],
"APPEARANCE": ["look", "looks", "color", "colour", "photo", "picture", "beautiful", "cute", "print", "design", "pattern", "shown"],
"STYLE": ["style", "stylish", "flattering", "casual", "formal", "fashion", "silhouette", "cut", "shape"],
"VALUE": ["price", "worth", "value", "money", "expensive", "overpriced", "cheap", "discount", "buy again", "purchase", "return"],
}
NEGATIVE_CUES = {
"bad", "poor", "terrible", "awful", "cheap", "scratchy", "itchy", "thin", "small", "large", "tight", "loose",
"broke", "broken", "ripped", "torn", "overpriced", "return", "returned", "waste", "disappointed", "uncomfortable",
"shrunk", "defective", "wrong",
}
POSITIVE_CUES = {
"love", "loved", "great", "good", "perfect", "excellent", "nice", "soft", "comfortable", "beautiful", "pretty",
"flattering", "worth", "recommend", "amazing", "stylish", "durable", "cute", "buy again",
}
SIZE_NEGATIVE_PATTERNS = [
r"\bruns?\s+(a\s+little\s+)?small\b",
r"\bruns?\s+(a\s+little\s+)?large\b",
r"\btoo\s+(small|large|big|tight|loose)\b",
r"\b(size|fit|fits|fitting)\b[^.!?]{0,35}\b(small|large|big|tight|loose)\b",
]
def _safe_json(path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def _safe_csv(path: Path, limit: int = 80) -> List[Dict[str, str]]:
if not path.exists():
return []
try:
with path.open(newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))[:limit]
except Exception:
return []
def _pct(value: Any) -> str:
try:
return f"{100 * float(value):.2f}%"
except Exception:
return "-"
def _num(value: Any, default: float = 0.0) -> float:
try:
if value in (None, ""):
return default
return float(value)
except Exception:
return default
def _int(value: Any, default: int = 0) -> int:
try:
if value in (None, ""):
return default
return int(float(value))
except Exception:
return default
def _model_issues() -> List[str]:
issues = []
if not CHECKPOINT_PATH.exists():
issues.append("Missing checkpoints/meta_acsa/best.pt")
elif CHECKPOINT_PATH.stat().st_size < 1_000_000:
size = CHECKPOINT_PATH.stat().st_size
try:
head = CHECKPOINT_PATH.read_bytes()[:160].decode("utf-8", errors="replace")
except Exception as exc:
head = f"<could not read file head: {exc}>"
issues.append(
"checkpoints/meta_acsa/best.pt looks too small. "
f"Runtime path={CHECKPOINT_PATH}; runtime size={size} bytes; "
f"file head={head!r}. "
"It may be a Git LFS/Xet pointer or the running Space has not synced the real 303 MB model file."
)
if not (CHECKPOINT_DIR / "tokenizer").exists():
issues.append("Missing checkpoints/meta_acsa/tokenizer/")
if not META_ENCODER_PATH.exists():
issues.append("Missing data/meta_encoder.pkl")
return issues
@lru_cache(maxsize=1)
def _predictor() -> AspectPredictor:
issues = _model_issues()
if issues:
raise FileNotFoundError("; ".join(issues))
return AspectPredictor(checkpoint_dir=CHECKPOINT_DIR)
def _meta(features: str, categories: str, price: Any, avg_rating: Any, rating_count: Any) -> Dict[str, Any]:
return {
"features_text": features or "",
"categories_text": categories or "",
"price": _num(price),
"average_rating": _num(avg_rating),
"rating_number": _int(rating_count),
}
def _status_html() -> str:
issues = _model_issues()
if not issues:
return "<div class='status ok'><b>Model ready.</b> All required inference artifacts are available.</div>"
items = "".join(f"<li>{html.escape(x)}</li>" for x in issues)
return f"<div class='status warn'><b>Model artifacts need attention.</b><ul>{items}</ul></div>"
def _error_html(title: str, exc: Exception) -> str:
msg = html.escape(str(exc) or exc.__class__.__name__)
detail = html.escape(traceback.format_exc(limit=2))
return (
"<div class='error-card'>"
f"<h3>{html.escape(title)}</h3>"
f"<p>{msg}</p>"
f"<details><summary>Technical detail</summary><pre>{detail}</pre></details>"
"</div>"
)
def _contains_phrase(text: str, phrase: str) -> bool:
if " " in phrase:
return phrase in text
return re.search(r"\b" + re.escape(phrase) + r"\b", text) is not None
def _extract_evidence(review_text: str) -> Dict[str, List[str]]:
text = str(review_text or "")
lower = text.lower()
result: Dict[str, List[str]] = {}
for aspect, terms in ASPECT_KEYWORDS.items():
hits: List[str] = []
for term in terms:
if _contains_phrase(lower, term.lower()) and term not in hits:
hits.append(term)
cue_hits = []
for cue in sorted(POSITIVE_CUES | NEGATIVE_CUES):
if _contains_phrase(lower, cue.lower()):
cue_hits.append(cue)
if hits:
merged = hits + [c for c in cue_hits if c not in hits]
else:
merged = []
result[aspect] = merged[:8]
return result
def _confidence(result: Dict[str, Any], aspect: str) -> float:
details = result.get("aspect_details", {}) or {}
val = details.get(aspect, {}).get("confidence") if isinstance(details, dict) else None
return _num(val, 0.0)
def _apply_demo_calibration(result: Dict[str, Any], review_text: str) -> Dict[str, Any]:
"""Conservative display-time calibration for the HF demo.
It prevents overall positivity from spreading to aspects with no textual
evidence, while preserving the raw model confidence in aspect_details.
"""
aspects = dict(result.get("aspects", {}) or {})
evidence = _extract_evidence(review_text)
raw_aspects = dict(aspects)
lower = str(review_text or "").lower()
for aspect in cfg.ASPECTS:
hits = evidence.get(aspect, [])
if aspect == "SIZE" and any(re.search(p, lower) for p in SIZE_NEGATIVE_PATTERNS):
aspects[aspect] = "Negative"
if not hits:
evidence[aspect] = ["size issue"]
continue
if not hits:
# A metadata-only model signal is useful internally, but the demo
# should not claim aspect sentiment without review evidence.
aspects[aspect] = "Not_Mentioned"
elif aspects.get(aspect) == "Not_Mentioned" and _confidence(result, aspect) >= 0.90:
# Keep strong mention signals visible, but do not force sentiment.
aspects[aspect] = raw_aspects.get(aspect, "Not_Mentioned")
result["raw_model_aspects"] = raw_aspects
result["aspects"] = aspects
result["text_evidence_by_aspect"] = evidence
result["display_calibrated"] = True
return result
def _highlight_evidence(evidence: Any) -> str:
if not evidence:
return "<span class='muted'>No explicit text evidence returned.</span>"
if isinstance(evidence, str):
evidence = [evidence]
chips = []
for item in list(evidence)[:5]:
text = str(item).strip()
if text:
chips.append(f"<span class='chip'>{html.escape(text)}</span>")
return "".join(chips) or "<span class='muted'>No explicit text evidence returned.</span>"
def _aspect_cards(result: Dict[str, Any]) -> str:
aspects = result.get("aspects", {}) or {}
evidence = result.get("text_evidence_by_aspect", {}) or {}
details = result.get("aspect_details", {}) or {}
parts = ["<div class='aspect-grid'>"]
for aspect in cfg.ASPECTS:
label = aspects.get(aspect, "Not_Mentioned")
color = LABEL_COLORS.get(label, "#64748b")
bg = LABEL_BG.get(label, "#f1f5f9")
conf = details.get(aspect, {}).get("confidence") if isinstance(details, dict) else None
conf_html = f"<span class='confidence'>conf {float(conf):.2f}</span>" if isinstance(conf, (int, float)) else ""
parts.append(
f"<section class='aspect-card' style='border-top-color:{color}'>"
f"<div class='aspect-top'><b>{html.escape(aspect)}</b>{conf_html}</div>"
f"<div class='label-pill' style='color:{color};background:{bg}'>{html.escape(label)}</div>"
f"<p>{html.escape(ASPECT_DESCRIPTIONS.get(aspect, ''))}</p>"
f"<div class='evidence'>{_highlight_evidence(evidence.get(aspect))}</div>"
"</section>"
)
parts.append("</div>")
return "".join(parts)
def _flatten_attention(result: Dict[str, Any]) -> List[Tuple[str, str, float]]:
rows: List[Tuple[str, str, float]] = []
by_aspect = result.get("meta_attention_by_aspect", {}) or {}
if isinstance(by_aspect, dict):
for aspect, weights in by_aspect.items():
if isinstance(weights, dict):
for name, val in weights.items():
rows.append((str(aspect), str(name), _num(val)))
global_attn = result.get("meta_attention", {}) or {}
if isinstance(global_attn, dict):
for name, val in global_attn.items():
rows.append(("GLOBAL", str(name), _num(val)))
return rows
def _attention_html(result: Dict[str, Any]) -> str:
rows = _flatten_attention(result)
if not rows:
return "<div class='empty'>No metadata attention returned by this checkpoint.</div>"
max_v = max(v for _, _, v in rows) or 1.0
grouped: Dict[str, List[Tuple[str, float]]] = {}
for aspect, name, val in rows:
grouped.setdefault(aspect, []).append((name, val))
parts = ["<div class='attn-grid'>"]
for aspect in ["GLOBAL"] + [a for a in cfg.ASPECTS if a in grouped]:
if aspect not in grouped:
continue
parts.append(f"<section class='attn-card'><h4>{html.escape(aspect)} metadata use</h4>")
for name, val in sorted(grouped[aspect], key=lambda x: x[1], reverse=True):
width = max(5, min(100, 100 * val / max_v))
parts.append(
f"<div class='bar-row'><span>{html.escape(name)}</span>"
f"<div class='bar-bg'><div class='bar-fill' style='width:{width:.1f}%'></div></div>"
f"<em>{val:.3f}</em></div>"
)
parts.append("</section>")
parts.append("</div>")
return "".join(parts)
def _overall_label(result: Dict[str, Any]) -> str:
overall = result.get("overall")
if isinstance(overall, dict):
return str(overall.get("label") or overall.get("prediction") or overall)
if overall:
return str(overall)
labels = list((result.get("aspects") or {}).values())
if labels.count("Negative") >= 1:
return "Negative"
if labels.count("Positive") >= 1:
return "Positive"
return "Neutral / Not enough aspect signal"
def _diagnosis_html(result: Dict[str, Any]) -> str:
aspects = result.get("aspects", {}) or {}
overall = _overall_label(result)
positives = [a for a, v in aspects.items() if v == "Positive"]
negatives = [a for a, v in aspects.items() if v == "Negative"]
meta_summary = result.get("metadata_summary") or {}
top_meta = result.get("top_meta_source") or "-"
risk = "High" if len(negatives) >= 2 or overall == "Negative" else "Medium" if negatives else "Low"
return (
"<section class='diagnosis'>"
"<h3>Business Diagnosis</h3>"
f"<div class='diag-row'><span>Overall</span><b>{html.escape(overall)}</b></div>"
f"<div class='diag-row'><span>Risk level</span><b>{risk}</b></div>"
f"<div class='diag-row'><span>Positive drivers</span><b>{html.escape(', '.join(positives) or '-')}</b></div>"
f"<div class='diag-row'><span>Negative drivers</span><b>{html.escape(', '.join(negatives) or '-')}</b></div>"
f"<div class='diag-row'><span>Top metadata source</span><b>{html.escape(str(top_meta))}</b></div>"
f"<p>{html.escape(_recommendation(positives, negatives))}</p>"
f"<details><summary>Metadata summary</summary><pre>{html.escape(json.dumps(meta_summary, ensure_ascii=False, indent=2))}</pre></details>"
"</section>"
)
def _recommendation(positives: Iterable[str], negatives: Iterable[str]) -> str:
positives = list(positives)
negatives = list(negatives)
if negatives:
return "Prioritize the negative aspects in PDP copy, sizing guidance, QA checks, and return-reason analysis."
if positives:
return "Use the positive aspect signals as merchandising highlights and customer-facing selling points."
return "The review does not contain a strong aspect-level sentiment signal; use it as low-priority qualitative feedback."
def analyze_review(review: str, features: str, categories: str, price: Any, avg_rating: Any, rating_count: Any):
if not str(review or "").strip():
msg = "<div class='empty'>Enter a customer review, then click Analyze Review.</div>"
return msg, "", "", []
try:
result = _predictor().predict(review, _meta(features, categories, price, avg_rating, rating_count))
result = _apply_demo_calibration(result, review)
rows = [[a, result.get("aspects", {}).get(a, "Not_Mentioned"), ASPECT_DESCRIPTIONS.get(a, ""), ", ".join(result.get("text_evidence_by_aspect", {}).get(a, []))] for a in cfg.ASPECTS]
return _aspect_cards(result), _attention_html(result), _diagnosis_html(result), rows
except Exception as exc:
return _error_html("Prediction could not run", exc), "", "", []
def _dashboard_payload() -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
comparison = _safe_json(REPORT_DIR / "evaluation_comparison.json")
overall = comparison.get("overall_3class_comparison", {}) if isinstance(comparison, dict) else {}
proposed = comparison.get("proposed_per_aspect", {}) if isinstance(comparison, dict) else {}
no_meta = comparison.get("acsa_no_meta_per_aspect", {}) if isinstance(comparison, dict) else {}
return overall, proposed, no_meta
def _metric_cards() -> str:
overall, proposed, no_meta = _dashboard_payload()
proposed_overall = overall.get("Proposed_BERT_Meta_Fusion__overall_head", {})
proposed_aspect = proposed.get("overall", {})
no_meta_aspect = no_meta.get("overall", {})
metadata_acc_gain = _num(proposed_aspect.get("mean_accuracy")) - _num(no_meta_aspect.get("mean_accuracy"))
cards = [
("Proposed Overall Accuracy", _pct(proposed_overall.get("accuracy")), "Joint-trained overall head"),
("Proposed Aspect Accuracy", _pct(proposed_aspect.get("mean_accuracy")), "Mean accuracy across six aspects"),
("Metadata Accuracy Gain", f"{metadata_acc_gain:+.4f}", "Proposed vs baseline without metadata"),
]
return "<div class='metric-card-grid'>" + "".join(
f"<section class='metric-card'><span>{html.escape(k)}</span><b>{html.escape(v)}</b><em>{html.escape(s)}</em></section>"
for k, v, s in cards
) + "</div>"
def _overall_accuracy_rows() -> List[List[Any]]:
overall, _, _ = _dashboard_payload()
selected = [
("TF-IDF + Logistic Regression", "Baseline_1_TFIDF_LogReg"),
("BERT Overall Classifier", "Baseline_2_BERT_overall_3class"),
("Proposed BERT + Metadata Fusion", "Proposed_BERT_Meta_Fusion__overall_head"),
]
return [[
label,
round(_num(overall.get(key, {}).get("macro_f1")), 4),
round(_num(overall.get(key, {}).get("accuracy")), 4),
] for label, key in selected]
def _aspect_overall_rows() -> List[List[Any]]:
_, proposed, no_meta = _dashboard_payload()
selected = [
("BERT ACSA (text-only)", no_meta.get("overall", {})),
("Proposed BERT + Metadata Fusion", proposed.get("overall", {})),
]
return [[label, round(_num(block.get("mean_macro_f1")), 4), round(_num(block.get("mean_accuracy")), 4)] for label, block in selected]
def _aspect_rows() -> List[List[Any]]:
_, proposed_block, no_meta_block = _dashboard_payload()
proposed = proposed_block.get("per_aspect", {})
no_meta = no_meta_block.get("per_aspect", {})
rows = []
for aspect in cfg.ASPECTS:
proposed_metrics = proposed.get(aspect, {})
text_metrics = no_meta.get(aspect, {})
rows.append([
aspect,
round(_num(text_metrics.get("macro_f1")), 4),
round(_num(proposed_metrics.get("macro_f1")), 4),
round(_num(proposed_metrics.get("macro_f1")) - _num(text_metrics.get("macro_f1")), 4),
round(_num(text_metrics.get("accuracy")), 4),
round(_num(proposed_metrics.get("accuracy")), 4),
round(_num(proposed_metrics.get("accuracy")) - _num(text_metrics.get("accuracy")), 4),
])
return rows
def _ablation_rows() -> List[List[Any]]:
data = _safe_json(REPORT_DIR / "ablation_summary.json")
labels = [
("Proposed", "Proposed"),
("w/o Text Metadata (keep numeric)", "A1_no_text_meta"),
("w/o Numeric Metadata", "A2_no_numeric_meta"),
("w/o Cross-Attention (Concat Fusion)", "A3_concat_fusion"),
]
return [
[label, round(_num(data.get(key, {}).get("mean_macro_f1")), 4), round(_num(data.get(key, {}).get("mean_accuracy")), 4)]
for label, key in labels
]
def refresh_dashboard():
return _metric_cards(), _overall_accuracy_rows(), _aspect_overall_rows(), _aspect_rows(), _ablation_rows()
CSS = """'
:root { --orange: #f97316; --ink: #111827; --muted: #64748b; --line: #e5e7eb; --soft: #f8fafc; }
.gradio-container { max-width: 1240px !important; margin: 0 auto !important; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.hero { margin: 24px 0 18px; padding: 28px 30px; border: 1px solid #dbe3ee; border-radius: 8px; background: linear-gradient(135deg, #ffffff 0%, #f4f7fb 100%); }
.hero h1 { margin: 0 0 8px; font-size: 30px; line-height: 1.15; color: var(--ink); }
.hero p { margin: 0; color: #475569; font-size: 14px; }
.status { margin: 0 0 14px; padding: 12px 14px; border-radius: 8px; font-size: 13px; }
.status.ok { color: #166534; background: #ecfdf5; border: 1px solid #bbf7d0; }
.status.warn { color: #9a3412; background: #fff7ed; border: 1px solid #fed7aa; }
.input-panel, .output-panel { box-sizing: border-box; border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: #fff; }
.output-panel { min-height: 470px; }
.result-placeholder { display: grid; min-height: 230px; place-items: center; border: 1px dashed #cbd5e1; border-radius: 8px; color: var(--muted); background: #fbfdff; font-size: 14px; }
.diagnosis-placeholder { margin-top: 12px; padding: 14px; border: 1px solid #e2e8f0; border-radius: 8px; color: var(--muted); background: #f8fafc; font-size: 13px; }
.analyze-button, .analyze-button button { display: flex !important; width: 100% !important; min-height: 44px !important; margin-top: 12px !important; align-items: center !important; justify-content: center !important; background: #f97316 !important; border: 1px solid #f97316 !important; border-radius: 6px !important; color: #ffffff !important; font-weight: 700 !important; }
.analyze-button button { color: #ffffff !important; }
.aspect-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.aspect-card { border: 1px solid var(--line); border-top: 4px solid #94a3b8; border-radius: 8px; padding: 13px; background: #fff; min-height: 150px; }
.aspect-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ink); }
.confidence { color: var(--muted); font-size: 12px; font-weight: 500; }
.label-pill { display: inline-block; margin: 9px 0 8px; padding: 4px 9px; border-radius: 999px; font-weight: 800; font-size: 13px; }
.aspect-card p { margin: 0 0 8px; color: #475569; font-size: 12px; min-height: 34px; }
.chip { display: inline-block; margin: 2px 5px 2px 0; padding: 3px 8px; border: 1px solid #c7d2fe; border-radius: 999px; background: #eef2ff; color: #3730a3; font-size: 12px; }
.muted, .empty { color: var(--muted); font-size: 13px; }
.attn-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 4px; }
.attn-card { border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: #fff; }
.attn-card h4 { margin: 0 0 8px; font-size: 14px; color: var(--ink); }
.bar-row { display: grid; grid-template-columns: 112px 1fr 48px; gap: 8px; align-items: center; margin-top: 7px; color: #475569; font-size: 12px; }
.bar-bg { height: 9px; border-radius: 999px; background: #edf2f7; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #f97316, #2563eb); }
.diagnosis { border: 1px solid #cbd5e1; border-radius: 8px; padding: 15px; background: #fbfdff; }
.diagnosis h3 { margin: 0 0 10px; font-size: 18px; }
.diag-row { display: grid; grid-template-columns: 150px 1fr; padding: 7px 0; border-bottom: 1px solid #e8edf4; color: #475569; }
.diag-row b { color: var(--ink); }
.diagnosis p { margin: 12px 0 0; color: #334155; }
.error-card { border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; border-radius: 8px; padding: 14px; }
.error-card h3 { margin: 0 0 6px; }
.error-card pre { white-space: pre-wrap; color: #7f1d1d; }
.metric-card-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
.metric-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 14px; }
.metric-card span { display: block; color: var(--muted); font-size: 13px; }
.metric-card b { display: block; margin: 6px 0; font-size: 26px; color: var(--ink); }
.metric-card em { color: #475569; font-style: normal; font-size: 12px; }
@media (max-width: 860px) { .aspect-grid, .attn-grid, .metric-card-grid { grid-template-columns: 1fr; } .hero h1 { font-size: 24px; } }
"""
def build_app() -> gr.Blocks:
with gr.Blocks(css=CSS, title="ACSA Clothing Sentiment") as demo:
gr.HTML(
"<div class='hero'><h1>Aspect-Level Sentiment Analysis for Clothing Reviews</h1>"
"<p>BERT + metadata fusion for SIZE, MATERIAL, QUALITY, APPEARANCE, STYLE, and VALUE.</p></div>"
)
gr.HTML(_status_html())
with gr.Tabs():
with gr.Tab("Customer Demo"):
with gr.Row(equal_height=False):
with gr.Column(scale=5):
with gr.Group(elem_classes="input-panel"):
review = gr.Textbox(
label="Customer review",
lines=4,
value="The size runs really small, I ordered an XL but it fits like a Medium. The fabric feels soft and the print looks great.",
)
features = gr.Textbox(label="Product features", value="100% Cotton, Slim Fit, Machine Wash Cold")
categories = gr.Textbox(label="Product categories", value="Clothing > Men > T-Shirts > Graphic Tees")
with gr.Group(elem_classes="input-panel"):
with gr.Row():
price = gr.Number(label="Price", value=19.99)
avg_rating = gr.Number(label="Average rating", value=4.2)
rating_count = gr.Number(label="Rating count", value=312, precision=0)
run = gr.Button("Analyze Review", variant="primary", elem_classes=["analyze-button"])
with gr.Column(scale=7, elem_classes="output-panel"):
pred = gr.HTML("<div class='result-placeholder'>Enter a review and click Analyze Review to generate aspect-level results.</div>")
diagnosis = gr.HTML("<div class='diagnosis-placeholder'>The business diagnosis and metadata contribution summary will appear here.</div>")
with gr.Row():
attn = gr.HTML(label="Metadata attention")
table = gr.Dataframe(
headers=["Aspect", "Prediction", "Business Meaning", "Evidence"],
label="Aspect-level result table",
interactive=False,
)
run.click(analyze_review, [review, features, categories, price, avg_rating, rating_count], [pred, attn, diagnosis, table])
with gr.Tab("Research Dashboard"):
gr.Markdown("### Research Dashboard")
gr.Markdown("Metrics are loaded from the bundled 30K experiment reports. All values use the same held-out test split.")
refresh = gr.Button("Refresh Dashboard", variant="primary")
metric_cards = gr.HTML(_metric_cards())
gr.Markdown("#### 1. Overall Sentiment Accuracy")
overall_accuracy_table = gr.Dataframe(
headers=["Model", "Macro-F1", "Accuracy"],
value=_overall_accuracy_rows(),
label="Overall: two baselines and the Proposed overall head",
interactive=False,
)
gr.Markdown("#### 2. Aspect-Level Overall Comparison")
aspect_overall_table = gr.Dataframe(
headers=["Model", "Mean Macro-F1", "Mean Accuracy"],
value=_aspect_overall_rows(),
label="Text-only BERT ACSA versus metadata fusion",
interactive=False,
)
gr.Markdown("#### 3. Six-Aspect Comparison")
aspect_table = gr.Dataframe(
headers=["Aspect", "Text-only F1", "Proposed F1", "F1 Delta", "Text-only Acc", "Proposed Acc", "Acc Delta"],
value=_aspect_rows(),
label="SIZE, MATERIAL, QUALITY, APPEARANCE, STYLE, and VALUE",
interactive=False,
)
gr.Markdown("#### 4. Ablation Study")
ablation_table = gr.Dataframe(
headers=["Variant", "Mean Macro-F1", "Mean Accuracy"],
value=_ablation_rows(),
label="Contribution of text metadata, numeric metadata, and cross-attention",
interactive=False,
)
refresh.click(
refresh_dashboard,
outputs=[metric_cards, overall_accuracy_table, aspect_overall_table, aspect_table, ablation_table],
)
demo.load(
refresh_dashboard,
outputs=[metric_cards, overall_accuracy_table, aspect_overall_table, aspect_table, ablation_table],
)
return demo
if __name__ == "__main__":
build_app().launch()