OliverPerrin commited on
Commit
6a7a381
·
1 Parent(s): 1601799

Improve emotion task architecture and fix visualizations

Browse files

Architecture improvements:
- Added hidden_dim option to ClassificationHead for 2-layer MLP
- Emotion head now uses 768→384→28 MLP for better multi-label capacity
- Updated task weights: emotion=1.5, summarization=1.0, topic=0.8

Visualization fixes:
- LR schedule now generates theoretical curve when no metrics logged
- Added learning_rate logging to MLflow in trainer
- Log warmup_steps and scheduler_type to MLflow params

Gradio UI modernization:
- New yellow/black research theme (lexitheme)
- Removed emojis for cleaner academic presentation
- Progress bar style emotion scores
- Updated tab names and descriptions

configs/training/full.yaml CHANGED
@@ -30,9 +30,9 @@ trainer:
30
  validation_max_length: 128
31
  label_smoothing: 0.1
32
  task_weights:
33
- summarization: 1.2 # Balanced weights
34
- emotion: 1.0
35
- topic: 1.0
36
  max_train_samples: 50000 # Cap training for speed
37
  max_val_samples: 3000 # Faster validation
38
  early_stopping_patience: 3
 
30
  validation_max_length: 128
31
  label_smoothing: 0.1
32
  task_weights:
33
+ summarization: 1.0 # Reduced from 1.2 to give emotion room
34
+ emotion: 1.5 # Increased to prevent degradation
35
+ topic: 0.8 # Reduced since topic already near SOTA
36
  max_train_samples: 50000 # Cap training for speed
37
  max_val_samples: 3000 # Faster validation
38
  early_stopping_patience: 3
scripts/demo_gradio.py CHANGED
@@ -48,27 +48,26 @@ NEWS_FILE = DEMO_DATA_DIR / "news_samples.jsonl"
48
  EVAL_REPORT_PATH = OUTPUTS_DIR / "evaluation_report.json"
49
  TRAINING_HISTORY_PATH = OUTPUTS_DIR / "training_history.json"
50
 
51
- # Emotion display with emojis
52
- EMOTION_EMOJI = {
53
- "joy": "😊", "love": "❤️", "anger": "😠", "fear": "😨",
54
- "sadness": "😢", "surprise": "😲", "neutral": "😐",
55
- "admiration": "🤩", "amusement": "😄", "annoyance": "😤",
56
- "approval": "👍", "caring": "🤗", "confusion": "😕",
57
- "curiosity": "🤔", "desire": "😍", "disappointment": "😞",
58
- "disapproval": "👎", "disgust": "🤢", "embarrassment": "😳",
59
- "excitement": "🎉", "gratitude": "🙏", "grief": "😭",
60
- "nervousness": "😰", "optimism": "🌟", "pride": "🦁",
61
- "realization": "💡", "relief": "😌", "remorse": "😔",
62
  }
63
 
64
- # Topic display with emojis
65
- TOPIC_EMOJI = {
66
- "World": "🌍", "Sports": "🏆", "Business": "💼",
67
- "Sci/Tech": "🔬", "Science & Mathematics": "🔬",
68
- "Education & Reference": "📚", "Entertainment & Music": "🎬",
69
- "Health": "🏥", "Family & Relationships": "👨‍👩‍👧",
70
- "Society & Culture": "🏛️", "Politics & Government": "🗳️",
71
- "Computers & Internet": "💻",
72
  }
73
 
74
  # --------------- Data Loading ---------------
@@ -202,24 +201,25 @@ def analyze_text(text: str) -> tuple[str, str, str]:
202
  emotions = pipe.predict_emotions([text], threshold=0.3)[0]
203
  topic = pipe.predict_topics([text])[0]
204
 
205
- # Format emotions - sort by score descending (not alphabetical)
206
  if emotions.labels:
207
- # Pair labels with scores and sort by score descending
208
  paired = list(zip(emotions.labels, emotions.scores, strict=False))
209
  paired_sorted = sorted(paired, key=lambda x: x[1], reverse=True)[:5]
210
 
211
  emotion_parts = []
212
  for lbl, score in paired_sorted:
213
- emoji = EMOTION_EMOJI.get(lbl.lower(), "•")
214
- emotion_parts.append(f"{emoji} **{lbl.title()}** ({score:.0%})")
215
- # Use double newline for proper Markdown paragraph breaks
216
- emotion_str = " \n".join(emotion_parts)
 
 
217
  else:
218
- emotion_str = "😐 No strong emotions detected"
219
 
220
- # Format topic
221
- topic_emoji = TOPIC_EMOJI.get(topic.label, "📄")
222
- topic_str = f"{topic_emoji} **{topic.label}**\n\nConfidence: {topic.confidence:.0%}"
223
 
224
  return summary, emotion_str, topic_str
225
 
@@ -257,7 +257,7 @@ def get_book_info(title: str) -> str:
257
  num_paras = len(book["paragraphs"])
258
  word_count = book["word_count"]
259
  topic = book.get("topic", "Unknown")
260
- return f"**{title}**\n\n📖 {word_count:,} words | {num_paras} excerpts\n\n📂 Topic: {topic}"
261
  return ""
262
 
263
 
@@ -271,8 +271,8 @@ def get_book_summary_and_emotions(title: str) -> tuple[str, str, str]:
271
 
272
  # Get topic
273
  topic = book.get("topic", "Unknown")
274
- topic_emoji = TOPIC_EMOJI.get(topic, "📄")
275
- topic_str = f"{topic_emoji} **{topic}**"
276
 
