============================================
; Title:       Week 10 — Deep Learning Sentiment Analysis with Universal Sentence Encoder
; Author:      Hali Sarah Parsons
; Date:        20 May 2026
; Description: Builds a deep learning sentiment analysis model using the Universal
;              Sentence Encoder (USE) from TensorFlow Hub as the text encoding layer.
;              Applied to the hotel reviews dataset (happy / not happy labels).
;              Includes three-way train/validation/test split, Keras dense network,
;              training history plots, and full evaluation metrics.
============================================

Week 10 — Deep Learning Sentiment Analysis: Universal Sentence Encoder¶

BU 360 | Hali Sarah Parsons | May 2026


Overview¶

This notebook builds a deep learning sentiment classifier using the Universal Sentence Encoder (USE) from TensorFlow Hub, applied to the same hotel reviews dataset used in Week 9. The target label is Is_Response — happy or not happy — label encoded as 0 and 1.

The architecture follows the pattern Sarkar (2019) establishes in Chapter 10: USE encodes each review into a fixed 512-dimensional embedding vector, then a dense Keras network classifies those vectors. The key difference from Week 9's TF-IDF + Logistic Regression approach is where the feature representation comes from. TF-IDF counted words. USE encodes meaning — two sentences using completely different words can land close together in the 512-dimensional space if they express the same idea.

TF 2.0 Compatibility¶

As the assignment notes, TF 2.0 introduced breaking changes. This notebook uses tensorflow.compat.v1 conventions where needed and sets TF_CPP_MIN_LOG_LEVEL=3 to suppress the verbose hardware warnings that TF 2.x produces by default.

TOTAL_STEPS and STEP_SIZE¶

Following the assignment guidance: TOTAL_STEPS = 100, STEP_SIZE = 10, giving 10 training epochs. The assignment explicitly notes that higher values (the author's 1500/100) would take many hours on a standard machine. The model still produces meaningful results at these settings — accuracy and loss trajectories are clearly visible across 10 epochs.


Data Split¶

The same three-way split from Week 9 applies here: 70% train, 15% validation, 15% test, stratified by class. USE receives the raw, uncleaned text — punctuation, capitalization, and sentence structure are all part of what USE was trained to encode. Cleaning would strip signals the encoder already knows how to use.


Step 1: Imports¶

In [1]:
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'       # suppress TF hardware warnings
os.environ['TF_ENABLE_ONEDNN_OPTS'] = '0'       # suppress oneDNN floating-point notes

import tensorflow as tf
import tensorflow_hub as hub
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import (
    classification_report, accuracy_score,
    confusion_matrix, ConfusionMatrixDisplay
)

print(f"TensorFlow version: {tf.__version__}")
print(f"TensorFlow Hub version: {hub.__version__}")
print("All imports loaded.")
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1779299471.032155     707 cudart_stub.cc:31] Could not find cuda drivers on your machine, GPU will not be used.
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1779299472.431723     707 cudart_stub.cc:31] Could not find cuda drivers on your machine, GPU will not be used.
TensorFlow version: 2.21.0
TensorFlow Hub version: 0.16.1
All imports loaded.

Step 2: Load Data and Label Encode¶

In [2]:
df = pd.read_csv('hotel_reviews.csv')

print(f"Shape: {df.shape}")
print(f"\nClass distribution:")
print(df['Is_Response'].value_counts())

# Label encode — happy=0, not happy=1
le = LabelEncoder()
df['label'] = le.fit_transform(df['Is_Response'])
print(f"\nLabel encoding: {dict(zip(le.classes_, le.transform(le.classes_)))}")
df.head(3)
Shape: (1000, 3)

Class distribution:
Is_Response
happy        543
not happy    457
Name: count, dtype: int64

Label encoding: {'happy': np.int64(0), 'not happy': np.int64(1)}
Out[2]:
User_ID Description Is_Response label
0 USER_0001 The hotel was absolutely wonderful. Staff were... happy 0
1 USER_0002 Waited over an hour for room service and the f... not happy 1
2 USER_0003 Great wifi and comfortable work desk made this... happy 0

Step 3: Three-Way Train / Validation / Test Split¶

USE works on raw text directly — no tokenization, no vocabulary, no cleaning step required. The split happens on the original Description column. The encoder handles the rest.

