Build a Rock-Paper-Scissors Mind Reader

Train a neural network to predict rock-paper-scissors moves in Google Colab.

Introduction

30 Second Summary

Neural networks learn by finding patterns that guide predictions. Rock-paper-scissors is a useful test because each round has only three possible moves, a clear winner, and habits that can emerge over repeated play.

What You'll Build

In this project, you will build a machine that uses your last four moves to predict your next choice. It will play the counter-move that beats its prediction, so every round tests what the neural network has learned.

By the end of this project, you'll have:

  • A 60-round collection game that records your choices. Its running score shows how a random opponent performs.
  • Readable four-move sliding windows that show the context used to predict each next move.
  • A trained dense neural network that reports its accuracy on future held-out moves. A random control shows whether the same model finds anything useful in moves with no pattern.
  • Secret Mission: Replace the Dense model with a small LSTM to compare both models on the same held-out moves.

Are there any prerequisites?

  • A Google account.
  • Basic familiarity with Python syntax.

Before We Start

This checkpoint defines the notebook experiment before any code is written: predict your next rock-paper-scissors move from the previous four moves. Poor held-out performance on random moves would show that the model cannot reliably find patterns in randomness or read minds.

Set Up the Colab Runtime

Your rock-paper-scissors experiment needs a stable notebook environment before it can collect moves. Google Colab gives you that environment without a local installation.

Pinning TensorFlow keeps the model code consistent across runs. Pinning Keras keeps its training API consistent.

A standard CPU runtime provides enough compute for this small dataset. It also keeps the project on Colab's free standard runtime.

In this step, get ready to:
  • Create a named Colab notebook on the standard CPU runtime.
  • Install the pinned TensorFlow and Keras packages.
  • Verify the package versions and active Keras backend.
Create the Colab notebook

Colab stores notebooks in Google Drive. Your code remains available after the temporary runtime ends.

This step uses the Google account you already have. You do not need to create another account.

You'll see the Colab home screen where you can access existing notebooks or create a new one.

  • Complete the Google sign-in prompt with your existing account.
  • Create a new notebook from the Colab home screen.

You'll see a blank notebook with an editable title at the top of the page.

  • Click the notebook title in the top-left corner.
  • Enter Rock_Paper_Scissors_Mind_Reader.ipynb as the notebook name.
  • Wait until Colab confirms that the notebook is saved.

That's the notebook in place: Rock_Paper_Scissors_Mind_Reader.ipynb is now saved in Google Drive.

  • Open the Runtime menu at the top of the notebook.
  • Select Change Runtime Type.

You'll see the runtime settings dialog with an accelerator option.

  • Set Hardware Accelerator to CPU.
  • Apply the runtime setting to return to the notebook.

Your notebook now uses the standard CPU runtime required for this experiment.

Why use the standard runtime?

This project trains a tiny model on only 56 sliding windows. A CPU can handle that workload without paid acceleration.

Install the pinned packages

A Colab runtime has its own active notebook kernel. The %pip command installs packages directly into that kernel.

Package installation may take a few minutes while Colab updates the active runtime. A quiet cell during that wait is normal.

  • Select the first empty code cell in the notebook.
  • Pin both package versions in the selected cell by running:
%pip install tensorflow==2.21.0 keras==3.15.1

What does this command do?

The %pip notebook command installs TensorFlow 2.21.0 into the active kernel. It also installs Keras 3.15.1 into the same kernel.

Exact version pins make the environment reproducible. Every later cell can use the APIs expected by this project.

  • Wait for the installation cell to finish.
  • Confirm the final output shows that the installation completed without a package failure.
  • Press "Restart the session"

Installation cell failed?

Confirm that the notebook is connected to a runtime. Check your internet connection before running the installation cell again.

If the cell still fails, help me troubleshoot the Colab package installation.

Configure and verify the runtime

Keras reads its backend setting when it is imported. Setting KERAS_BACKEND first ensures that Keras uses TensorFlow.

A shared random seed makes later model initialization more reproducible. The final lines expose the exact environment before the experiment begins.

  • Add a new code cell directly below the installation cell.
  • Configure the active runtime in the new cell by pasting the code below.
  • Run the configuration block
# Cell 1: Configure and verify the pinned environment.
import os
os.environ["KERAS_BACKEND"] = "tensorflow"

import random
import keras
import tensorflow as tf

keras.utils.set_random_seed(42)

print(f"TensorFlow: {tf.__version__}")
print(f"Keras: {keras.__version__}")
print(f"Backend: {keras.config.backend()}")

What does this cell do?

  • The os.environ["KERAS_BACKEND"] = "tensorflow" line selects TensorFlow before Keras is imported.
  • The imports make os, random, keras, and tf available to later cells.
  • The keras.utils.set_random_seed(42) call aligns the random seeds used by Python and the model framework.
  • The final three lines print the TensorFlow version, the Keras version, and the active backend.

Seeing the wrong environment?

If the versions or backend differ, help me diagnose my Colab runtime output.

✔️ Awesome, I've got everything!

  • Confirm that Rock_Paper_Scissors_Mind_Reader.ipynb has finished saving in Google Drive.