277
  # Get emotions - already sorted by score in library.json
278
  emotions = book.get("emotions", [])
@@ -281,11 +281,13 @@ def get_book_summary_and_emotions(title: str) -> tuple[str, str, str]:
281
  for emo in emotions[:5]:
282
  label = emo.get("label", "")
283
  score = emo.get("score", 0)
284
- emoji = EMOTION_EMOJI.get(label.lower(), "•")
285
- emotion_parts.append(f"{emoji} **{label.title()}** ({score:.0%})")
286
- emotion_str = " \n".join(emotion_parts)
 
 
287
  else:
288
- emotion_str = "😐 No strong emotions detected"
289
 
290
  return summary, emotion_str, topic_str
291
  return "No summary available.", "", ""
@@ -358,17 +360,17 @@ def load_metrics() -> str:
358
  val_final = train_metrics.get("val_epoch_3", {})
359
 
360
  md = """
361
- ## 📈 Model Performance
362
 
363
- ### Training Results
364
 
365
  | Task | Metric | Score |
366
  |------|--------|-------|
367
  | **Topic Classification** | Accuracy | **{topic_acc:.1%}** |
368
- | **Emotion Detection** | F1 | {emo_f1:.1%} |
369
  | **Summarization** | ROUGE-like | {rouge:.1%} |
370
 
371
- ### Evaluation Results
372
 
373
  | Metric | Value |
374
  |--------|-------|
@@ -376,6 +378,8 @@ def load_metrics() -> str:
376
  | Emotion F1 (macro) | {eval_emo:.1%} |
377
  | ROUGE-like | {eval_rouge:.1%} |
378
  | BLEU | {eval_bleu:.3f} |
 
 
379
  """.format(
380
  topic_acc=val_final.get("topic_accuracy", 0),
381
  emo_f1=val_final.get("emotion_f1", 0),
@@ -391,36 +395,83 @@ def load_metrics() -> str:
391
 
392
  # --------------- Gradio Interface ---------------
393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  with gr.Blocks(
395
- title="LexiMind - Multi-Task NLP",
396
- theme=gr.themes.Soft(),
397
  css="""
398
- .book-card { padding: 12px; border-radius: 8px; background: #2d3748; color: #f7fafc; }
399
- .book-card p { color: #f7fafc !important; }
400
  .results-panel { min-height: 200px; }
 
 
 
 
401
  """
402
  ) as demo:
403
  gr.Markdown(
404
  """
405
- # 🧠 LexiMind
406
- ### Multi-Task Transformer for Document Analysis
407
 
408
- Explore classic literature and news articles with AI-powered analysis:
409
- - 📝 **Summarization** - Generate concise summaries
410
- - 😊 **Emotion Detection** - Identify emotional tones
411
- - 📂 **Topic Classification** - Categorize by subject
412
 
413
- > Built with a custom Transformer initialized from FLAN-T5 weights.
 
 
 
 
 
 
414
  """
415
  )
416
 
417
  # ===================== TAB 1: EXPLORE BOOKS =====================
418
- with gr.Tab("📚 Explore Books"):
419
  gr.Markdown(
420
  """
421
  ### Classic Literature Collection
422
- Select a book to see LexiMind's **whole-book analysis**: summary, emotions, and topic.
423
- You can also browse excerpts and analyze them individually.
424
  """
425
  )
426
 
@@ -428,26 +479,26 @@ with gr.Blocks(
428
  with gr.Column(scale=1):
429
  book_dropdown = gr.Dropdown(
430
  choices=get_book_titles(),
431
- label="📖 Select a Book",
432
  value=get_book_titles()[0] if get_book_titles() else None,
433
  )
434
  book_info = gr.Markdown(elem_classes=["book-card"])
435
 
436
  gr.Markdown("---")
437
- gr.Markdown("**📄 Browse Excerpts:**")
438
  para_slider = gr.Slider(
439
  minimum=0,
440
  maximum=19,
441
  step=1,
442
  value=0,
443
- label="Excerpt Number",
444
- info="Navigate through different parts of the book"
445
  )
446
 
447
- analyze_excerpt_btn = gr.Button("🔍 Analyze This Excerpt", variant="secondary", size="sm")
448
 
449
  with gr.Column(scale=2):
450
- gr.Markdown("#### 📝 Book Summary")
451
  book_summary = gr.Textbox(
452
  label="",
453
  lines=4,
@@ -457,14 +508,14 @@ with gr.Blocks(
457
 
458
  with gr.Row():
459
  with gr.Column():
460
- gr.Markdown("#### 😊 Emotions")
461
  book_emotions = gr.Markdown(value="*Select a book*")
462
  with gr.Column():
463
- gr.Markdown("#### 📂 Topic")
464
  book_topic = gr.Markdown(value="*Select a book*")
465
 
466
  gr.Markdown("---")
467
- gr.Markdown("#### 📜 Book Excerpt")
468
  book_excerpt = gr.Textbox(
469
  label="",
470
  lines=6,
@@ -476,7 +527,7 @@ with gr.Blocks(
476
  with gr.Row():
477
  with gr.Column():
478
  excerpt_summary = gr.Textbox(
479
- label="📝 Excerpt Summary",
480
  lines=3,
481
  interactive=False,
482
  )
@@ -510,12 +561,12 @@ with gr.Blocks(
510
  )
511
 
512
  # ===================== TAB 2: EXPLORE NEWS =====================
513
- with gr.Tab("📰 Explore News"):
514
  gr.Markdown(
515
  """
516
- ### CNN/DailyMail News Articles
517
- Explore news articles from the training dataset. Compare the model's
518
- generated summary with the original human-written summary.
519
  """
520
  )
521
 
@@ -526,13 +577,13 @@ with gr.Blocks(
526
  maximum=99,
527
  step=1,
528
  value=0,
529
- label="📰 Article Number",
530
  )
531
- random_news_btn = gr.Button("🎲 Random Article", variant="secondary")
532
- analyze_news_btn = gr.Button("🔍 Analyze Article", variant="primary")
533
 
534
  gr.Markdown("### Reference Summary")
535
- gr.Markdown("*Original human-written summary from the dataset:*")
536
  reference_summary = gr.Textbox(
537
  label="",
538
  lines=4,
@@ -542,7 +593,7 @@ with gr.Blocks(
542
 
543
  with gr.Column(scale=2):
544
  news_text = gr.Textbox(
545
- label="📰 News Article",
546
  lines=12,
547
  max_lines=15,
548
  interactive=False,
@@ -551,19 +602,19 @@ with gr.Blocks(
551
  with gr.Row():
552
  with gr.Column():
553
  news_summary = gr.Textbox(
554
- label="📝 LexiMind Summary",
555
  lines=4,
556
  interactive=False,
557
  )
558
  with gr.Column():
559
  with gr.Row():
560
  news_emotions = gr.Markdown(
561
- label="😊 Emotions",
562
- value="*Click Analyze*",
563
  )
564
  news_topic = gr.Markdown(
565
- label="📂 Topic",
566
- value="*Click Analyze*",
567
  )
568
 
569
  # News event handlers
@@ -591,41 +642,41 @@ with gr.Blocks(
591
  )
592
 
593
  # ===================== TAB 3: FREE TEXT =====================
594
- with gr.Tab("✏️ Free Text"):
595
  gr.Markdown(
596
  """
597
- ### Try Your Own Text
598
- Enter any text to analyze. Note that the model performs best on
599
- **news-style articles** and **literary prose** similar to the training data.
600
  """
601
  )
602
 
603
  with gr.Row():
604
  with gr.Column(scale=3):
605
  free_text_input = gr.Textbox(
606
- label="📝 Enter Text",
607
  lines=8,
608
- placeholder="Paste or type your text here...\n\nThe model works best with news articles or literary passages.",
609
  )
610
 
611
  with gr.Row():
612
- analyze_free_btn = gr.Button("🔍 Analyze", variant="primary")
613
- clear_btn = gr.Button("🗑️ Clear", variant="secondary")
614
 
615
- gr.Markdown("**Sample texts:**")
616
  with gr.Row():
617
- sample1 = gr.Button("📈 Business News", size="sm")
618
- sample2 = gr.Button("🔬 Science News", size="sm")
619
- sample3 = gr.Button("🏆 Sports News", size="sm")
620
 
621
  with gr.Column(scale=2):
622
  free_summary = gr.Textbox(
623
- label="📝 Summary",
624
  lines=4,
625
  interactive=False,
626
  )
627
  with gr.Row():
628
- free_emotions = gr.Markdown(value="*Enter text and click Analyze*")
629
  free_topic = gr.Markdown(value="")
630
 
631
  # Sample texts
@@ -647,7 +698,7 @@ with gr.Blocks(
647
  )
648
 
649
  # ===================== TAB 4: METRICS =====================
650
- with gr.Tab("📊 Metrics"):
651
  with gr.Row():
652
  with gr.Column(scale=2):
653
  gr.Markdown(load_metrics())
@@ -657,46 +708,53 @@ with gr.Blocks(
657
  gr.Image(str(confusion_path), label="Topic Confusion Matrix")
658
 
659
  # ===================== TAB 5: ABOUT =====================
660
- with gr.Tab("ℹ️ About"):
661
  gr.Markdown(
662
  """
663
- ### About LexiMind
664
 
665
- LexiMind is a **multi-task NLP system** built from scratch with PyTorch,
666
- demonstrating end-to-end machine learning engineering.
667
 
668
- #### 🏗️ Architecture
669
 
670
- - **Custom Transformer** encoder-decoder (12 layers each)
671
- - **Pre-LN with RMSNorm** for training stability
672
- - **T5 Relative Position Bias** for sequence modeling
673
- - **FLAN-T5-base** weight initialization
674
- - **Task-specific heads**: LM head (summarization), Classification heads (emotion, topic)
 
 
 
675
 
676
- #### 📚 Training Data
677
 
678
- | Task | Dataset | Description |
679
- |------|---------|-------------|
680
- | Summarization | CNN/DailyMail | ~100K news articles with summaries |
681
- | Emotion | GoEmotions | Multi-label emotion classification |
682
- | Topic | AG News | 4-class news categorization |
683
- | Books | Project Gutenberg | 8 classic novels for evaluation |
684
 
685
- #### ⚠️ Known Limitations
686
 
687
- - **Domain-specific**: Best results on news articles and literary text
688
- - **Summarization quality**: Limited by model size and training data
689
- - **Generalization**: May struggle with very different text styles
 
 
690
 
691
- #### 🔗 Links
692
 
693
- - [GitHub Repository](https://github.com/OliverPerrin/LexiMind)
694
- - [Model on HuggingFace](https://huggingface.co/OliverPerrin/LexiMind-Model)
695
- - [HuggingFace Space](https://huggingface.co/spaces/OliverPerrin/LexiMind)
696
 
697
  ---
698
 
699
- **Built by Oliver Perrin** | Appalachian State University | 2025-2026
 
 
 
700
  """
701
  )
702
 
 
48
  EVAL_REPORT_PATH = OUTPUTS_DIR / "evaluation_report.json"
49
  TRAINING_HISTORY_PATH = OUTPUTS_DIR / "training_history.json"
50
 
51
+ # Emotion display - clean labels without emojis for research aesthetic
52
+ EMOTION_LABELS = {\n "joy": "Joy", "love": "Love", "anger": "Anger", "fear": "Fear",
53
+ "sadness": "Sadness", "surprise": "Surprise", "neutral": "Neutral",
54
+ "admiration": "Admiration", "amusement": "Amusement", "annoyance": "Annoyance",
55
+ "approval": "Approval", "caring": "Caring", "confusion": "Confusion",
56
+ "curiosity": "Curiosity", "desire": "Desire", "disappointment": "Disappointment",
57
+ "disapproval": "Disapproval", "disgust": "Disgust", "embarrassment": "Embarrassment",
58
+ "excitement": "Excitement", "gratitude": "Gratitude", "grief": "Grief",
59
+ "nervousness": "Nervousness", "optimism": "Optimism", "pride": "Pride",
60
+ "realization": "Realization", "relief": "Relief", "remorse": "Remorse",
 
61
  }
62
 
63
+ # Topic display - clean labels
64
+ TOPIC_LABELS = {
65
+ "World": "World News", "Sports": "Sports", "Business": "Business/Finance",
66
+ "Sci/Tech": "Science & Technology", "Science & Mathematics": "Science",
67
+ "Education & Reference": "Education", "Entertainment & Music": "Entertainment",
68
+ "Health": "Health", "Family & Relationships": "Lifestyle",
69
+ "Society & Culture": "Society", "Politics & Government": "Politics",
70
+ "Computers & Internet": "Technology",
71
  }
72
 
73
  # --------------- Data Loading ---------------
 
201
  emotions = pipe.predict_emotions([text], threshold=0.3)[0]
202
  topic = pipe.predict_topics([text])[0]
203
 
204
+ # Format emotions - sort by score descending, clean presentation
205
  if emotions.labels:
 
206
  paired = list(zip(emotions.labels, emotions.scores, strict=False))
207
  paired_sorted = sorted(paired, key=lambda x: x[1], reverse=True)[:5]
208
 
209
  emotion_parts = []
210
  for lbl, score in paired_sorted:
211
+ clean_label = EMOTION_LABELS.get(lbl.lower(), lbl.title())
212
+ # Use progress bar style
213
+ bar_width = int(score * 10)
214
+ bar = "█" * bar_width + "░" * (10 - bar_width)
215
+ emotion_parts.append(f"**{clean_label}** \n`{bar}` {score:.0%}")
216
+ emotion_str = "\n\n".join(emotion_parts)
217
  else:
218
+ emotion_str = "*No strong emotions detected*"
219
 
220
+ # Format topic - clean presentation
221
+ clean_topic = TOPIC_LABELS.get(topic.label, topic.label)
222
+ topic_str = f"**{clean_topic}**\n\nConfidence: {topic.confidence:.0%}"
223
 
224
  return summary, emotion_str, topic_str
225
 
 
257
  num_paras = len(book["paragraphs"])
258
  word_count = book["word_count"]
259
  topic = book.get("topic", "Unknown")
260
+ return f"**{title}**\n\n{word_count:,} words | {num_paras} excerpts\n\nTopic: {topic}"
261
  return ""
262
 
263
 
 
271
 
272
  # Get topic
273
  topic = book.get("topic", "Unknown")
274
+ clean_topic = TOPIC_LABELS.get(topic, topic)
275
+ topic_str = f"**{clean_topic}**"
276
 
277
  # Get emotions - already sorted by score in library.json
278
  emotions = book.get("emotions", [])
 
281
  for emo in emotions[:5]:
282
  label = emo.get("label", "")
283
  score = emo.get("score", 0)
284
+ clean_label = EMOTION_LABELS.get(label.lower(), label.title())
285
+ bar_width = int(score * 10)
286
+ bar = "█" * bar_width + "░" * (10 - bar_width)
287
+ emotion_parts.append(f"**{clean_label}** \n`{bar}` {score:.0%}")
288
+ emotion_str = "\n\n".join(emotion_parts)
289
  else:
290
+ emotion_str = "*No strong emotions detected*"
291
 
292
  return summary, emotion_str, topic_str
293
  return "No summary available.", "", ""
 
360
  val_final = train_metrics.get("val_epoch_3", {})
361
 
362
  md = """
363
+ ## Model Performance
364
 
365
+ ### Training Results (Final Epoch)
366
 
367
  | Task | Metric | Score |
368
  |------|--------|-------|
369
  | **Topic Classification** | Accuracy | **{topic_acc:.1%}** |
370
+ | **Emotion Detection** | F1 (macro) | {emo_f1:.1%} |
371
  | **Summarization** | ROUGE-like | {rouge:.1%} |
372
 
373
+ ### Evaluation Set Results
374
 
375
  | Metric | Value |
376
  |--------|-------|
 
378
  | Emotion F1 (macro) | {eval_emo:.1%} |
379
  | ROUGE-like | {eval_rouge:.1%} |
380
  | BLEU | {eval_bleu:.3f} |
381
+
382
+ *Training on CNN/DailyMail, GoEmotions, and AG News datasets.*
383
  """.format(
384
  topic_acc=val_final.get("topic_accuracy", 0),
385
  emo_f1=val_final.get("emotion_f1", 0),
 
395
 
396
  # --------------- Gradio Interface ---------------
397
 
398
+ # Custom yellow/black research theme
399
+ lexitheme = gr.themes.Base(
400
+ primary_hue=gr.themes.colors.amber,
401
+ secondary_hue=gr.themes.colors.stone,
402
+ neutral_hue=gr.themes.colors.stone,
403
+ font=gr.themes.GoogleFont("Inter"),
404
+ font_mono=gr.themes.GoogleFont("JetBrains Mono"),
405
+ ).set(
406
+ body_background_fill="#0a0a0a",
407
+ body_background_fill_dark="#0a0a0a",
408
+ body_text_color="#e5e5e5",
409
+ body_text_color_dark="#e5e5e5",
410
+ block_background_fill="#171717",
411
+ block_background_fill_dark="#171717",
412
+ block_border_color="#262626",
413
+ block_border_color_dark="#262626",
414
+ block_label_text_color="#fbbf24",
415
+ block_label_text_color_dark="#fbbf24",
416
+ block_title_text_color="#fbbf24",
417
+ block_title_text_color_dark="#fbbf24",
418
+ input_background_fill="#262626",
419
+ input_background_fill_dark="#262626",
420
+ input_border_color="#404040",
421
+ input_border_color_dark="#404040",
422
+ button_primary_background_fill="#fbbf24",
423
+ button_primary_background_fill_dark="#fbbf24",
424
+ button_primary_text_color="#0a0a0a",
425
+ button_primary_text_color_dark="#0a0a0a",
426
+ button_primary_background_fill_hover="#f59e0b",
427
+ button_primary_background_fill_hover_dark="#f59e0b",
428
+ button_secondary_background_fill="#262626",
429
+ button_secondary_background_fill_dark="#262626",
430
+ button_secondary_text_color="#fbbf24",
431
+ button_secondary_text_color_dark="#fbbf24",
432
+ button_secondary_border_color="#fbbf24",
433
+ button_secondary_border_color_dark="#fbbf24",
434
+ slider_color="#fbbf24",
435
+ slider_color_dark="#fbbf24",
436
+ )
437
+
438
  with gr.Blocks(
439
+ title="LexiMind | Multi-Task NLP Research Demo",
440
+ theme=lexitheme,
441
  css="""
442
+ .book-card { padding: 16px; border-radius: 8px; background: #1f1f1f; border: 1px solid #333; }
443
+ .book-card p { color: #e5e5e5 !important; }
444
  .results-panel { min-height: 200px; }
445
+ .metric-box { background: #1f1f1f; border: 1px solid #fbbf24; border-radius: 8px; padding: 12px; }
446
+ h1, h2, h3 { color: #fbbf24 !important; }
447
+ .prose { color: #d4d4d4 !important; }
448
+ a { color: #fbbf24 !important; }
449
  """
450
  ) as demo:
451
  gr.Markdown(
452
  """
453
+ # LexiMind
454
+ ## Multi-Task Transformer for Document Understanding
455
 
456
+ A custom encoder-decoder architecture initialized from FLAN-T5-base, trained jointly on:
 
 
 
457
 
458
+ | Task | Dataset | Output |
459
+ |------|---------|--------|
460
+ | **Abstractive Summarization** | CNN/DailyMail | Condensed text |
461
+ | **Emotion Detection** | GoEmotions | 28-class multi-label |
462
+ | **Topic Classification** | AG News | 4-class single-label |
463
+
464
+ ---
465
  """
466
  )
467
 
468
  # ===================== TAB 1: EXPLORE BOOKS =====================
469
+ with gr.Tab("Literature"):
470
  gr.Markdown(
471
  """
472
  ### Classic Literature Collection
473
+ Select a book to view pre-computed analysis (summary, emotions, topic).
474
+ Browse excerpts and run real-time inference on individual passages.
475
  """
476
  )
477
 
 
479
  with gr.Column(scale=1):
480
  book_dropdown = gr.Dropdown(
481
  choices=get_book_titles(),
482
+ label="Select Book",
483
  value=get_book_titles()[0] if get_book_titles() else None,
484
  )
485
  book_info = gr.Markdown(elem_classes=["book-card"])
486
 
487
  gr.Markdown("---")
488
+ gr.Markdown("**Browse Excerpts**")
489
  para_slider = gr.Slider(
490
  minimum=0,
491
  maximum=19,
492
  step=1,
493
  value=0,
494
+ label="Excerpt Index",
495
+ info="Navigate through passages"
496
  )
497
 
498
+ analyze_excerpt_btn = gr.Button("Analyze Excerpt", variant="secondary", size="sm")
499
 
500
  with gr.Column(scale=2):
501
+ gr.Markdown("#### Summary")
502
  book_summary = gr.Textbox(
503
  label="",
504
  lines=4,
 
508
 
509
  with gr.Row():
510
  with gr.Column():
511
+ gr.Markdown("#### Detected Emotions")
512
  book_emotions = gr.Markdown(value="*Select a book*")
513
  with gr.Column():
514
+ gr.Markdown("#### Topic Classification")
515
  book_topic = gr.Markdown(value="*Select a book*")
516
 
517
  gr.Markdown("---")
518
+ gr.Markdown("#### Current Excerpt")
519
  book_excerpt = gr.Textbox(
520
  label="",
521
  lines=6,
 
527
  with gr.Row():
528
  with gr.Column():
529
  excerpt_summary = gr.Textbox(
530
+ label="Excerpt Summary",
531
  lines=3,
532
  interactive=False,
533
  )
 
561
  )
562
 
563
  # ===================== TAB 2: EXPLORE NEWS =====================
564
+ with gr.Tab("News Articles"):
565
  gr.Markdown(
566
  """
567
+ ### CNN/DailyMail Dataset
568
+ Browse news articles from the training data. Compare generated summaries
569
+ against human-written reference summaries.
570
  """
571
  )
572
 
 
577
  maximum=99,
578
  step=1,
579
  value=0,
580
+ label="Article Index",
581
  )
582
+ random_news_btn = gr.Button("Random Article", variant="secondary")
583
+ analyze_news_btn = gr.Button("Run Inference", variant="primary")
584
 
585
  gr.Markdown("### Reference Summary")
586
+ gr.Markdown("*Human-written summary from dataset:*")
587
  reference_summary = gr.Textbox(
588
  label="",
589
  lines=4,
 
593
 
594
  with gr.Column(scale=2):
595
  news_text = gr.Textbox(
596
+ label="Source Article",
597
  lines=12,
598
  max_lines=15,
599
  interactive=False,
 
602
  with gr.Row():
603
  with gr.Column():
604
  news_summary = gr.Textbox(
605
+ label="Generated Summary",
606
  lines=4,
607
  interactive=False,
608
  )
609
  with gr.Column():
610
  with gr.Row():
611
  news_emotions = gr.Markdown(
612
+ label="Emotions",
613
+ value="*Run inference*",
614
  )
615
  news_topic = gr.Markdown(
616
+ label="Topic",
617
+ value="*Run inference*",
618
  )
619
 
620
  # News event handlers
 
642
  )
643
 
644
  # ===================== TAB 3: FREE TEXT =====================
645
+ with gr.Tab("Custom Input"):
646
  gr.Markdown(
647
  """
648
+ ### Interactive Inference
649
+ Enter any text for real-time analysis. Best results on news-style articles
650
+ and literary prose matching training distribution.
651
  """
652
  )
653
 
654
  with gr.Row():
655
  with gr.Column(scale=3):
656
  free_text_input = gr.Textbox(
657
+ label="Input Text",
658
  lines=8,
659
+ placeholder="Paste or type text here...\n\nOptimal for: news articles, literary passages, formal writing.",
660
  )
661
 
662
  with gr.Row():
663
+ analyze_free_btn = gr.Button("Run Inference", variant="primary")
664
+ clear_btn = gr.Button("Clear", variant="secondary")
665
 
666
+ gr.Markdown("**Sample Inputs**")
667
  with gr.Row():
668
+ sample1 = gr.Button("Business", size="sm")
669
+ sample2 = gr.Button("Science", size="sm")
670
+ sample3 = gr.Button("Sports", size="sm")
671
 
672
  with gr.Column(scale=2):
673
  free_summary = gr.Textbox(
674
+ label="Generated Summary",
675
  lines=4,
676
  interactive=False,
677
  )
678
  with gr.Row():
679
+ free_emotions = gr.Markdown(value="*Enter text and run inference*")
680
  free_topic = gr.Markdown(value="")
681
 
682
  # Sample texts
 
698
  )
699
 
700
  # ===================== TAB 4: METRICS =====================
701
+ with gr.Tab("Metrics"):
702
  with gr.Row():
703
  with gr.Column(scale=2):
704
  gr.Markdown(load_metrics())
 
708
  gr.Image(str(confusion_path), label="Topic Confusion Matrix")
709
 
710
  # ===================== TAB 5: ABOUT =====================
711
+ with gr.Tab("Architecture"):
712
  gr.Markdown(
713
  """
714
+ ### Model Architecture
715
 
716
+ LexiMind is a **multi-task encoder-decoder Transformer** built from scratch in PyTorch,
717
+ demonstrating end-to-end ML engineering from data processing to deployment.
718
 
719
+ #### Architecture Details
720
 
721
+ | Component | Specification |
722
+ |-----------|---------------|
723
+ | **Encoder** | 12-layer Transformer, 768-dim, 12 heads |
724
+ | **Decoder** | 12-layer Transformer with causal masking |
725
+ | **Normalization** | Pre-LN with RMSNorm |
726
+ | **Position Encoding** | T5 Relative Position Bias |
727
+ | **Initialization** | FLAN-T5-base pretrained weights |
728
+ | **Parameters** | 272.5M total |
729
 
730
+ #### Task Heads
731
 
732
+ | Head | Architecture | Loss |
733
+ |------|--------------|------|
734
+ | **Summarization** | LM head (shared embeddings) | Cross-entropy w/ label smoothing |
735
+ | **Emotion** | Mean-pooled encoder → Linear(768, 28) | BCE (multi-label) |
736
+ | **Topic** | Mean-pooled encoder → Linear(768, 4) | Cross-entropy |
 
737
 
738
+ #### Training Data
739
 
740
+ | Task | Dataset | Size |
741
+ |------|---------|------|
742
+ | Summarization | CNN/DailyMail | ~100K articles |
743
+ | Emotion | GoEmotions | ~43K samples |
744
+ | Topic | AG News | ~120K articles |
745
 
746
+ #### Known Limitations
747
 
748
+ - **Domain specificity**: Optimized for news/literary text
749
+ - **Emotion task**: Multi-label F1 limited by class imbalance in GoEmotions
750
+ - **Summarization**: Constrained by 512 token context
751
 
752
  ---
753
 
754
+ **Repository**: [github.com/OliverPerrin/LexiMind](https://github.com/OliverPerrin/LexiMind)
755
+ **Model**: [huggingface.co/OliverPerrin/LexiMind-Model](https://huggingface.co/OliverPerrin/LexiMind-Model)
756
+
757
+ *Oliver Perrin • Appalachian State University • 2025-2026*
758
  """
759
  )
760
 
scripts/visualize_training.py CHANGED
@@ -397,11 +397,55 @@ def plot_learning_rate(run) -> None:
397
 
398
  fig, ax = plt.subplots(figsize=(12, 5))
399
 
400
- if not lr_metrics:
401
- ax.text(0.5, 0.5, "No learning rate data available",
402
- ha="center", va="center", fontsize=14, color="gray")
403
- ax.set_xlim(0, 1)
404
- ax.set_ylim(0, 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  else:
406
  steps = [m.step for m in lr_metrics]
407
  values = [m.value for m in lr_metrics]
@@ -410,8 +454,9 @@ def plot_learning_rate(run) -> None:
410
  ax.fill_between(steps, values, alpha=0.3, color=COLORS["primary"])
411
  ax.plot(steps, values, linewidth=2.5, color=COLORS["primary"])
412
 
413
- # Mark warmup region
414
- warmup_steps = 1000 # From config
 
415
  if warmup_steps < max(steps):
416
  ax.axvline(warmup_steps, color=COLORS["secondary"], linestyle="--",
417
  alpha=0.7, linewidth=2, label="Warmup End")
@@ -419,9 +464,8 @@ def plot_learning_rate(run) -> None:
419
  label="Warmup Phase")
420
  ax.legend(loc="upper right")
421
 
422
- # Scientific notation for y-axis if needed
423
- ax.ticklabel_format(axis="y", style="scientific", scilimits=(-3, 3))
424
-
425
  ax.set_xlabel("Step")
426
  ax.set_ylabel("Learning Rate")
427
  ax.set_title("Learning Rate Schedule (Cosine Annealing with Warmup)")
 
397
 
398
  fig, ax = plt.subplots(figsize=(12, 5))
399
 
400
+ if not lr_metrics or len(lr_metrics) < 2:
401
+ # No LR data logged - generate theoretical schedule from config
402
+ logger.info(" No LR metrics found - generating theoretical schedule...")
403
+
404
+ # Get config from run params
405
+ params = run.data.params
406
+ lr_max = float(params.get("learning_rate", params.get("lr", 5e-5)))
407
+ warmup_steps = int(params.get("warmup_steps", 500))
408
+ max_epochs = int(params.get("max_epochs", 5))
409
+
410
+ # Estimate total steps from training loss history
411
+ train_loss = client.get_metric_history(run.info.run_id, "train_total_loss")
412
+ if train_loss:
413
+ epochs_completed = len(train_loss)
414
+ # Estimate ~800 steps per epoch based on typical config
415
+ estimated_steps_per_epoch = 800
416
+ total_steps = max_epochs * estimated_steps_per_epoch
417
+ else:
418
+ total_steps = 4000 # Default fallback
419
+
420
+ # Generate cosine schedule with warmup
421
+ steps = np.arange(0, total_steps)
422
+ values = []
423
+ for step in steps:
424
+ if step < warmup_steps:
425
+ lr = lr_max * (step / max(1, warmup_steps))
426
+ else:
427
+ progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
428
+ lr = lr_max * max(0.1, 0.5 * (1 + np.cos(np.pi * progress)))
429
+ values.append(lr)
430
+
431
+ ax.fill_between(steps, values, alpha=0.3, color=COLORS["primary"])
432
+ ax.plot(steps, values, linewidth=2.5, color=COLORS["primary"], label="Cosine + Warmup")
433
+
434
+ # Mark warmup region
435
+ ax.axvline(warmup_steps, color=COLORS["secondary"], linestyle="--",
436
+ alpha=0.7, linewidth=2, label=f"Warmup End ({warmup_steps})")
437
+ ax.axvspan(0, warmup_steps, alpha=0.1, color=COLORS["highlight"])
438
+
439
+ # Add annotation
440
+ ax.annotate(f"Peak LR: {lr_max:.1e}", xy=(warmup_steps, lr_max),
441
+ xytext=(warmup_steps + 200, lr_max * 0.9),
442
+ fontsize=10, color=COLORS["dark"],
443
+ arrowprops=dict(arrowstyle="->", color=COLORS["dark"], alpha=0.5))
444
+
445
+ ax.legend(loc="upper right")
446
+ ax.text(0.98, 0.02, "(Theoretical - actual LR not logged)",
447
+ transform=ax.transAxes, ha="right", va="bottom",
448
+ fontsize=9, color="gray", style="italic")
449
  else:
450
  steps = [m.step for m in lr_metrics]
451
  values = [m.value for m in lr_metrics]
 
454
  ax.fill_between(steps, values, alpha=0.3, color=COLORS["primary"])
455
  ax.plot(steps, values, linewidth=2.5, color=COLORS["primary"])
456
 
457
+ # Mark warmup region (get from params if available)
458
+ params = run.data.params
459
+ warmup_steps = int(params.get("warmup_steps", 500))
460
  if warmup_steps < max(steps):
461
  ax.axvline(warmup_steps, color=COLORS["secondary"], linestyle="--",
462
  alpha=0.7, linewidth=2, label="Warmup End")
 
464
  label="Warmup Phase")
465
  ax.legend(loc="upper right")
466
 
467
+ # Scientific notation for y-axis if needed
468
+ ax.ticklabel_format(axis="y", style="scientific", scilimits=(-3, 3))
 
469
  ax.set_xlabel("Step")
470
  ax.set_ylabel("Learning Rate")
471
  ax.set_title("Learning Rate Schedule (Cosine Annealing with Warmup)")
src/models/factory.py CHANGED
@@ -548,10 +548,15 @@ def build_multitask_model(
548
  "summarization",
549
  LMHead(d_model=cfg.d_model, vocab_size=vocab_size, tie_embedding=decoder.embedding),
550
  )
 
551
  model.add_head(
552
  "emotion",
553
  ClassificationHead(
554
- d_model=cfg.d_model, num_labels=num_emotions, pooler="mean", dropout=cfg.dropout
 
 
 
 
555
  ),
556
  )
557
  model.add_head(
 
548
  "summarization",
549
  LMHead(d_model=cfg.d_model, vocab_size=vocab_size, tie_embedding=decoder.embedding),
550
  )
551
+ # Emotion head with 2-layer MLP for better multi-label capacity (28 classes)
552
  model.add_head(
553
  "emotion",
554
  ClassificationHead(
555
+ d_model=cfg.d_model,
556
+ num_labels=num_emotions,
557
+ pooler="mean",
558
+ dropout=cfg.dropout,
559
+ hidden_dim=cfg.d_model // 2, # 384-dim hidden layer
560
  ),
561
  )
562
  model.add_head(
src/models/heads.py CHANGED
@@ -25,6 +25,7 @@ class ClassificationHead(nn.Module):
25
  num_labels: number of output classes
26
  pooler: one of 'mean', 'cls', 'max' - how to pool the sequence
27
  dropout: dropout probability before final linear layer
 
28
  """
29
 
30
  def __init__(
@@ -33,12 +34,23 @@ class ClassificationHead(nn.Module):
33
  num_labels: int,
34
  pooler: Literal["mean", "cls", "max"] = "mean",
35
  dropout: float = 0.1,
 
36
  ):
37
  super().__init__()
38
  assert pooler in ("mean", "cls", "max"), "pooler must be 'mean'|'cls'|'max'"
39
  self.pooler = pooler
40
  self.dropout = nn.Dropout(dropout)
41
- self.out_proj = nn.Linear(d_model, num_labels)
 
 
 
 
 
 
 
 
 
 
42
 
43
  def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> torch.Tensor:
44
  """
 
25
  num_labels: number of output classes
26
  pooler: one of 'mean', 'cls', 'max' - how to pool the sequence
27
  dropout: dropout probability before final linear layer
28
+ hidden_dim: optional intermediate dimension for 2-layer MLP (improves capacity)
29
  """
30
 
31
  def __init__(
 
34
  num_labels: int,
35
  pooler: Literal["mean", "cls", "max"] = "mean",
36
  dropout: float = 0.1,
37
+ hidden_dim: Optional[int] = None,
38
  ):
39
  super().__init__()
40
  assert pooler in ("mean", "cls", "max"), "pooler must be 'mean'|'cls'|'max'"
41
  self.pooler = pooler
42
  self.dropout = nn.Dropout(dropout)
43
+
44
+ # Optional 2-layer MLP for more capacity (useful for multi-label)
45
+ if hidden_dim is not None:
46
+ self.out_proj = nn.Sequential(
47
+ nn.Linear(d_model, hidden_dim),
48
+ nn.GELU(),
49
+ nn.Dropout(dropout),
50
+ nn.Linear(hidden_dim, num_labels),
51
+ )
52
+ else:
53
+ self.out_proj = nn.Linear(d_model, num_labels)
54
 
55
  def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> torch.Tensor:
56
  """
src/training/trainer.py CHANGED
@@ -262,6 +262,9 @@ class Trainer:
262
  self.optimizer.zero_grad()
263
  if self.scheduler:
264
  self.scheduler.step()
 
 
 
265
  self.global_step += 1
266
 
267
  if step_loss > 0:
@@ -398,6 +401,9 @@ class Trainer:
398
  "gradient_clip_norm": self.config.gradient_clip_norm,
399
  "label_smoothing": self.config.label_smoothing,
400
  "task_weights": str(self.config.task_weights),
 
 
 
401
  })
402
 
403
  def _log_metrics(self, metrics: Dict[str, float], prefix: str, epoch: int) -> None:
 
262
  self.optimizer.zero_grad()
263
  if self.scheduler:
264
  self.scheduler.step()
265
+ # Log learning rate to MLflow
266
+ current_lr = self.scheduler.get_last_lr()[0]
267
+ mlflow.log_metric("learning_rate", current_lr, step=self.global_step)
268
  self.global_step += 1
269
 
270
  if step_loss > 0:
 
401
  "gradient_clip_norm": self.config.gradient_clip_norm,
402
  "label_smoothing": self.config.label_smoothing,
403
  "task_weights": str(self.config.task_weights),
404
+ "warmup_steps": self.config.warmup_steps,
405
+ "scheduler_type": self.config.scheduler_type,
406
+ "learning_rate": self.optimizer.param_groups[0]["lr"],
407
  })
408
 
409
  def _log_metrics(self, metrics: Dict[str, float], prefix: str, epoch: int) -> None: