Topic Classification of Reuters Newswires with Densely Connected Networks¶

A systematic application of the universal deep learning workflow¶

Dataset: Reuters newswire topic classification, 46 single-label classes

Author: Omar Ashraf Mohammed

Module: CM3015: Machine Learning and Neural Networks


This notebook applies the universal deep learning workflow in Section 4.5 of Deep Learning with Python (Chollet, 2017). Every predictive model is a Keras Sequential network restricted to Dense and Dropout layers. The final assessed artefact is the HTML export of this executed notebook.

Abstract¶

This study applies the universal deep learning workflow to the imbalanced 46-class Reuters newswire dataset. Newswires are represented by multi-hot word vectors and classified with Keras Sequential networks restricted to Dense and Dropout layers. A stratified validation partition supports controlled experiments on capacity, Dropout and vocabulary size. Class weighting is then evaluated over five training seeds and five independent validation splits. Square-root balanced weights raise mean validation macro F1 from 0.5217 to 0.6219 and reduce its seed standard deviation from 0.0473 to 0.0135. The selected model has two 128-unit hidden layers, no Dropout, a 5,000-word vocabulary and nine training epochs. On the untouched test split it achieves accuracy 0.8072 with a 95 percent bootstrap interval of [0.7912, 0.8241], macro F1 0.6280 with interval [0.5484, 0.6506], and weighted F1 0.8034. A learning curve remains positive through the full development partition, with a mean final macro F1 increase of 0.0564 across five paired runs. Class weighting therefore improves equal-weight class performance without reducing overall accuracy in these experiments. The main limitation is that multi-hot encoding discards word order and frequency, and the conclusions apply only to the tested Dense model family.

1. Introduction and scope¶

1.1 Aim and context¶

The aim is to determine how capacity, Dropout, vocabulary size and class weighting affect a densely connected classifier for Reuters newswires. Reuters is a useful test of multiclass learning because 46 topics are distributed unevenly and several classes contain few examples. Chollet's worked Reuters example establishes a relevant Dense-network reference for the same representation and reports test accuracy of approximately 0.797 (Chollet, 2017, Chapter 3). This investigation extends that example through controlled model comparison, rare-class metrics and repeated estimates of variability.

1.2 Scope¶

Every neural model is a Keras Sequential model containing only Dense and Dropout layers. The supplied test split is reserved for one final evaluation. All analysis, code and interpretation are contained in this notebook.

1.3 Research questions¶

  • RQ1. Does a small Dense network exceed a majority-class baseline on validation macro F1?
  • RQ2. How does unregularised capacity affect fit and overfitting?
  • RQ3. Does Dropout improve validation macro F1?
  • RQ4. Does a 10,000-word vocabulary improve on a 5,000-word vocabulary?
  • RQ5. Does class weighting improve rare-class performance without an unacceptable loss of overall accuracy?
  • RQ6. Is performance still improving as the amount of labelled training data increases?

Background¶

A Dense layer applies a learned affine transformation, (z = Wx + b), followed by an activation function. Each hidden unit can therefore combine evidence from every input feature. ReLU, (\max(0,z)), introduces non-linearity and permits stacked Dense layers to learn decision boundaries that a single linear transformation cannot express. The final layer has 46 units with softmax activation. Softmax converts the output scores into non-negative class probabilities that sum to one, so the largest probability supplies the predicted topic.

Categorical cross-entropy is the negative logarithm of the probability assigned to the correct class. Backpropagation uses the chain rule to calculate how each weight contributed to this loss. RMSprop then updates the weights using a moving average of squared gradients, which controls the scale of each update (Hinton, Srivastava and Swersky, 2012). Validation loss is used to select an epoch because it evaluates the complete predicted probability distribution.

Greater capacity can reduce training error but also fit patterns that do not generalise. Early stopping retains the weights from the minimum validation loss. Dropout supplies a second form of regularisation by randomly setting a proportion of hidden activations to zero during training (Srivastava et al., 2014). This discourages reliance on particular activation paths. At evaluation time all units are active and their outputs are scaled consistently by Keras.

Reuters is strongly imbalanced. Standard cross-entropy gives each newswire equal influence, so frequent classes contribute most of the training objective. Balanced class weights assign class (c) the weight (n/(K n_c)), where (n) is the training size, (K) the number of classes and (n_c) the support of class (c). A square-root scheme moderates these weights. Both schemes preserve the model architecture and operate through Keras training weights. Macro F1 gives each class equal importance during selection, while weighted F1 and accuracy describe performance under the observed class distribution (He and Garcia, 2009).

Environment and global controls¶

The next cells record the software environment, fix random seeds and request deterministic TensorFlow operations. Dataset exploration is limited to information needed to justify the evaluation measures and experimental choices.

In [1]:
# Core scientific stack. Only Dense and Dropout are permitted as model layers;
# NumPy, pandas, Matplotlib, seaborn, and scikit-learn support data handling,
# metrics, and figures, and Keras callbacks act as training controls.
import os

# Quieten TensorFlow low-level C++ logs before import so the report output stays
# clean; this affects logging only, not computation.
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3")

import ast
import platform
from collections import Counter, defaultdict
import time
import random
import warnings

import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns

import sklearn
from sklearn.model_selection import train_test_split
from sklearn.metrics import (
    f1_score,
    precision_score,
    recall_score,
    accuracy_score,
    balanced_accuracy_score,
    cohen_kappa_score,
    matthews_corrcoef,
    top_k_accuracy_score,
    roc_auc_score,
    log_loss,
    classification_report,
    confusion_matrix,
)
from sklearn.preprocessing import label_binarize

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from tensorflow.keras.callbacks import EarlyStopping

# Silence known-harmless warnings so the report output stays clean: the Keras
# input_shape notice, since the brief requires input_shape on the first Dense
# layer, and scikit-learn notices raised when a bootstrap resample of the
# imbalanced label set omits a rare class. That omission is a real property of
# resampling an imbalanced set, and its effect on the macro F1 interval is
# measured in Section 3 and is not assumed away.
from sklearn.exceptions import UndefinedMetricWarning
warnings.filterwarnings("ignore", category=UndefinedMetricWarning)
warnings.filterwarnings("ignore", message="Do not pass an", category=UserWarning)
warnings.filterwarnings("ignore", message="y_pred contains classes not in y_true")
# Raise the TensorFlow logger to ERROR so tf.function retracing notices during
# the many short predict calls do not clutter the report output.
tf.get_logger().setLevel("ERROR")
In [2]:
# Version and hardware provenance for reproducibility.
versions = pd.DataFrame(
    {
        "component": [
            "Python", "TensorFlow", "Keras", "NumPy", "pandas",
            "scikit-learn", "Matplotlib", "seaborn",
        ],
        "version": [
            platform.python_version(), tf.__version__, keras.__version__,
            np.__version__, pd.__version__, sklearn.__version__,
            matplotlib.__version__, sns.__version__,
        ],
    }
)

gpus = tf.config.list_physical_devices("GPU")
hardware = f"{platform.system()} {platform.machine()}, GPUs visible to TensorFlow: {len(gpus)}"
print(hardware)
versions
Darwin arm64, GPUs visible to TensorFlow: 0
Out[2]:
component version
0 Python 3.12.0
1 TensorFlow 2.18.0
2 Keras 3.6.0
3 NumPy 2.1.2
4 pandas 2.2.2
5 scikit-learn 1.5.2
6 Matplotlib 3.9.2
7 seaborn 0.13.2
In [3]:
# Controls used throughout the investigation.
RANDOM_STATE = 42
REUTERS_SEED = 113
VOCAB_MAIN = 10000
VOCAB_SMALL = 5000
NUM_CLASSES = 46
BATCH_SIZE = 512
MAX_EPOCHS = 30
SEEDS = [7, 42, 113, 2026, 31415]
TIE_THRESHOLD = 0.005

EARLY_STOPPING = dict(
    monitor="val_loss",
    patience=4,
    min_delta=0.001,
    restore_best_weights=True,
)

os.environ["PYTHONHASHSEED"] = "0"
try:
    tf.config.experimental.enable_op_determinism()
except Exception:
    pass


def set_all_seeds(seed):
    random.seed(seed)
    np.random.seed(seed)
    keras.utils.set_random_seed(seed)


sns.set_theme(style="whitegrid", palette="colorblind")
plt.rcParams.update({
    "figure.dpi": 110,
    "font.size": 11,
    "axes.titlesize": 12,
    "axes.labelsize": 11,
    "legend.fontsize": 10,
    "figure.autolayout": True,
})
PALETTE = sns.color_palette("colorblind")

pd.set_option("display.max_rows", 120)
pd.set_option("display.max_colwidth", None)
print("Global controls set.")
Global controls set.

2. The universal deep learning workflow¶

This section applies the seven steps of the universal workflow (Chollet, 2017, Section 4.5) to the Reuters task. Each step states its purpose before the code and interprets the outcome after the result.

2.1 Step 1: Defining the problem and assembling the dataset¶

The task is single-label multiclass classification. One newswire is an observation, its word indices form the input, and one of 46 topic identifiers is the target. The model returns a probability distribution over these classes. Supervised learning assumes that labels are sufficiently reliable and that future observations resemble the historical training distribution.

Reuters is loaded twice with the same shuffle seed so each vocabulary experiment uses Keras's corresponding out-of-vocabulary handling. The labels and split membership are asserted to be identical.

In [4]:
# Load matching 10,000-word and 5,000-word versions of Reuters.
(train_data, train_labels), (test_data, test_labels) = keras.datasets.reuters.load_data(
    num_words=VOCAB_MAIN,
    seed=REUTERS_SEED,
)
(train_data_small, train_labels_small), (test_data_small, _) = (
    keras.datasets.reuters.load_data(num_words=VOCAB_SMALL, seed=REUTERS_SEED)
)

train_labels = np.asarray(train_labels, dtype="int64")
test_labels = np.asarray(test_labels, dtype="int64")
train_labels_small = np.asarray(train_labels_small, dtype="int64")

assert np.array_equal(train_labels, train_labels_small)
assert set(np.unique(train_labels)) == set(range(NUM_CLASSES))
print(f"Training newswires: {len(train_data)}   Test newswires: {len(test_data)}")
Training newswires: 8982   Test newswires: 2246
In [5]:
# Compact dataset summary. Sequence lengths describe raw newswire lengths before
# multi-hot encoding, which motivates the fixed-length representation in Step 4.
train_lengths = np.array([len(seq) for seq in train_data])
class_counts = pd.Series(train_labels).value_counts().sort_index()

summary = pd.DataFrame(
    {
        "quantity": [
            "Training examples", "Test examples", "Number of classes",
            "Vocabulary limit", "Median newswire length (tokens)",
            "Mean newswire length (tokens)", "Maximum newswire length (tokens)",
            "Largest class share", "Smallest class support",
        ],
        "value": [
            len(train_data), len(test_data), NUM_CLASSES, VOCAB_MAIN,
            int(np.median(train_lengths)), round(float(train_lengths.mean()), 1),
            int(train_lengths.max()),
            f"{class_counts.max() / len(train_labels):.1%} (class {int(class_counts.idxmax())})",
            f"{int(class_counts.min())} (class {int(class_counts.idxmin())})",
        ],
    }
)
summary
Out[5]:
quantity value
0 Training examples 8982
1 Test examples 2246
2 Number of classes 46
3 Vocabulary limit 10000
4 Median newswire length (tokens) 95
5 Mean newswire length (tokens) 145.5
6 Maximum newswire length (tokens) 2376
7 Largest class share 35.2% (class 3)
8 Smallest class support 10 (class 35)

Interpretation. The training partition contains 8982 newswires and the reserved test partition 2246. Newswire lengths are short and right skewed, with a median of 95 tokens. Class support is markedly uneven: the largest class holds 35.2% of the training examples while the smallest holds only 10. This imbalance is the central reason for selecting macro F1 as the primary measure in Step 2, since a classifier can reach moderate accuracy by favouring frequent topics while failing the rare ones.

2.2 Step 2: Choosing a measure of success¶

Categorical cross-entropy is the optimisation loss and selects the training epoch. Accuracy measures the overall correct proportion. Macro F1 gives every class equal weight and is the primary model-selection measure because Reuters is imbalanced. Weighted F1 describes performance under the observed class frequencies.

Models are ranked by validation macro F1. Values within 0.005 are treated as practically tied, with lower validation cross-entropy and then fewer parameters used as tie-breaks. This is a decision rule and does not establish statistical equivalence. Balanced accuracy, top-k accuracy and calibration are reserved for final diagnosis.

In [6]:
# The metric hierarchy is fixed here so that later selection is transparent.
metric_plan = pd.DataFrame(
    {
        "measure": [
            "Categorical cross-entropy", "Macro F1", "Accuracy",
            "Weighted F1", "Generalisation gap", "Parameter count",
            "Balanced accuracy", "Cohen's kappa",
            "Matthews correlation coefficient", "Top-3 and top-5 accuracy",
            "Macro one-vs-rest ROC AUC", "Expected calibration error",
        ],
        "role": [
            "Optimisation objective and epoch selection",
            "Primary model-selection measure",
            "Secondary, interpretable overall measure",
            "Secondary, support-weighted quality",
            "Diagnostic for overfitting",
            "Tie-break and efficiency",
            "Final diagnostic, imbalance-robust recall",
            "Final diagnostic, chance-corrected agreement",
            "Final diagnostic, balanced multiclass correlation",
            "Final diagnostic, ranked-shortlist quality",
            "Final diagnostic, threshold-independent separability",
            "Final diagnostic, confidence calibration",
        ],
    }
)
metric_plan
Out[6]:
measure role
0 Categorical cross-entropy Optimisation objective and epoch selection
1 Macro F1 Primary model-selection measure
2 Accuracy Secondary, interpretable overall measure
3 Weighted F1 Secondary, support-weighted quality
4 Generalisation gap Diagnostic for overfitting
5 Parameter count Tie-break and efficiency
6 Balanced accuracy Final diagnostic, imbalance-robust recall
7 Cohen's kappa Final diagnostic, chance-corrected agreement
8 Matthews correlation coefficient Final diagnostic, balanced multiclass correlation
9 Top-3 and top-5 accuracy Final diagnostic, ranked-shortlist quality
10 Macro one-vs-rest ROC AUC Final diagnostic, threshold-independent separability
11 Expected calibration error Final diagnostic, confidence calibration

The diagnostic functions are defined once and reused during final evaluation. Expected calibration error divides predictions into ten confidence intervals and compares mean confidence with observed accuracy (Guo et al., 2017). Macro one-vs-rest ROC AUC is retained as a secondary diagnostic and is not used for selection.

In [7]:
LABELS = list(range(NUM_CLASSES))


def macro_ovr_auc(y_true, y_proba):
    # One-vs-rest ROC AUC averaged over classes that contain both a positive and
    # a negative test case; classes without both cannot yield a defined AUC.
    binary = label_binarize(y_true, classes=LABELS)
    scores = []
    for k in LABELS:
        column = binary[:, k]
        if 0 < column.sum() < len(column):
            scores.append(roc_auc_score(column, y_proba[:, k]))
    return float(np.mean(scores)), len(scores)


def expected_calibration_error(confidence, correct, n_bins=10):
    # Partition predictions into equal-width confidence bins and accumulate the
    # support-weighted gap between mean confidence and empirical accuracy.
    edges = np.linspace(0.0, 1.0, n_bins + 1)
    total = len(confidence)
    ece = 0.0
    rows = []
    for b in range(n_bins):
        low, high = edges[b], edges[b + 1]
        in_bin = (confidence > low) & (confidence <= high)
        if b == 0:
            in_bin = (confidence >= low) & (confidence <= high)
        count = int(in_bin.sum())
        if count == 0:
            rows.append({"bin_low": low, "bin_high": high, "count": 0,
                         "accuracy": np.nan, "confidence": np.nan})
            continue
        acc = float(correct[in_bin].mean())
        conf = float(confidence[in_bin].mean())
        ece += (count / total) * abs(acc - conf)
        rows.append({"bin_low": low, "bin_high": high, "count": count,
                     "accuracy": acc, "confidence": conf})
    return ece, pd.DataFrame(rows)


def full_metric_suite(y_true, y_pred, y_proba):
    # Scalar diagnostic suite reported at the final evaluation. Macro and
    # weighted variants use zero_division=0 so absent classes score zero.
    confidence = y_proba.max(axis=1)
    correct = (y_pred == y_true).astype(float)
    ece, _ = expected_calibration_error(confidence, correct)
    auc, auc_classes = macro_ovr_auc(y_true, y_proba)
    return {
        "Cross-entropy": log_loss(y_true, y_proba, labels=LABELS),
        "Accuracy": accuracy_score(y_true, y_pred),
        "Balanced accuracy": balanced_accuracy_score(y_true, y_pred),
        "Top-3 accuracy": top_k_accuracy_score(y_true, y_proba, k=3, labels=LABELS),
        "Top-5 accuracy": top_k_accuracy_score(y_true, y_proba, k=5, labels=LABELS),
        "Macro precision": precision_score(y_true, y_pred, average="macro",
                                           labels=LABELS, zero_division=0),
        "Macro recall": recall_score(y_true, y_pred, average="macro",
                                     labels=LABELS, zero_division=0),
        "Macro F1": f1_score(y_true, y_pred, average="macro",
                             labels=LABELS, zero_division=0),
        "Weighted precision": precision_score(y_true, y_pred, average="weighted",
                                              labels=LABELS, zero_division=0),
        "Weighted recall": recall_score(y_true, y_pred, average="weighted",
                                        labels=LABELS, zero_division=0),
        "Weighted F1": f1_score(y_true, y_pred, average="weighted",
                                labels=LABELS, zero_division=0),
        "Cohen's kappa": cohen_kappa_score(y_true, y_pred),
        "Matthews corrcoef": matthews_corrcoef(y_true, y_pred),
        f"Macro OVR ROC AUC ({auc_classes} classes)": auc,
        "Expected calibration error": ece,
    }

2.3 Step 3: Deciding on an evaluation protocol¶

The supplied test split is not used during model development. A reproducible stratified 80:20 split of the supplied training data creates development and validation partitions. Candidate comparisons use identical rows. Capacity, Dropout and vocabulary are screened on this controlled split. All three class-weighting schemes are then repeated across five training seeds. The two strongest schemes are also compared across five independent stratified splits, which tests sensitivity to partition choice.

Repeated use of validation data can favour its particular examples. Seed and split repetitions quantify parts of this uncertainty, although they do not replace evaluation on the untouched test set.

In [8]:
# One reproducible stratified 80:20 development and validation split.
# Integer targets are retained for scikit-learn metrics; one-hot targets are
# created in Step 4 for categorical cross-entropy.
dev_idx, val_idx = train_test_split(
    np.arange(len(train_labels)),
    test_size=0.20,
    random_state=RANDOM_STATE,
    stratify=train_labels,
)

# Disjointness and coverage assertions guard against leakage between partitions.
assert set(dev_idx).isdisjoint(set(val_idx))
assert len(dev_idx) + len(val_idx) == len(train_labels)

y_dev_int = train_labels[dev_idx]
y_val_int = train_labels[val_idx]

split_table = pd.DataFrame(
    {
        "partition": ["Development", "Validation", "Test (reserved)"],
        "examples": [len(dev_idx), len(val_idx), len(test_labels)],
        "share of training": [
            f"{len(dev_idx) / len(train_labels):.0%}",
            f"{len(val_idx) / len(train_labels):.0%}",
            "held out",
        ],
    }
)
split_table
Out[8]:
partition examples share of training
0 Development 7185 80%
1 Validation 1797 20%
2 Test (reserved) 2246 held out

2.4 Step 4: Preparing the data¶

Each newswire becomes a fixed-length binary vector whose entries record whether a vocabulary item appears. This representation is compatible with Dense layers and follows Chollet's Reuters example. It discards word order and term frequency. Targets are one-hot encoded for categorical cross-entropy, while integer labels are retained for scikit-learn metrics.

In [9]:
def multi_hot_encode(sequences, dimension):
    # Encode word presence in a float32 matrix.
    if dimension <= 0:
        raise ValueError("dimension must be positive")
    result = np.zeros((len(sequences), dimension), dtype="float32")
    for row, sequence in enumerate(sequences):
        valid = [token for token in sequence if 0 <= token < dimension]
        result[row, valid] = 1.0
    return result


def sequences_for_vocab(dimension):
    if dimension == VOCAB_MAIN:
        return train_data, test_data
    if dimension == VOCAB_SMALL:
        return train_data_small, test_data_small
    raise ValueError(f"Unsupported vocabulary size: {dimension}")


def prepare_dev_val(dimension):
    training_sequences, _ = sequences_for_vocab(dimension)
    x_development = multi_hot_encode(
        [training_sequences[i] for i in dev_idx], dimension
    )
    x_validation = multi_hot_encode(
        [training_sequences[i] for i in val_idx], dimension
    )
    return x_development, x_validation


x_dev, x_val = prepare_dev_val(VOCAB_MAIN)
y_dev = keras.utils.to_categorical(y_dev_int, NUM_CLASSES)
y_val = keras.utils.to_categorical(y_val_int, NUM_CLASSES)

assert x_dev.dtype == np.float32 and x_val.dtype == np.float32
assert x_dev.shape[1] == VOCAB_MAIN and y_dev.shape[1] == NUM_CLASSES
assert set(np.unique(x_dev)).issubset({0.0, 1.0})
assert np.isfinite(x_dev).all()
assert np.isclose(y_dev.sum(axis=1), 1).all()
print(f"x_dev {x_dev.shape} {x_dev.dtype}   y_dev {y_dev.shape}")
print(f"x_val {x_val.shape}   validation targets {y_val.shape}")
x_dev (7185, 10000) float32   y_dev (7185, 46)
x_val (1797, 10000)   validation targets (1797, 46)
In [10]:
# Figure 1: class support in the development partition. This is the first figure
# in the report and appears only after the problem, measures, protocol, and data
# preparation have been explained. It motivates the macro F1 emphasis.
dev_counts = pd.Series(y_dev_int).value_counts().sort_index()