ⓧ I'd like to double check the full code

The complete configuration cell in Rock_Paper_Scissors_Mind_Reader.ipynb should match this reference.

# Cell 1: Configure and verify the pinned environment.
import os
os.environ["KERAS_BACKEND"] = "tensorflow"

import random
import keras
import tensorflow as tf

keras.utils.set_random_seed(42)

print(f"TensorFlow: {tf.__version__}")
print(f"Keras: {keras.__version__}")
print(f"Backend: {keras.config.backend()}")

How the full cell fits together

This cell selects the backend before importing Keras. It seeds the runtime before printing the three environment checks.

Your pinned Colab runtime is ready to run the experiment. Next, you'll build a random opponent that can play but cannot learn from your choices.

Collect Your Personal Moves

Your pinned Google Colab runtime is ready. The notebook now needs a personal move history for the experiment.

The first opponent chooses each move independently. Repeated play lets you test whether it notices your habits.

In this step, get ready to:
  • Test one move through a validated input prompt.
  • Define the numeric game values and round scoring.
  • Collect a 60-move personal history.
Define the game language

Before you build a loop, test whether the notebook can pause for one valid move. This first run checks input handling without starting an opponent or collecting a history.

  • Set up one validated prompt in the empty second code cell by pasting the code below:
  • Run the second code cell.
  • Enter r, p, s, rock, paper, or scissors at the prompt.
# Cell 2: Validate one practice move.
# Map accepted text to the numeric class used by the game.
MOVE_ALIASES = {
    "r": 0,
    "rock": 0,
    "p": 1,
    "paper": 1,
    "s": 2,
    "scissors": 2,
}

# Keep asking until the learner enters a supported move.
def read_move(prompt="Choose rock, paper, or scissors: "):
    while True:
        value = input(prompt).strip().lower()
        if value in MOVE_ALIASES:
            return MOVE_ALIASES[value]
        print("Enter r, p, s, rock, paper, or scissors.")

# Test one input before any game loop exists.
TEST_INPUT = True
if TEST_INPUT:
    test_move = read_move("Test move: ")
    print("Input accepted.")

How does the input test work?

  • MOVE_ALIASES accepts full move names or their single-letter forms.
  • input() pauses the cell until you enter a move.
  • strip() removes spaces around your response.
  • lower() makes capital letters match the lowercase aliases.
  • The loop returns a numeric class only after your response matches an alias.

You should see Input accepted. after one valid response. The cell stops there, so no opponent or collection loop runs.

Is the practice input failing?

  • Check that the body of read_move() stays indented beneath the function definition.
  • Enter one alias listed in MOVE_ALIASES.

Help me fix the practice input.

That first interaction proves the notebook can capture a valid move. Turn off the test before you build the game so later runs continue without another practice prompt.

  • In the second code cell, find this line:
TEST_INPUT = True
  • Replace True with False so the line looks like this:
TEST_INPUT = False

The game also needs fixed values for scoring and model input. A one-hot encoding gives each numeric move its own three-number vector.

  • Add the shared game values below the input test by pasting the code below:
  • Run the second code cell again.
# Add the shared values used by scoring and model input.
# Keep move names in numeric class order.
MOVES = ("rock", "paper", "scissors")

# Record which move wins and which response counters it.
BEATS = {0: 2, 1: 0, 2: 1}
COUNTER = {0: 1, 1: 2, 2: 0}

# Give each move a distinct three-number representation.
ONE_HOT = (
    (1.0, 0.0, 0.0),
    (0.0, 1.0, 0.0),
    (0.0, 0.0, 1.0),
)

# Use four previous moves as the model context.
WINDOW_SIZE = 4

What do the shared values represent?

  • MOVES maps each numeric class back to its move name.
  • BEATS records which move each class defeats.
  • COUNTER records the response that defeats each predicted move.
  • ONE_HOT stores a distinct three-number vector for each class.
  • WINDOW_SIZE fixes the sequence context at four moves.

You should see the cell finish without a prompt or traceback. The input helper and shared game values are now available in the runtime.

Does the practice prompt return?

Confirm that TEST_INPUT is set to False before rerunning the cell.

Help me stop the practice prompt.

Round scoring compares your numeric move with the notebook's numeric move. The result identifies a tie or the winner.

  • Add the scoring function below WINDOW_SIZE = 4 by pasting the code below:
  • Run the second code cell again.
# Score one completed round from the two numeric moves.
def round_result(player_move, notebook_move):
    if player_move == notebook_move:
        return "tie"
    if BEATS[player_move] == notebook_move:
        return "human"
    return "notebook"

How is each round scored?

  • Matching classes return tie.
  • BEATS[player_move] checks whether your class defeats the notebook's class.
  • Every remaining combination returns notebook.

You should see the cell complete without a traceback. Both helper functions are now available in the runtime.

Build the collection loop

A collection loop turns individual rounds into an ordered history. It stores only your moves because those are the observations the notebook needs from this game.

  • Add the collection function below round_result() by pasting the code below:
  • Run the second code cell again.
