Arabic-Sentiment-Analysis / utils /visualization.py
mhamza-007's picture
Upload 56 files
d840583 verified
Raw
History Blame Contribute Delete
10.4 kB
"""Charts for the dataset and for the model results.
Dataset charts
:func:`plot_sentiment_distribution` - class balance
:func:`plot_review_length_distribution` - words per review, raw vs cleaned
:func:`plot_top_tokens` - most frequent tokens per sentiment
Model charts
:func:`plot_model_comparison` - all four models across all four metrics
:func:`plot_confusion_matrix` - where the best model confuses classes
:func:`plot_per_class_metrics` - precision/recall/F1 per sentiment
"""
from collections import Counter
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import confusion_matrix, precision_recall_fscore_support
from config.constants import LABEL_COLUMN, TEXT_COLUMN
from utils.chart_style import (
ACCENT,
INK,
INK_MUTED,
INK_SECONDARY,
SENTIMENT_COLORS,
SERIES,
apply_chart_style,
finish,
sequential_cmap,
strip_spines,
)
#: Plot classes in polarity order, not alphabetically.
LABEL_ORDER = ["Negative", "Neutral", "Positive"]
def _ordered_labels(values):
"""Return LABEL_ORDER filtered to the labels actually present."""
present = set(values)
return [label for label in LABEL_ORDER if label in present]
def plot_sentiment_distribution(df, label_column=LABEL_COLUMN,
title='Distribution of Sentiments',
save_path=None, show=True):
"""Bar chart of the row count per sentiment. Counts start at zero, so bar length is honest."""
apply_chart_style()
counts = df[label_column].value_counts()
labels = _ordered_labels(counts.index)
values = [counts[label] for label in labels]
fig, ax = plt.subplots(figsize=(7, 4.5))
bars = ax.bar(labels, values,
color=[SENTIMENT_COLORS[label] for label in labels],
width=0.6)
# only three bars, so a direct label on each is still selective
for bar, value in zip(bars, values):
ax.annotate(f'{value:,}',
(bar.get_x() + bar.get_width() / 2, bar.get_height()),
textcoords='offset points', xytext=(0, 4),
ha='center', fontsize=10, color=INK_SECONDARY)
ax.set_xlabel('')
ax.set_ylabel('Reviews')
ax.set_title(title)
ax.set_ylim(0, max(values) * 1.12)
ax.grid(axis='x', visible=False)
strip_spines(ax)
return finish(fig, save_path, show)
def plot_review_length_distribution(df, text_column=TEXT_COLUMN,
compare=None, max_words=60,
title='Review length',
save_path=None, show=True):
"""Histogram of words per review; optionally overlay a second corpus."""
apply_chart_style()
def lengths(frame):
return frame[text_column].dropna().astype(str).str.split().str.len()
fig, ax = plt.subplots(figsize=(7.5, 4.5))
bins = np.arange(0, max_words + 2)
if compare is not None:
# outlines, not translucent fills: overlapping alpha would invent a third colour that is in neither series
ax.hist(lengths(compare).clip(upper=max_words), bins=bins,
histtype='step', linewidth=2, color=SERIES[1],
label='Before cleaning')
ax.hist(lengths(df).clip(upper=max_words), bins=bins,
histtype='step', linewidth=2, color=SERIES[0],
label='After cleaning')
ax.legend(loc='upper right')
else:
ax.hist(lengths(df).clip(upper=max_words), bins=bins, color=ACCENT)
median = float(lengths(df).median())
ax.axvline(median, color=INK_MUTED, linewidth=1)
ax.annotate(f'median {median:.0f} words', (median, ax.get_ylim()[1]),
textcoords='offset points', xytext=(6, -12),
fontsize=9, color=INK_SECONDARY)
ax.set_xlabel(f'Words per review (clipped at {max_words})')
ax.set_ylabel('Reviews')
ax.set_title(title)
ax.grid(axis='x', visible=False)
strip_spines(ax)
return finish(fig, save_path, show)
def plot_top_tokens(df, text_column=TEXT_COLUMN, label_column=LABEL_COLUMN,
top_n=12, title='Most frequent tokens by sentiment',
save_path=None, show=True):
"""Small multiples: the ``top_n`` most frequent cleaned tokens per class.
Run this on the cleaned text - it shows what the model actually sees.
"""
apply_chart_style()
labels = _ordered_labels(df[label_column].unique())
fig, axes = plt.subplots(1, len(labels), figsize=(4.2 * len(labels), 5.2),
sharex=False)
if len(labels) == 1:
axes = [axes]
for ax, label in zip(axes, labels):
texts = df.loc[df[label_column] == label, text_column].dropna().astype(str)
counter = Counter(token for text in texts for token in text.split())
common = counter.most_common(top_n)[::-1]
tokens = [token for token, _ in common]
counts = [count for _, count in common]
ax.barh(range(len(tokens)), counts,
color=SENTIMENT_COLORS[label], height=0.65)
ax.set_yticks(range(len(tokens)))
ax.set_yticklabels(tokens, fontsize=10)
ax.set_title(label, color=SENTIMENT_COLORS[label])
ax.set_xlabel('Occurrences')
ax.grid(axis='y', visible=False)
strip_spines(ax)
fig.suptitle(title, fontsize=13, color=INK, y=1.01)
return finish(fig, save_path, show)
def plot_model_comparison(results_df, metrics=('F1 Score', 'Precision',
'Recall', 'Accuracy'),
title='Model comparison',
save_path=None, show=True):
"""Small multiples of dot plots - one facet per metric, models on the y-axis."""
apply_chart_style()
models = list(results_df['Model'])
y = np.arange(len(models))
fig, axes = plt.subplots(1, len(metrics), figsize=(3.4 * len(metrics), 3.6),
sharey=True)
if len(metrics) == 1:
axes = [axes]
for ax, metric in zip(axes, metrics):
values = results_df[metric].to_numpy(dtype=float)
best = int(np.argmax(values))
# ties are real - accent every model that reaches the maximum
colors = [ACCENT if value == values.max() else INK_MUTED
for value in values]
ax.hlines(y, values.min(), values, color='#e1e0d9', linewidth=1.2)
ax.scatter(values, y, s=70, color=colors, zorder=3)
# label only the winner - a number on every dot would be noise
ax.annotate(f'{values[best]:.4f}', (values[best], y[best]),
textcoords='offset points', xytext=(0, 11),
ha='center', fontsize=9, color=INK)
span = max(values.max() - values.min(), 1e-3)
ax.set_xlim(values.min() - span * 0.45, values.max() + span * 0.45)
ax.set_title(metric, fontsize=11)
ax.set_yticks(y)
ax.set_yticklabels(models, fontsize=10)
ax.grid(axis='y', visible=False)
ax.tick_params(axis='x', labelsize=8)
ax.tick_params(axis='y', length=0) # labels only; no floating dashes
strip_spines(ax, keep=('bottom',))
fig.suptitle(title, fontsize=13, color=INK, y=1.04)
return finish(fig, save_path, show)
def plot_confusion_matrix(y_test, y_pred, normalize=True,
title='Confusion matrix',
save_path=None, show=True):
"""Heatmap of true vs predicted labels for one model."""
apply_chart_style()
labels = _ordered_labels(set(y_test) | set(y_pred))
matrix = confusion_matrix(y_test, y_pred, labels=labels)
shown = (matrix / matrix.sum(axis=1, keepdims=True)) if normalize else matrix
fig, ax = plt.subplots(figsize=(5.6, 5))
image = ax.imshow(shown, cmap=sequential_cmap(), vmin=0,
vmax=shown.max())
threshold = shown.max() * 0.6
for i in range(len(labels)):
for j in range(len(labels)):
cell = f'{shown[i, j]:.1%}' if normalize else f'{matrix[i, j]:,}'
sub = f'\n{matrix[i, j]:,}' if normalize else ''
ax.text(j, i, cell + sub, ha='center', va='center', fontsize=10,
color='#ffffff' if shown[i, j] > threshold else INK)
ax.set_xticks(range(len(labels)), labels)
ax.set_yticks(range(len(labels)), labels)
ax.set_xlabel('Predicted')
ax.set_ylabel('Actual')
ax.set_title(title)
ax.grid(False)
strip_spines(ax, keep=())
bar = fig.colorbar(image, ax=ax, fraction=0.046, pad=0.04)
bar.outline.set_visible(False)
bar.ax.tick_params(labelsize=8, color=INK_MUTED)
return finish(fig, save_path, show)
def plot_per_class_metrics(y_test, y_pred, title='Per-class performance',
save_path=None, show=True):
"""Precision / recall / F1 for each sentiment class, as a grouped dot plot."""
apply_chart_style()
labels = _ordered_labels(set(y_test) | set(y_pred))
precision, recall, f1, support = precision_recall_fscore_support(
y_test, y_pred, labels=labels, zero_division=0)
series = [('Precision', precision, SERIES[0]),
('Recall', recall, SERIES[1]),
('F1', f1, SERIES[2])]
fig, ax = plt.subplots(figsize=(7.5, 4.4))
y = np.arange(len(labels))
offsets = (0.22, 0.0, -0.22) # top-to-bottom matches the legend order
for (name, values, color), offset in zip(series, offsets):
ax.scatter(values, y + offset, s=70, color=color, label=name, zorder=3)
for value, position in zip(values, y + offset):
ax.annotate(f'{value:.3f}', (value, position),
textcoords='offset points', xytext=(9, -3),
fontsize=8.5, color=INK_SECONDARY)
ax.set_yticks(y)
ax.set_yticklabels([f'{label}\n(n={count:,})'
for label, count in zip(labels, support)], fontsize=10)
ax.set_xlabel('Score')
ax.set_title(title)
lowest = float(min(precision.min(), recall.min(), f1.min()))
ax.set_xlim(max(0.0, lowest - 0.06), 1.02)
ax.grid(axis='y', visible=False)
# legend below the plot - inside the axes it collides with the bottom row
ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.16), ncol=3)
strip_spines(ax, keep=('bottom',))
return finish(fig, save_path, show)