In [3]:
X = df['Description'].astype(str)
y = df['label']

# 70% train, 30% temp
X_train, X_temp, y_train, y_temp = train_test_split(
    X, y, test_size=0.30, random_state=42, stratify=y
)
# 15% val, 15% test from the 30% temp
X_val, X_test, y_val, y_test = train_test_split(
    X_temp, y_temp, test_size=0.50, random_state=42, stratify=y_temp
)

print(f"Train:      {len(X_train):>4} rows ({len(X_train)/len(df)*100:.0f}%)")
print(f"Validation: {len(X_val):>4} rows ({len(X_val)/len(df)*100:.0f}%)")
print(f"Test:       {len(X_test):>4} rows ({len(X_test)/len(df)*100:.0f}%)")
Train:       700 rows (70%)
Validation:  150 rows (15%)
Test:        150 rows (15%)

Step 4: Load the Universal Sentence Encoder¶

USE version 4 is loaded from TensorFlow Hub. The first load downloads the model (~1GB) and caches it locally — subsequent runs are faster. The encoder takes any string input and returns a 512-dimensional vector regardless of sentence length.

This is the transfer learning step. USE was pre-trained on a large corpus covering web pages, Wikipedia, forums, and question-answer pairs. It already knows that "sleep deprivation" and "poor sleep" are semantically similar before this notebook ever runs a single training step. The dense layers on top learn only how to map those 512-dimensional representations onto the hotel sentiment task — which is a much smaller learning problem than starting from scratch.

Encoding is batched in groups of 64 to manage memory. The np.vectorize approach the author uses in the text is skipped here per the assignment note — direct batching is more reliable and avoids the shape issues np.vectorize introduces with tensor outputs.

In [4]:
USE_URL = "https://tfhub.dev/google/universal-sentence-encoder/4"

print("Loading Universal Sentence Encoder from TensorFlow Hub...")
print("(First run downloads ~1GB and caches locally — subsequent runs are faster)")
embed = hub.load(USE_URL)
print("USE loaded successfully.")

# Quick sanity check
test_vecs = embed(["The hotel was great.", "Terrible experience."])
print(f"\nEmbedding shape per sentence: {test_vecs.shape}")  # should be (2, 512)
Loading Universal Sentence Encoder from TensorFlow Hub...
(First run downloads ~1GB and caches locally — subsequent runs are faster)
USE loaded successfully.
Embedding shape per sentence: (2, 512)
In [5]:
import time

def encode_texts(texts, batch_size=64):
    """
    Encodes a list of strings into 512-dim USE vectors, processed in batches.
    Batching avoids memory issues on large inputs.
    Returns a single numpy array of shape (n_samples, 512).
    """
    embeddings = []
    texts_list = list(texts)
    for i in range(0, len(texts_list), batch_size):
        batch = texts_list[i:i + batch_size]
        embeddings.append(embed(batch).numpy())
    return np.vstack(embeddings)

print("Encoding train, validation, and test sets...")
t0 = time.time()

X_train_emb = encode_texts(X_train)
X_val_emb   = encode_texts(X_val)
X_test_emb  = encode_texts(X_test)

elapsed = time.time() - t0
print(f"Encoding complete in {elapsed:.1f}s")
print(f"Train embedding shape:  {X_train_emb.shape}")
print(f"Val embedding shape:    {X_val_emb.shape}")
print(f"Test embedding shape:   {X_test_emb.shape}")
Encoding train, validation, and test sets...
Encoding complete in 0.1s
Train embedding shape:  (700, 512)
Val embedding shape:    (150, 512)
Test embedding shape:   (150, 512)

Step 5: Build the Keras Classification Model¶

The network sits on top of the USE embeddings. The input is always 512-dimensional — one number per USE dimension. Two dense hidden layers follow, each with Dropout applied.

Dropout randomly disables a fraction of neurons during each training step. It forces the remaining neurons to learn redundant representations — which prevents the model from memorizing the training data and improves generalization. The output layer uses a sigmoid activation, which maps the final value to a probability between 0 and 1: above 0.5 predicts not happy, below predicts happy.

Binary crossentropy is the loss function for binary classification — it measures how far the predicted probability is from the true label. Adam handles the gradient-based weight updates, adapting the learning rate automatically per parameter.

In [6]:
tf.random.set_seed(42)

model = tf.keras.Sequential([
    # Input: 512-dim USE embedding
    tf.keras.layers.InputLayer(shape=(512,)),

    # Hidden layer 1: 256 units, ReLU activation
    # ReLU: f(x) = max(0, x) — introduces non-linearity so the model can learn
    # patterns that a simple linear combination of inputs can't capture
    tf.keras.layers.Dense(256, activation='relu'),
    tf.keras.layers.Dropout(0.3),   # drop 30% of neurons per step during training

    # Hidden layer 2: 128 units, ReLU
    tf.keras.layers.Dense(128, activation='relu'),
    tf.keras.layers.Dropout(0.3),

    # Output: single sigmoid unit — probability of 'not happy' (label=1)
    tf.keras.layers.Dense(1, activation='sigmoid')
])

model.compile(
    optimizer=tf.keras.optimizers.Adam(learning_rate=0.001),
    loss='binary_crossentropy',
    metrics=['accuracy']
)

model.summary()
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ dense (Dense)                   │ (None, 256)            │       131,328 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout (Dropout)               │ (None, 256)            │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense)                 │ (None, 128)            │        32,896 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout_1 (Dropout)             │ (None, 128)            │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_2 (Dense)                 │ (None, 1)              │           129 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 164,353 (642.00 KB)
 Trainable params: 164,353 (642.00 KB)
 Non-trainable params: 0 (0.00 B)

Step 6: Train the Model¶

Following the assignment parameters: TOTAL_STEPS = 100, STEP_SIZE = 10. Each epoch covers the full training set once, processing reviews in mini-batches of 32. The validation set is checked at the end of every epoch — this is how the training curves in the next section are produced.

Each epoch is one complete pass through the training data. Each pass updates the weights via backpropagation — the loss computed at the output travels backward through the network, adjusting each weight slightly in the direction that would have lowered it. Ten passes give the optimizer ten chances to refine those adjustments and move the loss toward its minimum.

In [7]:
TOTAL_STEPS = 100
STEP_SIZE   = 10
BATCH_SIZE  = 32
EPOCHS      = TOTAL_STEPS // STEP_SIZE   # = 10

print(f"TOTAL_STEPS={TOTAL_STEPS}, STEP_SIZE={STEP_SIZE}")
print(f"Epochs: {EPOCHS}, Batch size: {BATCH_SIZE}")
print("\nTraining...")

t0 = time.time()
history = model.fit(
    X_train_emb, y_train.values,
    validation_data=(X_val_emb, y_val.values),
    epochs=EPOCHS,
    batch_size=BATCH_SIZE,
    verbose=1
)

print(f"\nTraining complete in {time.time()-t0:.1f} seconds.")
TOTAL_STEPS=100, STEP_SIZE=10
Epochs: 10, Batch size: 32

Training...
Epoch 1/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 17s 828ms/step - accuracy: 0.4688 - loss: 0.6956

22/22 ━━━━━━━━━━━━━━━━━━━━ 1s 10ms/step - accuracy: 0.9286 - loss: 0.4400 - val_accuracy: 1.0000 - val_loss: 0.1033
Epoch 2/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 1s 94ms/step - accuracy: 1.0000 - loss: 0.1154

22/22 ━━━━━━━━━━━━━━━━━━━━ 0s 5ms/step - accuracy: 1.0000 - loss: 0.0330 - val_accuracy: 1.0000 - val_loss: 0.0021
Epoch 3/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step - accuracy: 1.0000 - loss: 0.0040

22/22 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 1.0000 - loss: 0.0018 - val_accuracy: 1.0000 - val_loss: 5.3678e-04
Epoch 4/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step - accuracy: 1.0000 - loss: 0.0012

22/22 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 1.0000 - loss: 7.6573e-04 - val_accuracy: 1.0000 - val_loss: 3.4344e-04
Epoch 5/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 1s 50ms/step - accuracy: 1.0000 - loss: 5.3750e-04

22/22 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 1.0000 - loss: 5.6561e-04 - val_accuracy: 1.0000 - val_loss: 2.5640e-04
Epoch 6/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 1s 48ms/step - accuracy: 1.0000 - loss: 5.1697e-04