def collect_moves(rounds=60):
    history = []
    scores = {"human": 0, "notebook": 0, "ties": 0}

    print("Collection phase: the notebook is choosing randomly.")
    for round_number in range(1, rounds + 1):
        notebook_move = random.randrange(len(MOVES))
        player_move = read_move(f"Round {round_number}/{rounds}: ")
        result = round_result(player_move, notebook_move)
        history.append(player_move)

        score_key = "ties" if result == "tie" else result
        scores[score_key] += 1
        print(
            f"You: {MOVES[player_move]} | "
            f"Notebook: {MOVES[notebook_move]} | Result: {result}"
        )

    print(f"Final collection score: {scores}")
    print(f"Moves collected: {len(history)}")
    return history

What does the collection loop do?

  • history preserves your moves in the order you play them.
  • scores counts human wins, notebook wins, and ties.
  • random.randrange(len(MOVES)) chooses the notebook's move independently for each round.
  • read_move() rejects unsupported input before a move reaches the history.
  • round_result() supplies the score category for the current round.
  • The final two print calls expose the score and collected history length.

You should see the cell finish without starting the game. The runtime now contains collect_moves().

Collect 60 personal moves

The collection call starts the interactive game. Each accepted response adds one integer to human_history.

Before you run the cell, do you think the notebook's choices will shift when you repeat the same move?

Sixty prompts take a few minutes. Each response becomes one observation in your personal sequence.

  • Add the collection call below return history by pasting the code below:
  • Run the second code cell.
  • Enter r, p, s, rock, paper, or scissors at each prompt.
human_history = collect_moves(60)

What starts the game?

collect_moves(60) requests 60 valid moves. The returned integer sequence is assigned to human_history.

You will see your move, the notebook's move, and the result after every round. The final output ends with Final collection score: followed by Moves collected: 60.

Did collection stop before 60 moves?

Help me finish collecting 60 moves.

You completed the patient part. Your notebook now holds 60 ordered choices from your own play.

What did the random opponent prove?

Start with the final score. How many rounds were human wins, notebook wins, and ties? Convert each count into a share of the 60 rounds.

A random opponent gives each outcome about a one-in-three chance, so a rough benchmark is 20 human wins, 20 notebook wins, and 20 ties. Did you win more or less often than that benchmark? Note the size of the difference.

Which outcome dominated your results, if any? Decide whether the gap looks like evidence of adaptation or normal variation across a small random sample.

✔️ Awesome, I've got everything!

Great work. Confirm that the second cell finished with Moves collected: 60 and that human_history remains available in the connected runtime.

ⓧ I'd like to double check the full code

Compare your two notebook cells with this cumulative code:

# Cell 1: Configure and verify the pinned environment.
import os
os.environ["KERAS_BACKEND"] = "tensorflow"

import random
import keras
import tensorflow as tf

keras.utils.set_random_seed(42)

print(f"TensorFlow: {tf.__version__}")
print(f"Keras: {keras.__version__}")
print(f"Backend: {keras.config.backend()}")

# Cell 2: Validate input and collect personal moves.
# Map accepted text to the numeric class used by the game.
MOVE_ALIASES = {
    "r": 0,
    "rock": 0,
    "p": 1,
    "paper": 1,
    "s": 2,
    "scissors": 2,
}

# Keep asking until the learner enters a supported move.
def read_move(prompt="Choose rock, paper, or scissors: "):
    while True:
        value = input(prompt).strip().lower()
        if value in MOVE_ALIASES:
            return MOVE_ALIASES[value]
        print("Enter r, p, s, rock, paper, or scissors.")

# Keep the completed practice test disabled during later runs.
TEST_INPUT = False
if TEST_INPUT:
    test_move = read_move("Test move: ")
    print("Input accepted.")

# Keep move names in numeric class order.
MOVES = ("rock", "paper", "scissors")

# Record which move wins and which response counters it.
BEATS = {0: 2, 1: 0, 2: 1}
COUNTER = {0: 1, 1: 2, 2: 0}

# Give each move a distinct three-number representation.
ONE_HOT = (
    (1.0, 0.0, 0.0),
    (0.0, 1.0, 0.0),
    (0.0, 0.0, 1.0),
)

# Use four previous moves as the model context.
WINDOW_SIZE = 4

# Score one completed round from the two numeric moves.
def round_result(player_move, notebook_move):
    if player_move == notebook_move:
        return "tie"
    if BEATS[player_move] == notebook_move:
        return "human"
    return "notebook"

# Collect ordered player moves against a random opponent.
def collect_moves(rounds=60):
    history = []
    scores = {"human": 0, "notebook": 0, "ties": 0}

    print("Collection phase: the notebook is choosing randomly.")
    for round_number in range(1, rounds + 1):
        notebook_move = random.randrange(len(MOVES))
        player_move = read_move(f"Round {round_number}/{rounds}: ")
        result = round_result(player_move, notebook_move)
        history.append(player_move)

        score_key = "ties" if result == "tie" else result
        scores[score_key] += 1
        print(
            f"You: {MOVES[player_move]} | "
            f"Notebook: {MOVES[notebook_move]} | Result: {result}"
        )

    print(f"Final collection score: {scores}")
    print(f"Moves collected: {len(history)}")
    return history

