File size: 10,424 Bytes
d840583
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""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)