fig, ax = plt.subplots(figsize=(9, 3.2))
ax.bar(dev_counts.index, dev_counts.values, color=PALETTE[0])
ax.set_xlabel("Class identifier")
ax.set_ylabel("Development examples")
ax.set_title("Figure 1. Class support in the development partition")
ax.set_xlim(-1, NUM_CLASSES)
plt.show()
plt.close(fig)
No description has been provided for this image

Interpretation. Figure 1 confirms a long-tailed class distribution. A small number of topics dominate the development partition while many topics have limited support. Under this imbalance, accuracy is an incomplete measure, which is why macro F1 leads the selection hierarchy. The class labels are shown as integer identifiers because the installed Keras Reuters API does not supply official topic names, and inventing names is out of scope.

2.5 Step 5: Developing a model that performs above a baseline¶

Uniform chance and a majority-class rule establish elementary reference levels. The neural baseline has one 16-unit ReLU hidden layer and a 46-unit softmax output. A shared model factory ensures that all candidates use the same optimiser, loss and permitted layer family.

In [11]:
def build_model(input_dim, hidden_units, dropout_rate, num_classes, seed):
    # Sequential model of Dense and Dropout layers only. The first Dense layer
    # receives input_shape, so a separate Input layer is unnecessary. Dropout is
    # placed after every hidden Dense layer and never after the output.
    set_all_seeds(seed)
    model = keras.Sequential(name="reuters_mlp")
    for position, units in enumerate(hidden_units):
        if position == 0:
            model.add(layers.Dense(units, activation="relu", input_shape=(input_dim,)))
        else:
            model.add(layers.Dense(units, activation="relu"))
        if dropout_rate > 0.0:
            model.add(layers.Dropout(dropout_rate))
    model.add(layers.Dense(num_classes, activation="softmax"))

    # Permitted-layer audit: every layer must be Dense or Dropout.
    for layer in model.layers:
        assert isinstance(layer, (layers.Dense, layers.Dropout)), type(layer)

    model.compile(optimizer="rmsprop", loss="categorical_crossentropy",
                  metrics=["accuracy"])
    return model
In [12]:
def make_class_weights(labels_int, scheme):
    # Recalculate weights from the training rows used by each fit.
    if scheme == "none":
        return None
    counts = np.bincount(labels_int, minlength=NUM_CLASSES).astype(float)
    if np.any(counts == 0):
        raise ValueError("Every class must be present in the training partition")
    balanced = len(labels_int) / (NUM_CLASSES * counts)
    if scheme == "sqrt_balanced":
        weights = np.sqrt(balanced)
    elif scheme == "balanced":
        weights = balanced
    else:
        raise ValueError(f"Unknown class-weight scheme: {scheme}")
    weights = weights / np.mean(weights[labels_int])
    return {class_id: float(weight) for class_id, weight in enumerate(weights)}


def run_experiment(
    name,
    hidden_units,
    dropout_rate,
    vocab_size,
    seed,
    features,
    class_weight_scheme="none",
    use_early_stopping=True,
    max_epochs=MAX_EPOCHS,
    keep_history=False,
):
    x_tr, y_tr, x_va, y_va, y_va_int = features
    y_tr_int = y_tr.argmax(axis=1)
    keras.backend.clear_session()
    set_all_seeds(seed)

    model = build_model(
        x_tr.shape[1], hidden_units, dropout_rate, NUM_CLASSES, seed
    )
    early_stopper = EarlyStopping(**EARLY_STOPPING)
    callbacks = [early_stopper] if use_early_stopping else []
    class_weights = make_class_weights(y_tr_int, class_weight_scheme)

    start = time.perf_counter()
    history = model.fit(
        x_tr,
        y_tr,
        validation_data=(x_va, y_va),
        epochs=max_epochs,
        batch_size=BATCH_SIZE,
        class_weight=class_weights,
        callbacks=callbacks,
        verbose=0,
    )
    elapsed = time.perf_counter() - start

    if use_early_stopping:
        best_epoch = int(early_stopper.best_epoch) + 1
    else:
        best_epoch = int(np.argmin(history.history["val_loss"])) + 1

    # Evaluate both partitions with Dropout disabled and restored weights active.
    train_loss, train_acc = model.evaluate(
        x_tr, y_tr, batch_size=BATCH_SIZE, verbose=0
    )
    val_loss, val_acc = model.evaluate(
        x_va, y_va, batch_size=BATCH_SIZE, verbose=0
    )
    proba = model.predict(x_va, batch_size=BATCH_SIZE, verbose=0)
    preds = proba.argmax(axis=1)
    macro = f1_score(
        y_va_int,
        preds,
        average="macro",
        labels=LABELS,
        zero_division=0,
    )
    weighted = f1_score(
        y_va_int,
        preds,
        average="weighted",
        labels=LABELS,
        zero_division=0,
    )

    record = {
        "name": name,
        "hidden_units": str(list(hidden_units)),
        "dropout": dropout_rate,
        "vocab": vocab_size,
        "class_weight": class_weight_scheme,
        "seed": seed,
        "params": model.count_params(),
        "epochs_run": len(history.history["val_loss"]),
        "best_epoch": best_epoch,
        "train_loss": round(float(train_loss), 4),
        "val_loss": round(float(val_loss), 4),
        "train_acc": round(float(train_acc), 4),
        "val_acc": round(float(val_acc), 4),
        "val_macro_f1": round(float(macro), 4),
        "val_weighted_f1": round(float(weighted), 4),
        "acc_gap": round(float(train_acc - val_acc), 4),
        "seconds": round(elapsed, 1),
    }
    hist = history.history if keep_history else None
    keras.backend.clear_session()
    return record, hist


def parse_units(units_field):
    return ast.literal_eval(units_field)


FIT_COUNTER = {"n": 0}


def counted_run(*args, **kwargs):
    record, hist = run_experiment(*args, **kwargs)
    FIT_COUNTER["n"] += 1
    return record, hist


features_main = (x_dev, y_dev, x_val, y_val, y_val_int)
print("Model factory and experiment runner ready.")
Model factory and experiment runner ready.
In [13]:
# Baselines on the validation partition.
uniform_acc = 1.0 / NUM_CLASSES
majority_class = int(pd.Series(y_dev_int).value_counts().idxmax())
majority_pred = np.full_like(y_val_int, majority_class)
majority_acc = accuracy_score(y_val_int, majority_pred)
majority_macro = f1_score(y_val_int, majority_pred, average="macro",
                          labels=list(range(NUM_CLASSES)), zero_division=0)

baseline_table = pd.DataFrame(
    {
        "baseline": ["Uniform chance", "Majority class"],
        "val_accuracy": [round(uniform_acc, 4), round(majority_acc, 4)],
        "val_macro_f1": ["n/a", round(majority_macro, 4)],
    }
)
baseline_table
Out[13]:
baseline val_accuracy val_macro_f1
0 Uniform chance 0.0217 n/a
1 Majority class 0.3517 0.0113
In [14]:
# Small densely connected neural baseline: one hidden layer of 16 units. This is
# also the small_16 point in the Step 7 capacity phase, so its record is reused.
baseline_record, baseline_hist = counted_run(
    "small_16", [16], 0.0, VOCAB_MAIN, RANDOM_STATE,
    features_main, use_early_stopping=True, keep_history=True,
)
pd.DataFrame([baseline_record])[
    ["name", "params", "best_epoch", "val_loss", "val_acc", "val_macro_f1"]
]
Out[14]:
name params best_epoch val_loss val_acc val_macro_f1
0 small_16 160798 30 0.8871 0.8058 0.4855

Interpretation of RQ1. Uniform chance gives accuracy 0.0217. Predicting the majority class gives accuracy 0.3517 and macro F1 0.0113. The small neural model reaches validation accuracy 0.8058 and macro F1 0.4855. It therefore learns useful topic structure and clearly exceeds the elementary baselines. The test set is not used here.

2.6 Step 6: Scaling up and establishing overfitting¶

To expose overfitting, a deliberately high-capacity network of three 256-unit hidden layers is trained without Dropout for the full 30 epochs with early stopping disabled. The purpose is diagnostic: the complete training curves reveal the onset and development of the training and validation divergence. This run is not automatically the selected model.

In [15]:
# High-capacity probe: three hidden layers of 256 units, no Dropout, no early
# stopping, so the full divergence is visible in the curves.
probe_record, probe_hist = counted_run(
    "probe_256_256_256", [256, 256, 256], 0.0, VOCAB_MAIN, RANDOM_STATE,
    features_main, use_early_stopping=False, max_epochs=30, keep_history=True,
)
probe_best_epoch = probe_record["best_epoch"]
pd.DataFrame([probe_record])[
    ["name", "params", "epochs_run", "best_epoch", "train_loss", "val_loss",
     "train_acc", "val_acc"]
]
Out[15]:
name params epochs_run best_epoch train_loss val_loss train_acc val_acc
0 probe_256_256_256 2703662 30 7 0.0699 1.055 0.9729 0.8114
In [16]:
# Figure 2: training and validation curves for the high-capacity probe.
epochs_axis = range(1, len(probe_hist["loss"]) + 1)
fig, axes = plt.subplots(1, 2, figsize=(11, 3.8))

axes[0].plot(epochs_axis, probe_hist["loss"], color=PALETTE[0], label="Training")
axes[0].plot(epochs_axis, probe_hist["val_loss"], color=PALETTE[3], label="Validation")
axes[0].axvline(probe_best_epoch, color="grey", linestyle="--", linewidth=1,
                label=f"Min val loss (epoch {probe_best_epoch})")
axes[0].set_xlabel("Epoch"); axes[0].set_ylabel("Categorical cross-entropy")
axes[0].set_title("Loss"); axes[0].legend()

axes[1].plot(epochs_axis, probe_hist["accuracy"], color=PALETTE[0], label="Training")
axes[1].plot(epochs_axis, probe_hist["val_accuracy"], color=PALETTE[3], label="Validation")
axes[1].axvline(probe_best_epoch, color="grey", linestyle="--", linewidth=1)
axes[1].set_xlabel("Epoch"); axes[1].set_ylabel("Accuracy")
axes[1].set_title("Accuracy"); axes[1].legend()

fig.suptitle("Figure 2. High-capacity probe training and validation curves", y=1.03)
plt.show()
plt.close(fig)
No description has been provided for this image

Interpretation of RQ2. Training loss continues towards zero and validation loss reaches its minimum at epoch 7 before rising. Evaluation of the final weights gives training accuracy 0.9729 and validation accuracy 0.8114, a gap of 0.1615. The network has enough capacity to fit the development data closely, and later epochs reduce validation performance. This supplies the evidence for early stopping in the candidate experiments.

2.7 Step 7: Regularising the model and tuning hyperparameters¶