# Start the 60-round collection game.
human_history = collect_moves(60)

Your personal sequence is ready. Next, you will turn these 60 choices into four-move windows with a next-move target.

Create Sliding-Window Tensors

Your 60-round collection has captured a personal move sequence. The next goal is to reshape that history into examples a model can study.

A sequence classifier needs each target move paired with a fixed-size context. A flat list does not provide that structure.

Keras turns the history into sliding windows stored as tensors. One-hot encoding gives each move a three-number representation.

In this step, get ready to:
  • Encode the recorded moves with three-value vectors.
  • Package each four-move context with its next-move label.
  • Confirm the tensor dimensions plus the chronological training boundary.
Build chronological windows

Each input window contains four consecutive moves. Its label is the move that came immediately afterward.

  • Add a code cell beneath Cell 2 in the notebook from earlier.
  • Paste the window-building function into the new cell by copying the code below:
  • Run Cell 3 to load make_window_tensors() into the connected runtime.
# Cell 3: Create chronological sliding-window tensors.
def make_window_tensors(moves, window_size=WINDOW_SIZE):
    encoded_moves = keras.ops.convert_to_tensor(
        [ONE_HOT[move] for move in moves], dtype="float32"
    )
    targets = keras.ops.convert_to_tensor(
        moves[window_size:], dtype="int32"
    )

    dataset = keras.utils.timeseries_dataset_from_array(
        data=encoded_moves[:-1],
        targets=targets,
        sequence_length=window_size,
        sequence_stride=1,
        batch_size=None,
        shuffle=False,
    )

    windows = []
    labels = []
    for window, label in dataset:
        windows.append(window)
        labels.append(label)

    return keras.ops.stack(windows), keras.ops.stack(labels)

What does this function do?

  • encoded_moves maps each integer through ONE_HOT. Every move becomes a three-value vector.
  • targets starts after the first four moves. Each label represents the move following one window.
  • keras.utils.timeseries_dataset_from_array moves through the history one position at a time. shuffle=False preserves chronological order.
  • keras.ops.stack combines the individual examples into feature tensors plus label tensors.

The cell's completed execution indicator confirms that make_window_tensors() is ready in memory.

Cell does not finish?

Help me debug the sliding-window function.

Inspect the chronological dataset

The next lines call the function with human_history. They expose one window plus its target so you can inspect the transformation.

Before you run the complete cell, how many examples do you expect from 60 moves when every label needs four earlier moves?

  • Paste the inspection code below the return keras.ops.stack(windows), keras.ops.stack(labels) line:
  • Run Cell 3 to create the tensors and print the inspection results.
features, labels = make_window_tensors(human_history)
first_window = keras.ops.convert_to_numpy(features[0])
first_target = int(keras.ops.convert_to_numpy(labels[0]))

print(f"Input shape: {features.shape}")
print(f"Label shape: {labels.shape}")
print(f"Chronological 75% split index: {int(features.shape[0] * 0.75)}")
print("First one-hot window:")
print(first_window)
print(f"First target: {MOVES[first_target]}")

What does the inspection show?

  • features holds the four-move input windows.
  • labels holds the integer move following each window.
  • keras.ops.convert_to_numpy turns the first example into a readable array.
  • The split calculation marks the first 75% of the ordered windows for training.
  • first_target maps the first integer label back to a move name through MOVES.

You'll see Input shape: (56, 4, 3). You'll also see Label shape: (56,).

The output prints Chronological 75% split index: 42. The displayed first window contains four one-hot vectors.

The first target names the move that followed those four choices. Its value depends on the sequence you played.

Why keep the split chronological?

Held-out moves are the latest 14 next-move labels reserved from training. The model sees them only during the final test.

This order tests whether earlier behavior helps predict choices made later. It also prevents future moves from leaking into the training segment.

That transformation is complete: your 60 choices are now a model-ready chronological dataset.

Shapes do not match?

Confirm that human_history still contains 60 moves. Rerun the collection cell if the runtime lost that history.

Check that WINDOW_SIZE is set to 4.

Help me fix my tensor shapes.

✔️ Awesome, I've got everything!

Cell 3 now creates 56 chronological windows. It also displays the expected tensor shapes plus the split index.

ⓧ I'd like to double check the full code

Here is the cumulative notebook code through Cell 3:

# Cell 1: Configure and verify the pinned environment.
import os
os.environ["KERAS_BACKEND"] = "tensorflow"

import random
import keras
import tensorflow as tf

keras.utils.set_random_seed(42)

print(f"TensorFlow: {tf.__version__}")
print(f"Keras: {keras.__version__}")
print(f"Backend: {keras.config.backend()}")


# Cell 2: Define the game and collect personal moves.
MOVES = ("rock", "paper", "scissors")
MOVE_ALIASES = {
    "r": 0,
    "rock": 0,
    "p": 1,
    "paper": 1,
    "s": 2,
    "scissors": 2,
}
BEATS = {0: 2, 1: 0, 2: 1}
COUNTER = {0: 1, 1: 2, 2: 0}
ONE_HOT = (
    (1.0, 0.0, 0.0),
    (0.0, 1.0, 0.0),
    (0.0, 0.0, 1.0),
)
WINDOW_SIZE = 4