22/22 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 1.0000 - loss: 4.4565e-04 - val_accuracy: 1.0000 - val_loss: 1.9989e-04
Epoch 7/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 1s 48ms/step - accuracy: 1.0000 - loss: 5.9850e-04

22/22 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 1.0000 - loss: 3.4418e-04 - val_accuracy: 1.0000 - val_loss: 1.6252e-04
Epoch 8/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 1s 50ms/step - accuracy: 1.0000 - loss: 4.4619e-04

22/22 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 1.0000 - loss: 2.4280e-04 - val_accuracy: 1.0000 - val_loss: 1.3493e-04
Epoch 9/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 1s 50ms/step - accuracy: 1.0000 - loss: 2.6791e-04

22/22 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 1.0000 - loss: 2.7199e-04 - val_accuracy: 1.0000 - val_loss: 1.1092e-04
Epoch 10/10
 1/22 ━━━━━━━━━━━━━━━━━━━━ 1s 50ms/step - accuracy: 1.0000 - loss: 2.5875e-04

22/22 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 1.0000 - loss: 1.7937e-04 - val_accuracy: 1.0000 - val_loss: 9.2884e-05
Training complete in 2.4 seconds.

Step 7: Training History — Loss and Accuracy Curves¶

The training history shows how loss and accuracy evolved across all 10 epochs. The gap between train and validation curves reveals how the model generalizes. A validation loss that tracks below the training loss early on — which happens here — is a sign that the USE embeddings are doing a lot of the heavy lifting before the dense layers even learn much. The 512-dimensional representations are already semantically organized, so the classifier converges quickly.

In [8]:
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# Loss curves
axes[0].plot(history.history['loss'],     label='Train Loss',      color='#457B9D', linewidth=2)
axes[0].plot(history.history['val_loss'], label='Validation Loss', color='#E63946', linewidth=2, linestyle='--')
axes[0].set_title('Loss per Epoch')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Binary Crossentropy Loss')
axes[0].legend()
axes[0].set_xticks(range(EPOCHS))
axes[0].set_xticklabels(range(1, EPOCHS+1))

# Accuracy curves
axes[1].plot(history.history['accuracy'],     label='Train Accuracy',      color='#2A9D8F', linewidth=2)
axes[1].plot(history.history['val_accuracy'], label='Validation Accuracy', color='#E9C46A', linewidth=2, linestyle='--')
axes[1].set_title('Accuracy per Epoch')
axes[1].set_xlabel('Epoch')
axes[1].set_ylabel('Accuracy')
axes[1].set_ylim([0.5, 1.05])
axes[1].legend()
axes[1].set_xticks(range(EPOCHS))
axes[1].set_xticklabels(range(1, EPOCHS+1))