Four controlled phases examine capacity, Dropout, vocabulary size and class weighting. The first three screen one factor at a time on a fixed split and seed. Class weighting is then evaluated across all five training seeds because it directly addresses the selected macro F1 objective. The two strongest weighting schemes are carried into the repeated-split study.

2.7.1 Phase 1: Capacity¶

Five architectures span narrow to wide and shallow to deep, including a deliberate bottleneck whose second layer has fewer units than the number of classes. All use no Dropout and the main vocabulary, with early stopping on validation loss.

In [17]:
# Phase 1 capacity configurations. The small_16 baseline record is reused.
phase1_configs = [
    ("small_16", [16]),
    ("reference_64_64", [64, 64]),
    ("wide_128_128", [128, 128]),
    ("deep_64_64_64", [64, 64, 64]),
    ("bottleneck_64_4", [64, 4]),
]

phase1_records = [baseline_record]
phase1_histories = {"small_16": baseline_hist}
for name, units in phase1_configs:
    if name == "small_16":
        continue
    rec, hist = counted_run(name, units, 0.0, VOCAB_MAIN, RANDOM_STATE,
                            features_main, use_early_stopping=True, keep_history=True)
    phase1_records.append(rec)
    phase1_histories[name] = hist

phase1_df = pd.DataFrame(phase1_records)
phase1_view = phase1_df[["name", "hidden_units", "params", "best_epoch",
                         "val_loss", "val_acc", "val_macro_f1", "acc_gap",
                         "seconds"]]
phase1_view
Out[17]:
name hidden_units params best_epoch val_loss val_acc val_macro_f1 acc_gap seconds
0 small_16 [16] 160798 30 0.8871 0.8058 0.4855 0.1420 3.4
1 reference_64_64 [64, 64] 647214 11 0.8899 0.8075 0.4867 0.1383 3.0
2 wide_128_128 [128, 128] 1302574 9 0.8577 0.8141 0.5298 0.1431 3.5
3 deep_64_64_64 [64, 64, 64] 651374 10 1.0042 0.7963 0.4071 0.1381 2.9
4 bottleneck_64_4 [64, 4] 640554 21 1.2937 0.6956 0.1343 0.0870 4.6
In [18]:
def select_best(df):
    # Apply the declared rule: highest macro F1, ties within TIE_THRESHOLD
    # resolved by lower validation loss then fewer parameters.
    top = df.sort_values("val_macro_f1", ascending=False).iloc[0]
    contenders = df[df["val_macro_f1"] >= top["val_macro_f1"] - TIE_THRESHOLD]
    contenders = contenders.sort_values(["val_loss", "params"],
                                        ascending=[True, True])
    return contenders.iloc[0]


phase1_best = select_best(phase1_df)
best_units = parse_units(phase1_best["hidden_units"])
top_two = phase1_df["val_macro_f1"].nlargest(2)
phase1_margin = round(float(top_two.iloc[0] - top_two.iloc[1]), 4)
print(f"Phase 1 selection: {phase1_best['name']} "
      f"(macro F1 {phase1_best['val_macro_f1']}, params {phase1_best['params']})")
print(f"Margin over the runner-up: {phase1_margin}")
Phase 1 selection: wide_128_128 (macro F1 0.5298, params 1302574)
Margin over the runner-up: 0.0431

Interpretation. The four-unit bottleneck performs poorly, which is consistent with excessive compression before a 46-class output. The 128-128 model gives the highest validation macro F1, 0.5298, and the lowest validation loss among the useful candidates. Its 0.0431 macro F1 margin over the 64-64 model comes from one seed and split, so it is treated as a screening result.

2.7.2 Phase 2: Dropout¶

Dropout rates of 0.0, 0.2, 0.35 and 0.5 are compared on the selected capacity. Training and validation metrics are both evaluated after training with Dropout disabled, making their generalisation gap directly comparable.

In [19]:
# Phase 2 varies Dropout on the Phase 1 winner, all else fixed.
dropout_rates = [0.0, 0.2, 0.35, 0.5]
phase2_records, phase2_histories = [], {}
for rate in dropout_rates:
    name = f"drop_{rate}"
    rec, hist = counted_run(name, best_units, rate, VOCAB_MAIN, RANDOM_STATE,
                            features_main, use_early_stopping=True, keep_history=True)
    phase2_records.append(rec)
    phase2_histories[rate] = hist

phase2_df = pd.DataFrame(phase2_records)
phase2_view = phase2_df[["name", "dropout", "params", "best_epoch", "val_loss",
                         "val_acc", "val_macro_f1", "acc_gap", "seconds"]]
phase2_view
Out[19]:
name dropout params best_epoch val_loss val_acc val_macro_f1 acc_gap seconds
0 drop_0.0 0.00 1302574 9 0.8577 0.8141 0.5298 0.1431 3.5
1 drop_0.2 0.20 1302574 9 0.8672 0.8158 0.4898 0.1291 3.7
2 drop_0.35 0.35 1302574 11 0.8943 0.8164 0.4577 0.1257 4.3
3 drop_0.5 0.50 1302574 14 0.9322 0.8058 0.3792 0.1214 4.8
In [20]:
phase2_best = select_best(phase2_df)
best_dropout = float(phase2_best["dropout"])
print(f"Phase 2 selection: dropout {best_dropout} "
      f"(macro F1 {phase2_best['val_macro_f1']}, gap {phase2_best['acc_gap']})")
Phase 2 selection: dropout 0.0 (macro F1 0.5298, gap 0.1431)
In [21]:
# Figure 3: Dropout effect on validation macro F1 and the accuracy gap.
fig, ax1 = plt.subplots(figsize=(7, 3.6))
ax1.plot(phase2_df["dropout"], phase2_df["val_macro_f1"], marker="o",
         color=PALETTE[0], label="Validation macro F1")
ax1.set_xlabel("Dropout rate"); ax1.set_ylabel("Validation macro F1", color=PALETTE[0])
ax1.tick_params(axis="y", labelcolor=PALETTE[0])

ax2 = ax1.twinx()
ax2.plot(phase2_df["dropout"], phase2_df["acc_gap"], marker="s",
         color=PALETTE[3], label="Accuracy gap")
ax2.set_ylabel("Train minus validation accuracy", color=PALETTE[3])
ax2.tick_params(axis="y", labelcolor=PALETTE[3])
ax2.grid(False)

fig.suptitle("Figure 3. Effect of Dropout on validation macro F1 and generalisation gap")
plt.show()
plt.close(fig)
No description has been provided for this image

Interpretation of RQ3. Post-training evaluation places both partitions in inference mode. The accuracy gap falls from 0.1431 at Dropout 0.0 to 0.1214 at rate 0.5, showing a modest reduction in the fit difference. Validation macro F1 falls at every tested rate, from 0.5298 to 0.4898, 0.4577 and 0.3792. Validation loss also rises from 0.8577 to 0.9322. Dropout therefore weakens the primary measure in this configuration, and rate 0.0 is selected. This result does not exclude an interaction between Dropout and another network width.

2.7.3 Phase 3: Vocabulary size¶

The selected architecture and Dropout setting are compared using Reuters loaded separately at 5,000 and 10,000 words. The loader seed and labels remain identical, while Keras applies the correct out-of-vocabulary handling for each vocabulary limit.

In [22]:
# Phase 3 varies vocabulary size with matching Keras representations.
x_dev_s, x_val_s = prepare_dev_val(VOCAB_SMALL)
features_small = (x_dev_s, y_dev, x_val_s, y_val, y_val_int)

phase3_records = []
for vocab, feats in [(VOCAB_SMALL, features_small), (VOCAB_MAIN, features_main)]:
    name = f"vocab_{vocab}"
    rec, _ = counted_run(
        name,
        best_units,
        best_dropout,
        vocab,
        RANDOM_STATE,
        feats,
        use_early_stopping=True,
        keep_history=False,
    )
    phase3_records.append(rec)

phase3_df = pd.DataFrame(phase3_records)
phase3_view = phase3_df[[
    "name",
    "vocab",
    "params",
    "best_epoch",
    "val_loss",
    "val_acc",
    "val_macro_f1",
    "acc_gap",
    "seconds",
]]
phase3_view
Out[22]:
name vocab params best_epoch val_loss val_acc val_macro_f1 acc_gap seconds
0 vocab_5000 5000 662574 10 0.8594 0.8119 0.5359 0.1437 2.4
1 vocab_10000 10000 1302574 9 0.8577 0.8141 0.5298 0.1431 3.5
In [23]:
phase3_best = select_best(phase3_df)
best_vocab = int(phase3_best["vocab"])
print(f"Phase 3 selection: vocabulary {best_vocab} "
      f"(macro F1 {phase3_best['val_macro_f1']})")
Phase 3 selection: vocabulary 5000 (macro F1 0.5359)

Interpretation of RQ4. The 5,000-word representation reaches validation macro F1 0.5359, compared with 0.5298 for 10,000 words. The difference is 0.0061 on one seed and split. The smaller representation also reduces the parameter count from 1,302,574 to 662,574. The declared rule selects 5,000 words on the primary measure. The result is treated as a screening decision because it does not establish a stable difference between vocabulary sizes.

2.7.4 Phase 4: Class weighting and training-seed stability¶

Three training objectives are compared on the selected architecture and vocabulary: unweighted cross-entropy, square-root balanced weights and fully balanced inverse-frequency weights. Weights are calculated from the development labels used by each fit and normalised to a mean sample weight of one. Every scheme is repeated across five seeds. This phase addresses the imbalance directly and avoids selecting a weighting scheme from one stochastic run.

In [24]:
# Inspect the range of weights before fitting the three schemes.
weight_schemes = ["none", "sqrt_balanced", "balanced"]


def feature_bundle_for_vocab(vocab):
    if vocab == VOCAB_MAIN:
        return features_main
    if vocab == VOCAB_SMALL:
        return features_small
    raise ValueError(f"Unsupported vocabulary size: {vocab}")


weight_rows = []
for scheme in weight_schemes:
    weights = make_class_weights(y_dev_int, scheme)
    weight_rows.append({
        "scheme": scheme,
        "minimum_weight": 1.0 if weights is None else min(weights.values()),
        "maximum_weight": 1.0 if weights is None else max(weights.values()),
    })
pd.DataFrame(weight_rows).round(3)
Out[24]:
scheme minimum_weight maximum_weight
0 none 1.000 1.000
1 sqrt_balanced 0.357 6.342
2 balanced 0.062 19.524
In [25]:
# Repeat every class-weighting scheme across five training seeds.
stability_rows = []
selected_features = feature_bundle_for_vocab(best_vocab)
for scheme in weight_schemes:
    for seed in SEEDS:
        rec, _ = counted_run(
            f"weight_{scheme}",
            best_units,
            best_dropout,
            best_vocab,
            seed,
            selected_features,
            class_weight_scheme=scheme,
            use_early_stopping=True,
            keep_history=False,
        )
        stability_rows.append(rec)