def read_move(prompt="Choose rock, paper, or scissors: "):
    while True:
        value = input(prompt).strip().lower()
        if value in MOVE_ALIASES:
            return MOVE_ALIASES[value]
        print("Enter r, p, s, rock, paper, or scissors.")


def round_result(player_move, notebook_move):
    if player_move == notebook_move:
        return "tie"
    if BEATS[player_move] == notebook_move:
        return "human"
    return "notebook"


def collect_moves(rounds=60):
    history = []
    scores = {"human": 0, "notebook": 0, "ties": 0}

    print("Collection phase: the notebook is choosing randomly.")
    for round_number in range(1, rounds + 1):
        notebook_move = random.randrange(len(MOVES))
        player_move = read_move(f"Round {round_number}/{rounds}: ")
        result = round_result(player_move, notebook_move)
        history.append(player_move)

        score_key = "ties" if result == "tie" else result
        scores[score_key] += 1
        print(
            f"You: {MOVES[player_move]} | "
            f"Notebook: {MOVES[notebook_move]} | Result: {result}"
        )

    print(f"Final collection score: {scores}")
    print(f"Moves collected: {len(history)}")
    return history


human_history = collect_moves(60)


# Cell 3: Create chronological sliding-window tensors.
def make_window_tensors(moves, window_size=WINDOW_SIZE):
    encoded_moves = keras.ops.convert_to_tensor(
        [ONE_HOT[move] for move in moves], dtype="float32"
    )
    targets = keras.ops.convert_to_tensor(
        moves[window_size:], dtype="int32"
    )

    dataset = keras.utils.timeseries_dataset_from_array(
        data=encoded_moves[:-1],
        targets=targets,
        sequence_length=window_size,
        sequence_stride=1,
        batch_size=None,
        shuffle=False,
    )

    windows = []
    labels = []
    for window, label in dataset:
        windows.append(window)
        labels.append(label)

    return keras.ops.stack(windows), keras.ops.stack(labels)


features, labels = make_window_tensors(human_history)
first_window = keras.ops.convert_to_numpy(features[0])
first_target = int(keras.ops.convert_to_numpy(labels[0]))

print(f"Input shape: {features.shape}")
print(f"Label shape: {labels.shape}")
print(f"Chronological 75% split index: {int(features.shape[0] * 0.75)}")
print("First one-hot window:")
print(first_window)
print(f"First target: {MOVES[first_target]}")

This reference shows the exact cumulative notebook state through the sliding-window transformation.

Your move history now has the structure a model can learn from. Next, you will train the Dense predictor on the earlier windows.

Train and Evaluate the Dense Model

Your chronological windows now preserve each four-move context with its next-move label. Now you can test whether those contexts contain repeatable clues about your future choices.

A small Dense neural network will learn from the earliest windows. The latest windows will measure how well it handles moves that came later.

The same architecture will also train on a random control. That comparison prevents a lucky result or overfitting from passing as mind reading.

In this step, get ready to:
  • Build a low-capacity Dense classifier.
  • Train it with a chronological split.
  • Compare your held-out result with a random control.
Build the Dense classifier

Every model needs a consistent training recipe. A shared compiler gives this model its optimizer, loss function, and accuracy metric through Keras.

  • Add a new code cell beneath Cell 3 using the notebook's add-cell control.
  • Define the shared compiler in the new cell by adding the code below:
  • Run the new code cell.
# Cell 4: Build, train, and evaluate the Dense predictor.
def compile_classifier(model):
    model.compile(
        optimizer=keras.optimizers.Adam(),
        loss=keras.losses.SparseCategoricalCrossentropy(),
        metrics=[keras.metrics.SparseCategoricalAccuracy()],
    )
    return model

What does this code do?

  • The Adam optimizer adjusts the model's weights while it learns.
  • Sparse categorical crossentropy measures errors across the three move classes.
  • Sparse categorical accuracy tracks how often the predicted move matches the integer label.
  • The function returns a compiled model that is ready for training.

The cell should finish successfully. The runtime now has a reusable compile_classifier() function.

The network needs one probability for each possible next move. A compact multiclass classifier keeps the capacity appropriate for only 56 windows.

  • Add the Dense architecture directly below compile_classifier() by pasting the code below:
  • Run Cell 4 again.
def build_dense(window_size=WINDOW_SIZE):
    return compile_classifier(
        keras.Sequential(
            [
                keras.Input(shape=(window_size, len(MOVES))),
                keras.layers.Flatten(),
                keras.layers.Dense(12, activation="relu"),
                keras.layers.Dense(len(MOVES), activation="softmax"),
            ]
        )
    )

How is the model structured?

  • The input accepts four moves with three one-hot values per move.
  • The Flatten layer converts the four-step window into one vector.
  • The hidden Dense layer uses 12 units to learn combinations across that vector.
  • The output layer returns three probabilities through softmax.

💡 Why start with a Dense model?