plt.suptitle('USE Deep Learning Model — Training History', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.show()

# Epoch-by-epoch table
print(f"\n{'Epoch':>6} {'Train Loss':>12} {'Val Loss':>10} {'Train Acc':>10} {'Val Acc':>10}")
print("-" * 55)
for i in range(EPOCHS):
    print(f"{i+1:>6} {history.history['loss'][i]:>12.4f} {history.history['val_loss'][i]:>10.4f} "
          f"{history.history['accuracy'][i]:>10.4f} {history.history['val_accuracy'][i]:>10.4f}")
No description has been provided for this image
 Epoch   Train Loss   Val Loss  Train Acc    Val Acc
-------------------------------------------------------
     1       0.4400     0.1033     0.9286     1.0000
     2       0.0330     0.0021     1.0000     1.0000
     3       0.0018     0.0005     1.0000     1.0000
     4       0.0008     0.0003     1.0000     1.0000
     5       0.0006     0.0003     1.0000     1.0000
     6       0.0004     0.0002     1.0000     1.0000
     7       0.0003     0.0002     1.0000     1.0000
     8       0.0002     0.0001     1.0000     1.0000
     9       0.0003     0.0001     1.0000     1.0000
    10       0.0002     0.0001     1.0000     1.0000

Reading the Training Curves¶

Epoch 1 is the most informative. Train loss starts high (~0.44) while validation loss is already low (~0.10). That gap reflects USE doing the semantic work before the dense layers have learned anything — the 512-dimensional embeddings already separate happy and unhappy reviews meaningfully, and the classifier picks that up immediately on the validation set.

By Epoch 2, both curves converge near zero and stay there. The model has found the classification boundary and the remaining epochs are refinements — each backpropagation pass nudges the weights slightly, but the direction barely changes because the gradient is already small.

On a real-world corpus — PubMed abstracts, Reddit threads, qualitative survey responses — this curve would look very different. The language would be more varied, the sentiment signals more ambiguous, and the loss would take many more epochs to descend. The hotel review dataset's directness makes the learning problem easier than the evaluation numbers suggest.


Step 8: Model Evaluation¶

In [9]:
# Validation set evaluation
val_loss, val_acc = model.evaluate(X_val_emb, y_val.values, verbose=0)
print(f"Validation Loss:     {val_loss:.6f}")
print(f"Validation Accuracy: {val_acc:.4f}")

print()

# Test set evaluation — final holdout
test_loss, test_acc = model.evaluate(X_test_emb, y_test.values, verbose=0)
print(f"Test Loss:           {test_loss:.6f}")
print(f"Test Accuracy:       {test_acc:.4f}")
Validation Loss:     0.000093
Validation Accuracy: 1.0000

Test Loss:           0.000084
Test Accuracy:       1.0000
In [10]:
# Generate predictions from sigmoid probabilities
# Threshold at 0.5: above = not happy (1), below = happy (0)
y_pred_prob = model.predict(X_test_emb, verbose=0)
y_pred      = (y_pred_prob > 0.5).astype(int).flatten()

print("Classification Report — Test Set")
print("=" * 55)
print(classification_report(y_test, y_pred, target_names=le.classes_))

# Sample predicted probabilities
print("\nSample Predicted Probabilities (first 8 test reviews):")
print(f"{'Review (truncated)':<55} {'P(not happy)':>13} {'Predicted':>10} {'Actual':>10}")
print("-" * 95)
for text, prob, pred, true in zip(
    list(X_test)[:8],
    y_pred_prob.flatten()[:8],
    y_pred[:8],
    y_test.values[:8]
):
    print(f"{str(text)[:54]:<55} {prob:>13.4f} {le.classes_[pred]:>10} {le.classes_[true]:>10}")
Classification Report — Test Set
=======================================================
              precision    recall  f1-score   support

       happy       1.00      1.00      1.00        82
   not happy       1.00      1.00      1.00        68

    accuracy                           1.00       150
   macro avg       1.00      1.00      1.00       150
weighted avg       1.00      1.00      1.00       150

Sample Predicted Probabilities (first 8 test reviews):
Review (truncated)                                       P(not happy)  Predicted     Actual
-----------------------------------------------------------------------------------------------
Loved every minute of our stay. The spa facilities wer         0.0004      happy      happy
Breakfast was substandard and the coffee was undrinkab         0.9999  not happy  not happy
Overpriced for what you get. Much better options avail         0.9999  not happy  not happy
Our room had not been properly cleaned when we arrived         1.0000  not happy  not happy
Front desk staff were unhelpful and dismissive when we         1.0000  not happy  not happy
No hot water in the morning despite multiple complaint         1.0000  not happy  not happy
The hotel did not match the photos online. Rooms were          0.9999  not happy  not happy
Bed was uncomfortable and the pillows were flat. Worst         1.0000  not happy  not happy
In [11]:
# Confusion matrix
cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=le.classes_)
fig, ax = plt.subplots(figsize=(5, 4))
disp.plot(ax=ax, colorbar=False, cmap='Blues')
ax.set_title('USE Deep Learning Model — Test Set Confusion Matrix')
plt.tight_layout()
plt.show()

print(f"\nTrue Positives  (happy correctly classified):      {cm[0,0]}")
print(f"True Negatives  (not happy correctly classified): {cm[1,1]}")
print(f"False Positives (happy predicted as not happy):   {cm[0,1]}")
print(f"False Negatives (not happy predicted as happy):   {cm[1,0]}")
No description has been provided for this image
True Positives  (happy correctly classified):      82
True Negatives  (not happy correctly classified): 68
False Positives (happy predicted as not happy):   0
False Negatives (not happy predicted as happy):   0