stability_df = pd.DataFrame(stability_rows)
stability_df[[
    "name",
    "class_weight",
    "seed",
    "best_epoch",
    "val_loss",
    "val_acc",
    "val_macro_f1",
    "val_weighted_f1",
]]
Out[25]:
name class_weight seed best_epoch val_loss val_acc val_macro_f1 val_weighted_f1
0 weight_none none 7 8 0.9001 0.8041 0.4616 0.7847
1 weight_none none 42 10 0.8594 0.8119 0.5359 0.7973
2 weight_none none 113 11 0.8679 0.8175 0.5912 0.8089
3 weight_none none 2026 9 0.8596 0.8147 0.5129 0.7997
4 weight_none none 31415 9 0.8579 0.8169 0.5068 0.8013
5 weight_sqrt_balanced sqrt_balanced 7 8 0.8086 0.8247 0.6008 0.8177
6 weight_sqrt_balanced sqrt_balanced 42 9 0.8042 0.8286 0.6351 0.8223
7 weight_sqrt_balanced sqrt_balanced 113 9 0.8006 0.8280 0.6201 0.8211
8 weight_sqrt_balanced sqrt_balanced 2026 9 0.8042 0.8303 0.6213 0.8224
9 weight_sqrt_balanced sqrt_balanced 31415 8 0.8081 0.8297 0.6321 0.8242
10 weight_balanced balanced 7 13 0.8686 0.8242 0.6181 0.8211
11 weight_balanced balanced 42 12 0.8571 0.8236 0.6245 0.8187
12 weight_balanced balanced 113 15 0.8733 0.8186 0.6144 0.8152
13 weight_balanced balanced 2026 13 0.8400 0.8297 0.6546 0.8256
14 weight_balanced balanced 31415 15 0.8842 0.8136 0.6118 0.8100
In [26]:
def summarise(group):
    return pd.Series({
        "hidden_units": group["hidden_units"].iloc[0],
        "dropout": group["dropout"].iloc[0],
        "vocab": group["vocab"].iloc[0],
        "class_weight": group["class_weight"].iloc[0],
        "params": int(group["params"].iloc[0]),
        "macro_f1_mean": round(group["val_macro_f1"].mean(), 4),
        "macro_f1_std": round(group["val_macro_f1"].std(ddof=1), 4),
        "acc_mean": round(group["val_acc"].mean(), 4),
        "acc_std": round(group["val_acc"].std(ddof=1), 4),
        "weighted_f1_mean": round(group["val_weighted_f1"].mean(), 4),
        "loss_mean": round(group["val_loss"].mean(), 4),
        "loss_std": round(group["val_loss"].std(ddof=1), 4),
        "median_best_epoch": int(np.median(group["best_epoch"])),
    })


stability_summary = (
    stability_df.groupby("name", sort=False)
    .apply(summarise, include_groups=False)
    .reset_index()
)
stability_summary
Out[26]:
name hidden_units dropout vocab class_weight params macro_f1_mean macro_f1_std acc_mean acc_std weighted_f1_mean loss_mean loss_std median_best_epoch
0 weight_none [128, 128] 0.0 5000 none 662574 0.5217 0.0473 0.8130 0.0054 0.7984 0.8690 0.0178 9
1 weight_sqrt_balanced [128, 128] 0.0 5000 sqrt_balanced 662574 0.6219 0.0135 0.8283 0.0022 0.8215 0.8051 0.0033 9
2 weight_balanced [128, 128] 0.0 5000 balanced 662574 0.6247 0.0174 0.8219 0.0061 0.8181 0.8646 0.0169 13
In [27]:
# Select from five-seed means, then retain the two strongest for split analysis.
ranked = stability_summary.sort_values("macro_f1_mean", ascending=False)
lead = ranked.iloc[0]
close = ranked[ranked["macro_f1_mean"] >= lead["macro_f1_mean"] - TIE_THRESHOLD]
close = close.sort_values(["loss_mean", "params"], ascending=[True, True])
final_row = close.iloc[0]
finalists = ranked.head(2).copy()

final_units = parse_units(final_row["hidden_units"])
final_dropout = float(final_row["dropout"])
final_vocab = int(final_row["vocab"])
final_class_weight = str(final_row["class_weight"])
final_epochs = int(final_row["median_best_epoch"])
print(
    f"Selected configuration: units={final_units}, dropout={final_dropout}, "
    f"vocab={final_vocab}, class_weight={final_class_weight}, "
    f"epochs={final_epochs}"
)
Selected configuration: units=[128, 128], dropout=0.0, vocab=5000, class_weight=sqrt_balanced, epochs=9

Interpretation of RQ5. Unweighted training gives mean validation macro F1 0.5217 with standard deviation 0.0473 across five seeds. Square-root balanced weights increase the mean to 0.6219 and reduce the standard deviation to 0.0135. Fully balanced weights give 0.6247 with standard deviation 0.0174. The last two means differ by 0.0028 and fall inside the declared tie threshold. Square-root weighting has lower mean validation loss, 0.8051 compared with 0.8646, and higher mean accuracy, 0.8283 compared with 0.8219, so it wins the tie-break. The improvement over unweighted training is 0.1002 in macro F1 and 0.0153 in accuracy. Weighting improves equal-weight class performance without sacrificing aggregate accuracy on the fixed validation split.

2.7.5 Sensitivity to the validation split¶

Training-seed repetition holds the data partition fixed. The two strongest weighting schemes are now evaluated over five fresh stratified 80:20 splits with the training seed held at 42. Class weights are recalculated from each development partition. The paired difference on each split shows whether the ranking survives changes in partition membership. The test set remains untouched.

In [28]:
SPLIT_SEEDS = SEEDS


def split_feature_bundle(x_all, seed):
    d_idx, v_idx = train_test_split(
        np.arange(len(train_labels)),
        test_size=0.20,
        random_state=seed,
        stratify=train_labels,
    )
    assert set(d_idx).isdisjoint(set(v_idx))
    return (
        x_all[d_idx],
        keras.utils.to_categorical(train_labels[d_idx], NUM_CLASSES),
        x_all[v_idx],
        keras.utils.to_categorical(train_labels[v_idx], NUM_CLASSES),
        train_labels[v_idx],
    )


split_rows = []
for vocab in sorted({int(v) for v in finalists["vocab"]}):
    training_sequences, _ = sequences_for_vocab(vocab)
    x_all_vocab = multi_hot_encode(training_sequences, vocab)
    for split_seed in SPLIT_SEEDS:
        bundle = split_feature_bundle(x_all_vocab, split_seed)
        for _, cfg in finalists[finalists["vocab"] == vocab].iterrows():
            rec, _ = counted_run(
                cfg["name"],
                parse_units(cfg["hidden_units"]),
                float(cfg["dropout"]),
                vocab,
                RANDOM_STATE,
                bundle,
                class_weight_scheme=str(cfg["class_weight"]),
                use_early_stopping=True,
                keep_history=False,
            )
            rec["split_seed"] = split_seed
            split_rows.append(rec)
        del bundle
    del x_all_vocab

split_df = pd.DataFrame(split_rows)
split_wide = split_df.pivot(
    index="split_seed", columns="name", values="val_macro_f1"
).round(4)
split_wide
Out[28]:
name weight_balanced weight_sqrt_balanced
split_seed
7 0.6334 0.6124
42 0.6245 0.6351
113 0.6031 0.6186
2026 0.5840 0.5911
31415 0.6118 0.6040
In [29]:
split_summary = (
    split_df.groupby("name")
    .agg(
        macro_f1_mean=("val_macro_f1", "mean"),
        macro_f1_std=("val_macro_f1", "std"),
        acc_mean=("val_acc", "mean"),
        acc_std=("val_acc", "std"),
        loss_mean=("val_loss", "mean"),
        loss_std=("val_loss", "std"),
    )
    .round(4)
    .reset_index()
)

lead_name = str(final_row["name"])
other_name = [name for name in split_wide.columns if name != lead_name][0]
paired_diff = split_wide[lead_name] - split_wide[other_name]
split_range = float(split_wide[lead_name].max() - split_wide[lead_name].min())
seed_range = float(
    stability_df.loc[
        stability_df["name"] == lead_name, "val_macro_f1"
    ].max()
    - stability_df.loc[
        stability_df["name"] == lead_name, "val_macro_f1"
    ].min()
)

print(
    f"Range for {lead_name}: {split_range:.4f} across splits and "
    f"{seed_range:.4f} across training seeds"
)
print(
    f"Paired difference ({lead_name} minus {other_name}): "
    f"mean {paired_diff.mean():.4f}, standard deviation "
    f"{paired_diff.std():.4f}, favourable on "
    f"{int((paired_diff > 0).sum())} of {len(paired_diff)} splits"
)
split_summary
Range for weight_sqrt_balanced: 0.0440 across splits and 0.0343 across training seeds
Paired difference (weight_sqrt_balanced minus weight_balanced): mean 0.0009, standard deviation 0.0150, favourable on 3 of 5 splits
Out[29]:
name macro_f1_mean macro_f1_std acc_mean acc_std loss_mean loss_std
0 weight_balanced 0.6114 0.0192 0.8128 0.0106 0.9366 0.0474
1 weight_sqrt_balanced 0.6122 0.0164 0.8179 0.0070 0.8618 0.0362

Interpretation. Across five validation splits, square-root weighting gives mean macro F1 0.6122 with standard deviation 0.0164. Fully balanced weighting gives 0.6114 with standard deviation 0.0192. The paired square-root minus balanced difference is 0.0009 with standard deviation 0.0150, and square-root weighting leads on three of the five splits. The schemes cannot be separated on macro F1 at this resolution. Square-root weighting retains the lower validation loss and higher accuracy, so the declared tie-break supports the selected scheme. The comparison also shows that the large improvement over the unweighted mean is not dependent on one training seed.

2.7.6 Learning curve¶

The selected weighted model is trained on nested stratified fractions of the development partition. Each fraction is repeated over five seeds, and class weights are recalculated from the rows present in that subset. The fixed validation set permits direct comparison without accessing the test data.

In [30]:
def nested_fraction_indices(labels_int, fractions, seed):
    order = np.arange(len(labels_int))
    rng = np.random.default_rng(seed)
    per_class = {
        class_id: rng.permutation(order[labels_int == class_id])
        for class_id in range(NUM_CLASSES)
    }
    subsets = {}
    for fraction in fractions:
        chosen = []
        for class_id in range(NUM_CLASSES):
            take = max(1, int(round(len(per_class[class_id]) * fraction)))
            chosen.extend(per_class[class_id][:take])
        subsets[fraction] = np.array(sorted(chosen))
    return subsets


fractions = [0.25, 0.5, 0.75, 1.0]
final_feats = feature_bundle_for_vocab(final_vocab)
x_dev_final, y_dev_final = final_feats[0], final_feats[1]

