"""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"" 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 "
Model ready. All required inference artifacts are available.
" items = "".join(f"
  • {html.escape(x)}
  • " for x in issues) return f"
    Model artifacts need attention.
    " 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 ( "
    " f"

    {html.escape(title)}

    " f"

    {msg}

    " f"
    Technical detail
    {detail}
    " "
    " ) 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 "No explicit text evidence returned." if isinstance(evidence, str): evidence = [evidence] chips = [] for item in list(evidence)[:5]: text = str(item).strip() if text: chips.append(f"{html.escape(text)}") return "".join(chips) or "No explicit text evidence returned." 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 = ["
    "] 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"conf {float(conf):.2f}" if isinstance(conf, (int, float)) else "" parts.append( f"
    " f"
    {html.escape(aspect)}{conf_html}
    " f"
    {html.escape(label)}
    " f"

    {html.escape(ASPECT_DESCRIPTIONS.get(aspect, ''))}

    " f"
    {_highlight_evidence(evidence.get(aspect))}
    " "
    " ) parts.append("
    ") 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 "
    No metadata attention returned by this checkpoint.
    " 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 = ["
    "] for aspect in ["GLOBAL"] + [a for a in cfg.ASPECTS if a in grouped]: if aspect not in grouped: continue parts.append(f"

    {html.escape(aspect)} metadata use

    ") 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"
    {html.escape(name)}" f"
    " f"{val:.3f}
    " ) parts.append("
    ") parts.append("
    ") 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 ( "
    " "

    Business Diagnosis

    " f"
    Overall{html.escape(overall)}
    " f"
    Risk level{risk}
    " f"
    Positive drivers{html.escape(', '.join(positives) or '-')}
    " f"
    Negative drivers{html.escape(', '.join(negatives) or '-')}
    " f"
    Top metadata source{html.escape(str(top_meta))}
    " f"

    {html.escape(_recommendation(positives, negatives))}

    " f"
    Metadata summary
    {html.escape(json.dumps(meta_summary, ensure_ascii=False, indent=2))}
    " "
    " ) 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 = "
    Enter a customer review, then click Analyze Review.
    " 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 "
    " + "".join( f"
    {html.escape(k)}{html.escape(v)}{html.escape(s)}
    " for k, v, s in cards ) + "
    " 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( "

    Aspect-Level Sentiment Analysis for Clothing Reviews

    " "

    BERT + metadata fusion for SIZE, MATERIAL, QUALITY, APPEARANCE, STYLE, and VALUE.

    " ) 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("
    Enter a review and click Analyze Review to generate aspect-level results.
    ") diagnosis = gr.HTML("
    The business diagnosis and metadata contribution summary will appear here.
    ") 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()