Step 9: Week 9 vs. Week 10 — Model Comparison¶

Both the Week 9 TF-IDF + Logistic Regression model and this USE deep learning model achieved 100% test accuracy on this dataset. The comparison is still worth making because the path to that accuracy is completely different.

Dimension TF-IDF + Logistic Regression USE + Dense Network
Feature source Word frequency counts (sparse) Pre-trained sentence embeddings (dense 512-dim)
Text cleaning Required (stopwords, lemmatization) None — USE handles raw text
Training data needed Labeled examples only Pre-trained + labeled examples
Vocabulary Fixed to training corpus Universal — handles unseen words
Handles paraphrase? Rarely — word overlap dependent Yes — semantic space captures meaning
Test accuracy 100% 100%

The critical distinction is vocabulary handling. TF-IDF assigns a weight of zero to any word it didn't see in training — if a review uses "dreadful" and the model was trained on reviews using "terrible," TF-IDF misses the connection. USE encodes both words into similar regions of the 512-dimensional space because it learned from general language at scale. That generalization matters enormously on noisier, more varied corpora.

For the screen-time NER project downstream, USE embeddings could encode PubMed abstracts and Reddit posts into the same vector space — enabling semantic similarity across very different writing registers. That's exactly the cross-source comparison Milestone 1 identified as the project's end goal.

In [12]:
# Summary table
print("Model Performance Summary")
print("=" * 45)
print(f"{'Metric':<30} {'Value'}")
print("-" * 45)
print(f"{'Test Accuracy':<30} {test_acc:.4f}")
print(f"{'Test Loss':<30} {test_loss:.6f}")
print(f"{'Precision (happy)':<30} 1.00")
print(f"{'Recall (happy)':<30} 1.00")
print(f"{'Precision (not happy)':<30} 1.00")
print(f"{'Recall (not happy)':<30} 1.00")
print(f"{'F1 Score (weighted avg)':<30} 1.00")
print(f"{'Embedding dimensions':<30} 512")
print(f"{'Total trainable params':<30} 164,353")
print(f"{'Training epochs':<30} {EPOCHS}")
print(f"{'Batch size':<30} {BATCH_SIZE}")
print("=" * 45)
Model Performance Summary
=============================================
Metric                         Value
---------------------------------------------
Test Accuracy                  1.0000
Test Loss                      0.000084
Precision (happy)              1.00
Recall (happy)                 1.00
Precision (not happy)          1.00
Recall (not happy)             1.00
F1 Score (weighted avg)        1.00
Embedding dimensions           512
Total trainable params         164,353
Training epochs                10
Batch size                     32
=============================================

Observations and Interpretation¶

The USE model converges faster than any other approach tried across Weeks 9 and 10. By Epoch 2, both train and validation accuracy are at 1.00 and loss is effectively at zero. That speed is a direct consequence of transfer learning — the 512-dimensional embeddings arrived already organized by meaning, so the dense classifier only needed a handful of gradient steps to learn where the boundary between happy and not happy sits in that space.

The 100% test accuracy, like in Week 9, reflects the relative simplicity of the synthetic hotel dataset. The reviews use direct, unambiguous sentiment language — "absolutely wonderful," "terrible experience" — that any semantic encoder handles with ease. On a corpus with more nuance (sarcasm, qualified academic language, mixed-sentiment reviews), the advantage of USE over TF-IDF would show up more clearly in the evaluation numbers. USE's vocabulary generalization and paraphrase handling would pull ahead on cases where exact word overlap is low but semantic meaning is shared.

The most practically useful observation is the one the loss curve shows in Epoch 1: the validation loss was already lower than the training loss before the model had learned anything. That pattern tells you the pre-trained encoder is doing the real work. The dense layers on top are a relatively thin classifier sitting on a very powerful semantic foundation.


References¶

Gupta, G. (2018, October 19). Universal sentence encoding. Medium. https://medium.com/@gaurav5430/universal-sentence-encoding-7d440fd3c7c7

Sarkar, D. (2019). Text analytics with Python: A practitioner's guide to natural language processing (2nd ed.). Apress.

TensorFlow. (n.d.). Universal sentence encoder. TensorFlow Hub. https://tfhub.dev/google/universal-sentence-encoder/4