learning_rows = []
for seed in SEEDS:
    subset_idx = nested_fraction_indices(y_dev_int, fractions, seed)
    for fraction in fractions:
        idx = subset_idx[fraction]
        feats = (
            x_dev_final[idx],
            y_dev_final[idx],
            final_feats[2],
            final_feats[3],
            final_feats[4],
        )
        rec, _ = counted_run(
            f"frac_{fraction}",
            final_units,
            final_dropout,
            final_vocab,
            seed,
            feats,
            class_weight_scheme=final_class_weight,
            use_early_stopping=True,
        )
        rec["fraction"] = fraction
        rec["dev_examples"] = len(idx)
        learning_rows.append(rec)

learning_df = pd.DataFrame(learning_rows)
learning_summary = (
    learning_df.groupby(["fraction", "dev_examples"])
    .agg(
        macro_f1_mean=("val_macro_f1", "mean"),
        macro_f1_std=("val_macro_f1", "std"),
        acc_mean=("val_acc", "mean"),
        acc_std=("val_acc", "std"),
        loss_mean=("val_loss", "mean"),
    )
    .round(4)
    .reset_index()
)
learning_summary["macro_f1_step"] = (
    learning_summary["macro_f1_mean"].diff().round(4)
)
learning_summary["acc_step"] = learning_summary["acc_mean"].diff().round(4)

learning_wide = learning_df.pivot(
    index="seed", columns="fraction", values="val_macro_f1"
).round(4)
print("Validation macro F1 by seed and fraction:")
print(learning_wide.to_string())

final_step = learning_wide[1.0] - learning_wide[0.75]
step_se = final_step.std(ddof=1) / np.sqrt(len(final_step))
print(f"\nFinal paired step: {final_step.round(4).tolist()}")
print(
    f"Mean {final_step.mean():.4f}, standard deviation "
    f"{final_step.std(ddof=1):.4f}, positive for "
    f"{int((final_step > 0).sum())} of {len(final_step)} seeds, "
    f"mean divided by standard error {final_step.mean() / step_se:.2f}"
)
learning_summary
Validation macro F1 by seed and fraction:
fraction    0.25    0.50    0.75    1.00
seed                                    
7         0.4293  0.5245  0.5365  0.6008
42        0.3722  0.5031  0.5711  0.6351
113       0.4171  0.5137  0.5617  0.6201
2026      0.3964  0.5093  0.5891  0.6213
31415     0.3823  0.5191  0.5691  0.6321

Final paired step: [0.0643, 0.064, 0.0584, 0.0322, 0.063]
Mean 0.0564, standard deviation 0.0137, positive for 5 of 5 seeds, mean divided by standard error 9.19
Out[30]:
fraction dev_examples macro_f1_mean macro_f1_std acc_mean acc_std loss_mean macro_f1_step acc_step
0 0.25 1797 0.3995 0.0237 0.7763 0.0060 1.0217 NaN NaN
1 0.50 3597 0.5139 0.0083 0.8027 0.0056 0.9141 0.1144 0.0264
2 0.75 5388 0.5655 0.0191 0.8155 0.0033 0.8495 0.0516 0.0128
3 1.00 7185 0.6219 0.0135 0.8283 0.0022 0.8051 0.0564 0.0128
In [31]:
# Figure 4: learning curve of validation macro F1 against development size. The
# band is plus or minus one standard deviation over the five seeds, so the
# change between neighbouring fractions can be judged against seed variation.
fig, ax = plt.subplots(figsize=(7, 3.8))
sizes = learning_summary["dev_examples"]
mean_f1 = learning_summary["macro_f1_mean"]
sd_f1 = learning_summary["macro_f1_std"]
ax.plot(sizes, mean_f1, marker="o", color=PALETTE[0], label="Mean over five seeds")
ax.fill_between(sizes, mean_f1 - sd_f1, mean_f1 + sd_f1, color=PALETTE[0],
                alpha=0.20, label="Plus or minus one standard deviation")
ax.set_xlabel("Development examples used")
ax.set_ylabel("Validation macro F1")
ax.set_title("Figure 4. Learning curve for the selected configuration")
ax.legend(loc="lower right")
plt.show()
plt.close(fig)
No description has been provided for this image

Interpretation of RQ6. Mean validation macro F1 rises from 0.3995 at one quarter of the development data to 0.5139, 0.5655 and 0.6219 as further data are added. The final paired increase is 0.0564 with standard deviation 0.0137 and is positive for all five seeds. Accuracy also rises from 0.7763 to 0.8283, with smaller increments in the second half of the curve. These results provide consistent evidence that additional labelled data would improve the selected model, especially under the equal-class macro F1 measure. Extrapolation beyond the observed sample sizes remains uncertain.

3. Final model and untouched test evaluation¶

The architecture, class-weighting scheme and epoch count are now fixed. A fresh model is trained once on every supplied training newswire. Test probabilities are generated after this fit and no tuning follows.

In [32]:
# Record the complete final specification before test access.
final_arch = pd.DataFrame({
    "property": [
        "Hidden units",
        "Dropout rate",
        "Vocabulary size",
        "Class weighting",
        "Output layer",
        "Loss",
        "Optimizer",
        "Training epochs",
        "Epoch rule",
    ],
    "value": [
        str(final_units),
        final_dropout,
        final_vocab,
        final_class_weight,
        "Dense(46, softmax)",
        "categorical_crossentropy",
        "rmsprop",
        final_epochs,
        "Median best epoch across five training seeds",
    ],
})
final_arch
Out[32]:
property value
0 Hidden units [128, 128]
1 Dropout rate 0.0
2 Vocabulary size 5000
3 Class weighting sqrt_balanced
4 Output layer Dense(46, softmax)
5 Loss categorical_crossentropy
6 Optimizer rmsprop
7 Training epochs 9
8 Epoch rule Median best epoch across five training seeds
In [33]:
# Train once on all supplied training examples with the selected weights.
final_train_sequences, final_test_sequences = sequences_for_vocab(final_vocab)
x_train_full = multi_hot_encode(final_train_sequences, final_vocab)
x_test_full = multi_hot_encode(final_test_sequences, final_vocab)
y_train_full = keras.utils.to_categorical(train_labels, NUM_CLASSES)
final_weights = make_class_weights(train_labels, final_class_weight)

assert x_train_full.shape[1] == final_vocab
assert np.isfinite(x_train_full).all()

keras.backend.clear_session()
set_all_seeds(RANDOM_STATE)
final_model = build_model(
    final_vocab, final_units, final_dropout, NUM_CLASSES, RANDOM_STATE
)
final_model.fit(
    x_train_full,
    y_train_full,
    epochs=final_epochs,
    batch_size=BATCH_SIZE,
    class_weight=final_weights,
    verbose=0,
)
FIT_COUNTER["n"] += 1
print(f"Final model trained for {final_epochs} epochs on {len(x_train_full)} examples.")
Final model trained for 9 epochs on 8982 examples.
In [34]:
# Single test evaluation. Probability rows are checked to sum to one before use.
test_proba = final_model.predict(x_test_full, batch_size=BATCH_SIZE, verbose=0)
assert np.allclose(test_proba.sum(axis=1), 1.0, atol=1e-4)
test_pred = test_proba.argmax(axis=1)

# Full diagnostic suite on the untouched test split.
suite = full_metric_suite(test_labels, test_pred, test_proba)

# The four required headline measures, retained by name for the abstract.
test_loss = suite["Cross-entropy"]
test_acc = suite["Accuracy"]
test_macro = suite["Macro F1"]
test_weighted = suite["Weighted F1"]
test_bal_acc = suite["Balanced accuracy"]
test_kappa = suite["Cohen's kappa"]
test_mcc = suite["Matthews corrcoef"]
test_top3 = suite["Top-3 accuracy"]
test_top5 = suite["Top-5 accuracy"]
test_ece = suite["Expected calibration error"]
test_auc = [v for k, v in suite.items() if k.startswith("Macro OVR")][0]

test_metrics = pd.DataFrame(
    {"metric": list(suite.keys()),
     "value": [round(v, 4) for v in suite.values()]}
)
test_metrics
Out[34]:
metric value
0 Cross-entropy 0.9068
1 Accuracy 0.8072
2 Balanced accuracy 0.6054
3 Top-3 accuracy 0.9159
4 Top-5 accuracy 0.9403
5 Macro precision 0.7060
6 Macro recall 0.6054
7 Macro F1 0.6280
8 Weighted precision 0.8109
9 Weighted recall 0.8072
10 Weighted F1 0.8034
11 Cohen's kappa 0.7626
12 Matthews corrcoef 0.7629
13 Macro OVR ROC AUC (46 classes) 0.9525
14 Expected calibration error 0.0566
In [35]:
# Percentile bootstrap intervals resample fixed prediction-label pairs.
def bootstrap_intervals(y_true, y_pred, n_resamples=2000, seed=RANDOM_STATE):
    rng = np.random.default_rng(seed)
    n = len(y_true)
    accs = np.empty(n_resamples)
    macros = np.empty(n_resamples)
    bals = np.empty(n_resamples)
    for draw in range(n_resamples):
        sample = rng.integers(0, n, n)
        observed = y_true[sample]
        predicted = y_pred[sample]
        accs[draw] = accuracy_score(observed, predicted)
        macros[draw] = f1_score(
            observed,
            predicted,
            average="macro",
            labels=LABELS,
            zero_division=0,
        )
        bals[draw] = recall_score(
            observed,
            predicted,
            average="macro",
            labels=LABELS,
            zero_division=0,
        )

    def interval(values):
        return (
            round(float(np.percentile(values, 2.5)), 4),
            round(float(np.percentile(values, 97.5)), 4),
        )

    return interval(accs), interval(macros), interval(bals)


acc_ci, macro_ci, bal_ci = bootstrap_intervals(test_labels, test_pred)
ci_table = pd.DataFrame({
    "metric": ["Accuracy", "Macro F1", "Balanced accuracy"],
    "point_estimate": [
        round(test_acc, 4),
        round(test_macro, 4),
        round(test_bal_acc, 4),
    ],
    "ci_lower_95": [acc_ci[0], macro_ci[0], bal_ci[0]],
    "ci_upper_95": [acc_ci[1], macro_ci[1], bal_ci[1]],
})
ci_table
Out[35]:
metric point_estimate ci_lower_95 ci_upper_95
0 Accuracy 0.8072 0.7912 0.8241
1 Macro F1 0.6280 0.5484 0.6506
2 Balanced accuracy 0.6054 0.5390 0.6449
In [36]:
# A stratified bootstrap conditions on the observed support of each class.
def stratified_bootstrap_intervals(
    y_true, y_pred, n_resamples=2000, seed=RANDOM_STATE
):
    rng = np.random.default_rng(seed)
    index_by_class = [np.flatnonzero(y_true == class_id) for class_id in LABELS]
    accs = np.empty(n_resamples)
    macros = np.empty(n_resamples)
    for draw in range(n_resamples):
        sample = np.concatenate([
            rng.choice(indices, len(indices), replace=True)
            for indices in index_by_class
            if len(indices) > 0
        ])
        accs[draw] = accuracy_score(y_true[sample], y_pred[sample])
        macros[draw] = f1_score(
            y_true[sample],
            y_pred[sample],
            average="macro",
            labels=LABELS,
            zero_division=0,
        )

    def interval(values):
        return (
            round(float(np.percentile(values, 2.5)), 4),
            round(float(np.percentile(values, 97.5)), 4),
        )

    return interval(accs), interval(macros)


