"""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)