A low-capacity baseline gives the experiment a clear starting point. It can learn simple patterns without adding unnecessary complexity to a dataset of 56 windows.

The cell should complete successfully. The runtime can now create a compiled model with build_dense().

Dense model definition failing?

Confirm that build_dense() sits below compile_classifier() in the same cell.

Help me fix the Dense architecture.

Create the chronological training helper

The training helper reserves the newest 25% of windows for testing. The model only learns from the first 42 windows.

Early stopping watches validation loss during training. It restores the weights from the best validation point before the held-out evaluation.

  • Add the training helper directly below build_dense() by pasting the code below:
  • Run Cell 4 again.
def train_and_score(moves, model_builder, seed):
    x, y = make_window_tensors(moves)
    split_index = int(x.shape[0] * 0.75)
    x_train, x_test = x[:split_index], x[split_index:]
    y_train, y_test = y[:split_index], y[split_index:]

    keras.utils.set_random_seed(seed)
    model = model_builder(WINDOW_SIZE)
    stop_early = keras.callbacks.EarlyStopping(
        monitor="val_loss",
        patience=12,
        restore_best_weights=True,
    )
    model.fit(
        x_train,
        y_train,
        validation_split=0.2,
        epochs=120,
        batch_size=8,
        shuffle=False,
        callbacks=[stop_early],
        verbose=0,
    )
    test_loss, test_accuracy = model.evaluate(x_test, y_test, verbose=0)
    return model, float(test_accuracy)

How does training stay honest?

  • The chronological split sends the first 75% of windows to training.
  • The latest 25% stays hidden until the final evaluation.
  • The fixed seed makes each experiment's model initialization reproducible.
  • The validation split uses the final 20% of the training segment to monitor learning.
  • Disabling shuffling preserves the order of the move sequence.

The cell should finish successfully. The runtime now has a train_and_score() function that returns a trained model with its held-out accuracy.

Compare personal and random results

A random control gives the same model 60 synthetic moves with no intentional sequence pattern. Any surprising score on that control shows how noisy a 14-window test can be.

Before you run the full cell, do you think your sequence will score differently from the random control?

  • Add both experiments directly below train_and_score() by pasting the code below:
  • Run Cell 4 to train both models.
dense_model, human_test_accuracy = train_and_score(
    human_history, build_dense, seed=42
)

control_rng = random.Random(2026)
random_control = [control_rng.randrange(len(MOVES)) for _ in range(60)]
_, random_test_accuracy = train_and_score(
    random_control, build_dense, seed=43
)

chance_reference = 1 / len(MOVES)
print(f"Your sequence held-out accuracy: {human_test_accuracy:.1%}")
print(f"Random control held-out accuracy: {random_test_accuracy:.1%}")
print(f"Chance reference: {chance_reference:.1%}")
print("One small test split is noisy, so compare patterns rather than declaring a winner.")

What does the comparison do?

  • The first experiment trains dense_model on your chronological move history.
  • The second experiment generates a separate reproducible sequence with random.Random(2026).
  • Both experiments use the same architecture and training process.
  • The chance reference divides one correct class by the three possible moves.

You'll see a held-out accuracy for your sequence. You'll also see a held-out accuracy for the random control.

The chance reference should print as 33.3%. The final line reminds you that this small test split cannot support a conclusive claim.

How should you read the scores?

Held-out moves are the latest choices kept out of the model's training data. A personal score above the random control can suggest a repeatable pattern in those unseen choices.

A random-control score above chance can happen across only 14 test windows. Its moves still came from a reproducible random generator.

A personal score near chance is a valid result. Your latest choices offered little usable information to this four-move Dense model.

✔️ Awesome, I've got everything!

Great. Double check that Cell 4 is saved in your notebook and that all three accuracy lines appear beneath it.

ⓧ I'd like to double check the full code

Here is what Cell 4 in Rock_Paper_Scissors_Mind_Reader.ipynb should look like:

# Cell 4: Build, train, and evaluate the Dense predictor.
def compile_classifier(model):
    model.compile(
        optimizer=keras.optimizers.Adam(),
        loss=keras.losses.SparseCategoricalCrossentropy(),
        metrics=[keras.metrics.SparseCategoricalAccuracy()],
    )
    return model


def build_dense(window_size=WINDOW_SIZE):
    return compile_classifier(
        keras.Sequential(
            [
                keras.Input(shape=(window_size, len(MOVES))),
                keras.layers.Flatten(),
                keras.layers.Dense(12, activation="relu"),
                keras.layers.Dense(len(MOVES), activation="softmax"),
            ]
        )
    )


def train_and_score(moves, model_builder, seed):
    x, y = make_window_tensors(moves)
    split_index = int(x.shape[0] * 0.75)
    x_train, x_test = x[:split_index], x[split_index:]
    y_train, y_test = y[:split_index], y[split_index:]

    keras.utils.set_random_seed(seed)
    model = model_builder(WINDOW_SIZE)
    stop_early = keras.callbacks.EarlyStopping(
        monitor="val_loss",
        patience=12,
        restore_best_weights=True,
    )
    model.fit(
        x_train,
        y_train,
        validation_split=0.2,
        epochs=120,
        batch_size=8,
        shuffle=False,
        callbacks=[stop_early],
        verbose=0,
    )
    test_loss, test_accuracy = model.evaluate(x_test, y_test, verbose=0)
    return model, float(test_accuracy)