strat_acc_ci, strat_macro_ci = stratified_bootstrap_intervals(
    test_labels, test_pred
)
interval_comparison = pd.DataFrame({
    "metric": ["Accuracy", "Accuracy", "Macro F1", "Macro F1"],
    "scheme": ["Unstratified", "Class-stratified"] * 2,
    "point_estimate": [round(test_acc, 4)] * 2 + [round(test_macro, 4)] * 2,
    "ci_lower_95": [
        acc_ci[0],
        strat_acc_ci[0],
        macro_ci[0],
        strat_macro_ci[0],
    ],
    "ci_upper_95": [
        acc_ci[1],
        strat_acc_ci[1],
        macro_ci[1],
        strat_macro_ci[1],
    ],
})
interval_comparison
Out[36]:
metric scheme point_estimate ci_lower_95 ci_upper_95
0 Accuracy Unstratified 0.8072 0.7912 0.8241
1 Accuracy Class-stratified 0.8072 0.7921 0.8210
2 Macro F1 Unstratified 0.6280 0.5484 0.6506
3 Macro F1 Class-stratified 0.6280 0.5763 0.6619

Interpretation. The selected model achieves test accuracy 0.8072 with an unstratified 95 percent bootstrap interval of [0.7912, 0.8241]. Macro F1 is 0.6280 with interval [0.5484, 0.6506], weighted F1 is 0.8034 and balanced accuracy is 0.6054. The difference between macro and weighted F1 shows that infrequent topics remain harder than the classes that dominate the corpus. Top-3 accuracy is 0.9159 and expected calibration error is 0.0566.

The unstratified bootstrap resamples the observed mixture of classes. Its macro F1 interval is affected when rare classes disappear from a resample. The class-stratified interval, [0.5763, 0.6619], conditions on the observed support of every class. These intervals answer different sampling questions and neither includes retraining variability.

Chollet's comparable 64-64 Dense example reports test accuracy of approximately 0.797 (Chollet, 2017, Chapter 3). The present accuracy is approximately 0.010 higher. Differences in class weighting, software version and training protocol prevent a direct claim of model superiority. The main experimental contribution is the measured improvement in validation macro F1 produced by class weighting.

4. Interpretation and error analysis¶

The final predictions are examined by class support, frequent confusion pairs and confidence. Integer identifiers are retained because the Keras dataset does not provide authoritative topic names.

In [37]:
# Per-class precision, recall, F1, and support on the test split.
report_dict = classification_report(
    test_labels, test_pred, labels=list(range(NUM_CLASSES)),
    output_dict=True, zero_division=0,
)
per_class = pd.DataFrame(
    [
        {
            "class": c,
            "precision": round(report_dict[str(c)]["precision"], 3),
            "recall": round(report_dict[str(c)]["recall"], 3),
            "f1": round(report_dict[str(c)]["f1-score"], 3),
            "support": int(report_dict[str(c)]["support"]),
        }
        for c in range(NUM_CLASSES)
    ]
)
per_class.sort_values("support", ascending=False).head(10)
Out[37]:
class precision recall f1 support
3 3 0.940 0.924 0.932 813
4 4 0.847 0.878 0.862 474
19 19 0.624 0.737 0.676 133
1 1 0.738 0.752 0.745 105
16 16 0.686 0.818 0.747 99
11 11 0.574 0.795 0.667 83
20 20 0.618 0.486 0.544 70
8 8 0.730 0.711 0.720 38
13 13 0.600 0.568 0.583 37
25 25 0.821 0.742 0.780 31
In [38]:
# Raw rankings are shown with support so small classes are not overinterpreted.
scored = per_class[per_class["support"] > 0].copy()
strongest = scored.sort_values(
    ["f1", "support"], ascending=[False, False]
).head(5)
weakest = scored.sort_values(
    ["f1", "support"], ascending=[True, False]
).head(5)
extremes = pd.concat([
    strongest.assign(group="strongest"),
    weakest.assign(group="weakest"),
])
extremes[["group", "class", "precision", "recall", "f1", "support"]]
Out[38]:
group class precision recall f1 support
45 strongest 45 1.000 1.000 1.000 1
32 strongest 32 1.000 0.900 0.947 10
3 strongest 3 0.940 0.924 0.932 813
10 strongest 10 0.900 0.900 0.900 30
44 strongest 44 1.000 0.800 0.889 5
22 weakest 22 0.000 0.000 0.000 7
5 weakest 5 0.333 0.200 0.250 5
15 weakest 15 0.500 0.222 0.308 9
14 weakest 14 0.250 0.500 0.333 2
12 weakest 12 0.500 0.308 0.381 13
In [39]:
# A support threshold limits the effect of one error on class recall to 0.05.
MIN_SUPPORT = 20
well_supported = per_class[per_class["support"] >= MIN_SUPPORT].copy()
qualified = pd.concat([
    well_supported.sort_values("f1", ascending=False)
    .head(5)
    .assign(group="strongest"),
    well_supported.sort_values("f1", ascending=True)
    .head(5)
    .assign(group="weakest"),
])

scored_classes = per_class[per_class["support"] > 0]
rho_all = scored_classes[["support", "f1"]].corr(
    method="spearman"
).iloc[0, 1]
rho_qualified = well_supported[["support", "f1"]].corr(
    method="spearman"
).iloc[0, 1]

print(
    f"{len(well_supported)} of {NUM_CLASSES} classes reach support "
    f"{MIN_SUPPORT}; they contain {int(well_supported['support'].sum())} "
    f"of {len(test_labels)} test cases."
)
print(
    f"Spearman support-F1 correlation: {rho_all:.3f} for all scored "
    f"classes and {rho_qualified:.3f} for supported classes."
)
qualified[["group", "class", "precision", "recall", "f1", "support"]]
15 of 46 classes reach support 20; they contain 2005 of 2246 test cases.
Spearman support-F1 correlation: 0.264 for all scored classes and 0.291 for supported classes.
Out[39]:
group class precision recall f1 support
3 strongest 3 0.940 0.924 0.932 813
10 strongest 10 0.900 0.900 0.900 30
4 strongest 4 0.847 0.878 0.862 474
25 strongest 25 0.821 0.742 0.780 31
9 strongest 9 0.818 0.720 0.766 25
20 weakest 20 0.618 0.486 0.544 70
13 weakest 13 0.600 0.568 0.583 37
2 weakest 2 0.571 0.800 0.667 20
11 weakest 11 0.574 0.795 0.667 83
18 weakest 18 0.750 0.600 0.667 20
In [40]:
# Most frequent off-diagonal confusion pairs across all classes.
cm = confusion_matrix(test_labels, test_pred, labels=list(range(NUM_CLASSES)))
pairs = []
for i in range(NUM_CLASSES):
    for j in range(NUM_CLASSES):
        if i != j and cm[i, j] > 0:
            pairs.append({"true_class": i, "predicted_class": j, "count": int(cm[i, j])})
confusion_pairs = (pd.DataFrame(pairs)
                   .sort_values("count", ascending=False)
                   .head(10)
                   .reset_index(drop=True))
confusion_pairs
Out[40]:
true_class predicted_class count
0 3 4 35
1 4 3 20
2 20 19 15
3 19 11 10
4 13 16 9
5 4 16 8
6 4 19 8
7 19 20 7
8 20 3 6
9 16 3 6
In [41]:
# Figure 5: row-normalised confusion matrix for the most supported classes,
# selected by a documented rule: the twelve classes with the largest test
# support. The matrix is computed over all 46 classes first, rows are then
# normalised by the complete row total, and a final column collects predictions
# that fall outside the twelve. Restricting the matrix before normalising would
# drop those predictions and inflate the diagonal, so every row here sums to one
# and no error is hidden by the restriction.
cm_full = confusion_matrix(test_labels, test_pred, labels=LABELS)
top_support = sorted(per_class.sort_values("support", ascending=False)
                              .head(12)["class"].tolist())

row_totals = cm_full[top_support].sum(axis=1, keepdims=True).clip(min=1)
block = cm_full[np.ix_(top_support, top_support)]
outside = cm_full[top_support].sum(axis=1, keepdims=True) - block.sum(axis=1, keepdims=True)
cm_norm = np.hstack([block, outside]) / row_totals
assert np.allclose(cm_norm.sum(axis=1), 1.0)

fig, ax = plt.subplots(figsize=(8.4, 6))
sns.heatmap(cm_norm, annot=True, fmt=".2f", cmap="Blues", cbar=True,
            xticklabels=[str(c) for c in top_support] + ["other"],
            yticklabels=top_support, ax=ax, annot_kws={"size": 8})
ax.set_xlabel("Predicted class"); ax.set_ylabel("True class")
ax.set_title("Figure 5. Row-normalised confusion matrix for the twelve most supported classes")
plt.show()
plt.close(fig)

# Share of each row that leaves the displayed block, which the restricted
# matrix would otherwise conceal.
outside_share = pd.DataFrame({
    "class": top_support,
    "support": cm_full[top_support].sum(axis=1),
    "outside_share": np.round((outside / row_totals).ravel(), 3),
})
outside_share
No description has been provided for this image
Out[41]:
class support outside_share
0 1 105 0.057
1 3 813 0.007
2 4 474 0.017
3 8 38 0.079
4 10 30 0.067
5 11 83 0.060
6 13 37 0.027
7 16 99 0.010
8 19 133 0.030
9 20 70 0.029
10 21 27 0.111
11 25 31 0.032
In [42]:
# Figure 6: predicted confidence for correct and incorrect test predictions.
confidence = test_proba.max(axis=1)
correct_mask = test_pred == test_labels
conf_df = pd.DataFrame({
    "confidence": confidence,
    "outcome": np.where(correct_mask, "correct", "incorrect"),
})

fig, ax = plt.subplots(figsize=(7.5, 3.6))
sns.boxplot(data=conf_df, x="outcome", y="confidence",
            hue="outcome", palette=[PALETTE[2], PALETTE[3]], legend=False, ax=ax)
ax.set_xlabel("Prediction outcome"); ax.set_ylabel("Predicted class probability")
ax.set_title("Figure 6. Prediction confidence for correct and incorrect predictions")
plt.show()
plt.close(fig)

conf_summary = conf_df.groupby("outcome")["confidence"].agg(["mean", "median"]).round(3)
conf_summary
No description has been provided for this image
Out[42]:
mean median
outcome
correct 0.852 0.918
incorrect 0.630 0.647
In [43]:
# Reliability is plotted at the observed mean confidence in each bin.
ece_value, reliability = expected_calibration_error(
    test_proba.max(axis=1),
    (test_pred == test_labels).astype(float),
)
plotted = reliability.dropna(subset=["accuracy"])

fig, ax = plt.subplots(figsize=(5.6, 4.6))
ax.plot(
    [0, 1],
    [0, 1],
    color="grey",
    linestyle="--",
    linewidth=1,
    label="Perfect calibration",
)
ax.plot(
    plotted["confidence"],
    plotted["accuracy"],
    marker="o",
    color=PALETTE[0],
    label="Observed accuracy",
)
ax.bar(
    plotted["confidence"],
    plotted["count"] / plotted["count"].sum(),
    width=0.07,
    color=PALETTE[0],
    alpha=0.25,
    label="Share of predictions",
)
ax.set_xlabel("Mean predicted confidence")
ax.set_ylabel("Empirical accuracy")
ax.set_title(
    f"Figure 7. Reliability diagram (expected calibration error {ece_value:.3f})"
)
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.legend(loc="upper left")
plt.show()
plt.close(fig)
reliability.round(3)
No description has been provided for this image
Out[43]:
bin_low bin_high count accuracy confidence
0 0.0 0.1 4 0.750 0.085
1 0.1 0.2 38 0.289 0.158
2 0.2 0.3 56 0.429 0.255
3 0.3 0.4 77 0.416 0.345
4 0.4 0.5 107 0.533 0.455
5 0.5 0.6 110 0.564 0.550
6 0.6 0.7 134 0.701 0.656
7 0.7 0.8 189 0.825 0.750
8 0.8 0.9 418 0.878 0.858
9 0.9 1.0 1113 0.905 0.964

Interpretation. Raw F1 rankings are unreliable for the smallest topics. Class 45 scores 1.000 on one case and class 44 scores 0.889 on five cases. Among the 15 classes with at least 20 test examples, class 3 has the highest F1 at 0.932 on 813 cases. Class 20 has the lowest at 0.544 on 70 cases. Support and F1 have Spearman correlation 0.264 across all scored classes and 0.291 within the supported subset, so support explains only part of the variation.

The largest numeric confusion is class 3 predicted as class 4 in 35 cases, followed by class 4 predicted as class 3 in 20 cases. No semantic interpretation is attached because authoritative topic names are unavailable. Correct predictions have median confidence 0.918, compared with 0.647 for errors. Expected calibration error is 0.0566. In the highest confidence interval, mean confidence is 0.964 and empirical accuracy is 0.905, which shows overconfidence among the most certain predictions.

4.1 Representation collision diagnostic¶

Multi-hot vectors lose order and frequency. Exact duplicates with different labels are indistinguishable to any classifier using this representation. The next calculation measures the empirical minimum error forced by these exact collisions. It does not measure ambiguity between similar, non-identical vectors.

In [44]:
# Size of the error that the representation forces. Newswires sharing a
# multi-hot vector are indistinguishable to any model built on this input, so
# the best available rule is to predict the majority label within each group of
# identical vectors. What that rule still gets wrong is the floor the
# representation imposes, and it can be compared with the observed error rate.
def representation_error_floor(sequences, labels, dimension):
    groups = defaultdict(list)
    for sequence, label in zip(sequences, labels):
        groups[frozenset(t for t in sequence if 0 <= t < dimension)].append(int(label))
    forced = sum(len(v) - Counter(v).most_common(1)[0][1] for v in groups.values())
    return forced, len(groups)


floor_rows = []
for scope, seqs, labs in [("Test split", test_data, test_labels),
                          ("Training split", train_data, train_labels)]:
    forced, distinct = representation_error_floor(seqs, labs, final_vocab)
    floor_rows.append({"scope": scope, "examples": len(labs),
                       "distinct_vectors": distinct, "forced_errors": forced,
                       "error_floor": round(forced / len(labs), 4)})

floor_table = pd.DataFrame(floor_rows)
test_error_rate = 1 - test_acc
test_floor = float(floor_table.loc[floor_table["scope"] == "Test split",
                                   "error_floor"].iloc[0])
print(f"Observed test error rate {test_error_rate:.4f}; the representation "
      f"forces {test_floor:.4f} of it, a share of {test_floor / test_error_rate:.1%}")
floor_table
Observed test error rate 0.1928; the representation forces 0.0085 of it, a share of 4.4%
Out[44]:
scope examples distinct_vectors forced_errors error_floor
0 Test split 2246 2213 19 0.0085
1 Training split 8982 8444 283 0.0315

Interpretation. Nineteen test newswires belong to an exact multi-hot collision whose labels disagree. This gives an empirical forced-error rate of 0.0085, compared with the model's observed error rate of 0.1928. Exact collisions account for 4.4 percent of the observed error. This calculation rules out exact vector duplication as the principal explanation for model error. It does not measure information lost through word order, term frequency or similarity between non-identical vectors.

5. Limitations and reproducibility¶

  • Multi-hot encoding discards word order and frequency. The collision diagnostic measures only identical vectors and does not establish a performance ceiling.
  • Rare classes yield unstable per-class metrics. Macro F1 remains more variable than accuracy even after class weighting.
  • Capacity, Dropout and vocabulary are screened sequentially, so interactions between them are not estimated.
  • Five seeds and five splits provide useful sensitivity checks but remain small samples of training and partition variability.
  • The ordinary bootstrap resamples the empirical class mixture. The class-stratified version conditions on the observed class counts. Neither interval includes model retraining or model-selection uncertainty (Efron and Tibshirani, 1993).
  • Reuters is historical, so performance may not transfer to current news language.
  • Results apply to Dense networks and the tested representation. They do not define an upper bound for Reuters classification.

Python and library versions, hardware, seeds and deterministic settings are recorded above. A clean-kernel execution is required before the HTML export is submitted.

6. Conclusion¶

The study follows the seven-stage deep learning workflow and keeps the supplied test partition outside model development. A small Dense model clearly exceeds chance and majority-class baselines. The high-capacity probe exposes overfitting after epoch 7. The controlled screens select two 128-unit hidden layers, no Dropout and a 5,000-word vocabulary. Vocabulary and capacity results come from one seed and split and remain screening decisions.

Class weighting provides the most substantial result. Across five seeds, square-root balanced weighting raises mean validation macro F1 from 0.5217 to 0.6219 and mean accuracy from 0.8130 to 0.8283. Its macro F1 is practically tied with full balancing, and its lower validation loss resolves the selection. The repeated-split comparison confirms that the two weighting schemes remain close. The final model uses square-root weights and nine epochs. It reaches test accuracy 0.8072, macro F1 0.6280 and weighted F1 0.8034.

The learning curve rises through the full development partition in all five paired runs, supporting the collection of additional labelled newswires. Future work within the permitted scope should allocate more examples to low-support topics and repeat the capacity and vocabulary screens across several seeds. The result is specific to the Reuters corpus, its multi-hot representation and the permitted Dense model family.

References¶

Abadi, M. et al. (2016) 'TensorFlow: a system for large-scale machine learning', in Proceedings of the 12th USENIX Symposium on Operating Systems Design and Implementation. Savannah, GA: USENIX Association, pp. 265-283.

Boughorbel, S., Jarray, F. and El-Anbari, M. (2017) 'Optimal classifier for imbalanced data using Matthews correlation coefficient metric', PLoS ONE, 12(6), e0177678.

Chollet, F. (2017) Deep Learning with Python. 1st edn. Shelter Island, NY: Manning Publications.

Efron, B. and Tibshirani, R.J. (1993) An Introduction to the Bootstrap. New York: Chapman and Hall.

Fawcett, T. (2006) 'An introduction to ROC analysis', Pattern Recognition Letters, 27(8), pp. 861-874.

Guo, C., Pleiss, G., Sun, Y. and Weinberger, K.Q. (2017) 'On calibration of modern neural networks', in Proceedings of the 34th International Conference on Machine Learning, 70, pp. 1321-1330.

He, H. and Garcia, E.A. (2009) 'Learning from imbalanced data', IEEE Transactions on Knowledge and Data Engineering, 21(9), pp. 1263-1284.

Hinton, G., Srivastava, N. and Swersky, K. (2012) Neural networks for machine learning, lecture 6a: overview of mini-batch gradient descent. University of Toronto.

Keras (2024) Reuters newswire classification dataset. Available at: https://keras.io/api/datasets/reuters/ (Accessed: 25 August 2026).

Pedregosa, F. et al. (2011) 'Scikit-learn: machine learning in Python', Journal of Machine Learning Research, 12, pp. 2825-2830.

Srivastava, N. et al. (2014) 'Dropout: a simple way to prevent neural networks from overfitting', Journal of Machine Learning Research, 15, pp. 1929-1958.

TensorFlow (2024) tf.keras.layers.Dense. Available at: https://www.tensorflow.org/api_docs/python/tf/keras/layers/Dense (Accessed: 25 August 2026).

Appendix A. Code provenance and use of generative AI¶

The vectorisation idea and reference Dense architecture are adapted from Chollet (2017). Standard TensorFlow, Keras and scikit-learn APIs provide model fitting and evaluation measures. Generative AI assisted with project planning, code development, notebook revision and prose editing. The author is responsible for checking the implementation, executing the experiments, verifying the reported values and complying with the institution's assessment rules.

The table below distinguishes direct adaptation, standard library use and AI-assisted analytical code. It does not label AI-produced material as wholly original.

In [45]:
provenance = pd.DataFrame({
    "element": [
        "Multi-hot vectorisation",
        "64-64 Reuters reference architecture",
        "Universal workflow structure",
        "Dense model assembly and fitting",
        "Metrics, splits and tabulation",
        "Experiment runner and controlled phases",
        "Bootstrap, calibration and collision diagnostics",
        "Report planning and prose revision",
    ],
    "provenance": [
        "Adapted from Chollet (2017), Chapter 3",
        "Adapted from Chollet (2017), Chapter 3",
        "Based on Chollet (2017), Section 4.5",
        "Standard Keras API use with AI-assisted development",
        "Standard scikit-learn and pandas API use",
        "AI-assisted development, reviewed and executed by the author",
        "AI-assisted development using NumPy and scikit-learn",
        "Generative AI assistance with author review",
    ],
    "location": [
        "Section 2.4",
        "Section 2.7.1",
        "Section 2",
        "Sections 2.5 to 3",
        "Sections 2.2 to 4",
        "Sections 2.7 to 3",
        "Sections 3 and 4",
        "Throughout",
    ],
})
provenance
Out[45]:
element provenance location
0 Multi-hot vectorisation Adapted from Chollet (2017), Chapter 3 Section 2.4
1 64-64 Reuters reference architecture Adapted from Chollet (2017), Chapter 3 Section 2.7.1
2 Universal workflow structure Based on Chollet (2017), Section 4.5 Section 2
3 Dense model assembly and fitting Standard Keras API use with AI-assisted development Sections 2.5 to 3
4 Metrics, splits and tabulation Standard scikit-learn and pandas API use Sections 2.2 to 4
5 Experiment runner and controlled phases AI-assisted development, reviewed and executed by the author Sections 2.7 to 3
6 Bootstrap, calibration and collision diagnostics AI-assisted development using NumPy and scikit-learn Sections 3 and 4
7 Report planning and prose revision Generative AI assistance with author review Throughout