dense_model, human_test_accuracy = train_and_score(
    human_history, build_dense, seed=42
)

control_rng = random.Random(2026)
random_control = [control_rng.randrange(len(MOVES)) for _ in range(60)]
_, random_test_accuracy = train_and_score(
    random_control, build_dense, seed=43
)

chance_reference = 1 / len(MOVES)
print(f"Your sequence held-out accuracy: {human_test_accuracy:.1%}")
print(f"Random control held-out accuracy: {random_test_accuracy:.1%}")
print(f"Chance reference: {chance_reference:.1%}")
print("One small test split is noisy, so compare patterns rather than declaring a winner.")

Your Dense predictor now has an honest held-out score and a random-control comparison. Next, you'll turn its probabilities into counter-moves for a live rematch.

Battle the Trained Mind Reader

Your trained Dense neural network has now produced a held-out score on moves it did not train on. That score gives you evidence about its predictions.

Held-out accuracy still feels abstract until each prediction becomes a counter-move against new choices. In this step, you'll turn each probability vector into a move for a live 15-round rematch.

In this step, get ready to:
  • Convert the latest four moves into prediction probabilities.
  • Counter each predicted move in a 15-round rematch.
  • Interpret the final score alongside held-out accuracy.
Predict the next move

The model expects four one-hot encoded moves in a batch. The prediction function prepares that input before selecting the move with the highest probability.

  • Add a code cell directly beneath Cell 4.
  • Paste the predictor into that cell by copying the code below.
  • Run the cell after you paste the predictor.
# Cell 5: Play a rematch against the trained predictor.
def predict_next_move(model, recent_moves):
    encoded_window = [ONE_HOT[move] for move in recent_moves[-WINDOW_SIZE:]]
    batch = keras.ops.convert_to_tensor([encoded_window], dtype="float32")
    probabilities = keras.ops.convert_to_numpy(model(batch, training=False))[0]
    predicted_move = max(
        range(len(MOVES)), key=lambda move_id: float(probabilities[move_id])
    )
    return predicted_move, probabilities

What does this code do?

  • The encoded_window list converts the latest four moves through ONE_HOT.
  • The batch tensor adds the batch dimension expected by the model.
  • The model(batch, training=False) call runs inference without changing the trained model.
  • The predicted_move value identifies the class with the highest probability.
  • Run the cell.
  • Confirm that the cell finishes without an exception.

The predictor is now ready to turn four recent moves into a three-class forecast.

Predictor cell not finishing?

A missing-name message usually means an earlier setup cell needs to run again. Confirm that ONE_HOT, WINDOW_SIZE, MOVES, and keras are available.

Help me debug the prediction function.

Build the rematch loop

A fair rematch commits to the notebook's counter before you reveal your next move. Each new choice then joins the history used for the following prediction.

  • Place your cursor below return predicted_move, probabilities in Cell 5.
  • Add the rematch loop by copying this code:
def play_mind_reader(model, starting_history, rounds=15):
    history = list(starting_history)
    scores = {"human": 0, "notebook": 0, "ties": 0}

    for round_number in range(1, rounds + 1):
        predicted_move, probabilities = predict_next_move(model, history)
        notebook_move = COUNTER[predicted_move]
        player_move = read_move(f"Mind-reader round {round_number}/{rounds}: ")
        result = round_result(player_move, notebook_move)
        history.append(player_move)

        score_key = "ties" if result == "tie" else result
        scores[score_key] += 1
        probability_text = ", ".join(
            f"{MOVES[index]}={float(probabilities[index]):.1%}"
            for index in range(len(MOVES))
        )
        print(
            f"Predicted: {MOVES[predicted_move]} | "
            f"Counter: {MOVES[notebook_move]} | "
            f"You played: {MOVES[player_move]} | Result: {result}"
        )
        print(f"Probabilities: {probability_text}")

    print(f"Final rematch score: {scores}")
    print(f"Rounds scored: {sum(scores.values())}")
    return scores


rematch_scores = play_mind_reader(
    dense_model, human_history.copy(), rounds=15
)

How does the rematch work?

  • The history list creates a separate rematch history without changing human_history.
  • The loop calls predict_next_move before read_move. The notebook commits to its counter before you enter a move.
  • The history.append(player_move) line makes each new move available to the next prediction.
  • The probability_text value formats all three probabilities for comparison.
Run and interpret the rematch

The rematch tests the trained model against choices made after training. Its result shows how the frozen predictor interacts with your current behavior.

  • Predict whether the notebook can finish the rematch with more wins than you.
  • Run Cell 5.
  • Enter one valid move at each prompt until all 15 rounds are complete.

After every move, you'll see the predicted move plus the notebook's counter. The following line shows all three move probabilities.

At the end, you'll see the human total, the notebook total, and the tie total. The Rounds scored line ends in 15.

You have turned the model's abstract accuracy score into a live opponent that reacts to its own forecast.

Rematch stopping early?

If a prompt repeats, enter one of the accepted move names or aliases. Valid entries include r, p, s, rock, paper, and scissors.

If the cell reports a missing name, run the earlier game and model cells again. They define read_move, round_result, dense_model, and human_history.

Help me fix the rematch loop.

A 15-round score is one small sample of your post-training choices. Patterns can persist during the rematch or change once you know the model is watching.

  • Compare the notebook total with the human total in rematch_scores.
  • Review whether your choices repeated a pattern during the rematch.
  • Classify your behavior as stable, deliberately changed, or effectively unpredictable.

✔️ Awesome, I've got everything!

Great work. Confirm that Cell 5 completed all 15 rounds and stored the final totals in rematch_scores.

ⓧ I'd like to double check the full code

Here is what Cell 5 in Rock_Paper_Scissors_Mind_Reader.ipynb should look like:

# Cell 5: Play a rematch against the trained predictor.
def predict_next_move(model, recent_moves):
    encoded_window = [ONE_HOT[move] for move in recent_moves[-WINDOW_SIZE:]]
    batch = keras.ops.convert_to_tensor([encoded_window], dtype="float32")
    probabilities = keras.ops.convert_to_numpy(model(batch, training=False))[0]
    predicted_move = max(
        range(len(MOVES)), key=lambda move_id: float(probabilities[move_id])
    )
    return predicted_move, probabilities


def play_mind_reader(model, starting_history, rounds=15):
    history = list(starting_history)
    scores = {"human": 0, "notebook": 0, "ties": 0}

    for round_number in range(1, rounds + 1):
        predicted_move, probabilities = predict_next_move(model, history)
        notebook_move = COUNTER[predicted_move]
        player_move = read_move(f"Mind-reader round {round_number}/{rounds}: ")
        result = round_result(player_move, notebook_move)
        history.append(player_move)

        score_key = "ties" if result == "tie" else result
        scores[score_key] += 1
        probability_text = ", ".join(
            f"{MOVES[index]}={float(probabilities[index]):.1%}"
            for index in range(len(MOVES))
        )
        print(
            f"Predicted: {MOVES[predicted_move]} | "
            f"Counter: {MOVES[notebook_move]} | "
            f"You played: {MOVES[player_move]} | Result: {result}"
        )
        print(f"Probabilities: {probability_text}")

    print(f"Final rematch score: {scores}")
    print(f"Rounds scored: {sum(scores.values())}")
    return scores


rematch_scores = play_mind_reader(
    dense_model, human_history.copy(), rounds=15
)

Secret mission

Replace the Dense Model with an LSTM

Your Dense model has set the baseline. Replace it with a small LSTM. Compare both held-out scores on the same personal history.

Clean Up Your Resources

Clean Up Your Resources

Your notebook uses the free standard CPU runtime in Google Colab, so this project has no ongoing cost. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • One Rock_Paper_Scissors_Mind_Reader.ipynb notebook stored in Google Drive.
  • One temporary Google Colab CPU runtime containing installed packages, move histories, tensors, trained models, and rematch scores.

Keep everything running

No action needed. Choose this if you want to run another rematch or compare more move sequences during the current session.

  • Keep the Rock_Paper_Scissors_Mind_Reader.ipynb notebook connected.
  • Run another 15-round rematch while the trained models remain available.
  • Expect the free runtime to time out eventually because its resources are not guaranteed or unlimited.
  • Keep the notebook in Google Drive at no ongoing cost.

Pause - I'll come back to this later

This option ends your active work while keeping the notebook in Google Drive. A fresh runtime requires you to recreate the packages and in-memory experiment data.

  • Close the Rock_Paper_Scissors_Mind_Reader.ipynb notebook tab.
  • Return to the notebook from Google Drive when you are ready to continue.
  • Run the setup cells again when Google Colab assigns a fresh runtime.
  • Run the data-collection cell again to recreate human_history.

Delete - I don't want to use this again

Deleting these resources removes your saved notebook and clears the temporary virtual machine state. Choose this option when you no longer need the experiment.

  • In the open Google Colab notebook, select Runtime from the top menu.
  • Select Disconnect and delete runtime.
  • Return to Google Drive.
  • Select Rock_Paper_Scissors_Mind_Reader.ipynb in the file list.
  • Delete the selected notebook.

The current managed virtual machine state is reset. You should no longer see the notebook in its previous Google Drive location.

Nice Work!

Nice Work!

You did it! Your Google Colab notebook now learns from your rock-paper-scissors history. A random control keeps its apparent mind-reading honest.

You've learned how to:

  • Built an interactive game that records a personal 60-move sequence. Turned those ordered moves into four-step sliding windows.
  • Trained a compact Dense neural network to predict the next move. Evaluated it on the newest held-out windows.
  • Compared personal behavior with a synthetic random control. Converted the model's predictions into counter-moves for a live 15-round rematch.
  • Secret Mission: Replaced the Dense baseline with an LSTM. Compared both models on the same personal history and chronological split.

Ready to quiz yourself?