← Back to list

Building a Toy RLHF System with Markov Processes: A Hands-On Guide

RLHF has become a cornerstone technique for fine-tuning LLMs, enabling them to align with human preferences. At its core, RLHF leverages a…

tej · 2025-08-11 17:06 · 2 claps · 4.3 min read
#machine-learning #large-language-models #artificial-intelligence #markov-chains #reinforcement-learning
Open on Medium ↗
Wiki topics: LLM · Large Language Models RAG · RAG & Retrieval FT · Fine-tuning & Adaptation ML · Machine Learning AI · AI · General EDU · Education & Learning

Building a Toy RLHF System with Markov Processes: A Hands-On Guide

RLHF has become a cornerstone technique for fine-tuning LLMs, enabling them to align with human preferences. At its core, RLHF leverages a Markov Decision Process (MDP) framework, where an agent learns to make sequential decisions based on rewards.

In this article, we’ll walk through a simple implementation of an RLHF system using Python and PyTorch. This toy example models sentence generation as an MDP, training a policy to produce positive-sounding sentences.

Understanding the Concept

Before coding, let’s understand. An MDP consists of states, actions, transitions, and rewards. Here:

states -> current sequence of words

actions -> the next words to add

rewards -> simulate human feedback (e.g., +1 for positive sentences, -1 for negative ones).

The Markov property ensures the next state depends only on the current state and action, mirroring how LLMs generate text autoregressively. Our goal is to train a policy network to maximize cumulative rewards over time using the REINFORCE algorithm, a policy gradient method.

This project is challenging due to variable-length sequences, sparse rewards, and the need to stabilize training in a high-dimensional action space — even with a small vocabulary.

This code runs on a Mac with an M4 chip (or any system with Python and PyTorch)

pip3 install torch torchvision numpy

1. Defining Hyperparameters and Vocabulary

We start by defining the constants that shape our experiment.

# Hyperparameters
VOCAB = ['start', 'the', 'cat', 'dog', 'is', 'happy', 'sad', 'runs', 'success', 'failure', 'end']  # Small vocabulary
VOCAB_SIZE = len(VOCAB)
STATE_DIM = 32  # Embedding dimension for state
HIDDEN_DIM = 64  # Policy network hidden size
MAX_LENGTH = 5  # Max sentence length (episode length)
GAMMA = 0.99  # Discount factor
LR = 0.001  # Learning rate
EPISODES = 1000  # Training episodes
BATCH_SIZE = 32  # For sampling episodes

# Token to index mapping
token_to_idx = {token: idx for idx, token in enumerate(VOCAB)}
idx_to_token = {idx: token for token, idx in token_to_idx.items()}

This section sets up a small vocabulary of 11 words, including start and end tokens. The state dimension (32) and hidden dimension (64) define the policy network’s architecture. MAX_LENGTH limits sentence length, while GAMMA discounts future rewards. The token-to-index mappings enable numerical processing.

2. Designing the Reward Function

The reward function simulates human feedback, a critical component of RLHF.

# Reward function: Simulate human feedback
def compute_reward(sentence):
    # Positive if contains 'happy' or 'success', negative otherwise
    if 'happy' in sentence or 'success' in sentence:
        return 1.0
    else:
        return -1.0

This simple function returns +1 if the sentence contains “happy” or “success” and -1 otherwise. In real RLHF, reward models are more sophisticated, often trained on human-labeled data, but this suffices for our toy example.

3. Building the Policy Network

The policy network predicts the next token’s probability distribution given the current state.

# Policy Network: Simple MLP to predict next token logits from state embedding
class PolicyNetwork(nn.Module):
    def __init__(self, state_dim, hidden_dim, action_dim):
        super(PolicyNetwork, self).__init__()
        self.embedding = nn.Embedding(VOCAB_SIZE, state_dim)  # Embed tokens
        self.fc1 = nn.Linear(state_dim * MAX_LENGTH, hidden_dim)  # Flatten sequence
        self.fc2 = nn.Linear(hidden_dim, action_dim)  # Output logits

    def forward(self, state):
        # State is list of token indices, pad to MAX_LENGTH
        state_padded = state + [0] * (MAX_LENGTH - len(state))  # Pad with 'start' index 0
        state_tensor = torch.tensor(state_padded, dtype=torch.long)
        emb = self.embedding(state_tensor).view(-1)  # Flatten embeddings
        x = F.relu(self.fc1(emb))
        logits = self.fc2(x)
        return logits

This MLP embeds the state (a sequence of token indices) into a fixed-size vector, processes it through a hidden layer, and outputs logits over the vocabulary. Padding ensures consistent input sizes, a practical compromise for variable-length sequences.

4. Sampling Actions and Generating Episodes

We need functions to sample actions and simulate episodes.

# Function to sample action from policy
def sample_action(policy, state):
    logits = policy(state)
    probs = F.softmax(logits, dim=-1)
    action = torch.multinomial(probs, 1).item()
    return action, torch.log(probs[action])  # Action and log prob

# Generate an episode
def generate_episode(policy):
    state = [token_to_idx['start']]  # Initial state
    log_probs = []
    actions = []
    rewards = [0] * MAX_LENGTH  # Rewards are 0 until end

    for t in range(MAX_LENGTH):
        action, log_prob = sample_action(policy, state)
        actions.append(action)
        log_probs.append(log_prob)
        state.append(action)  # Update state

        if idx_to_token[action] == 'end':
            break  # Early termination if 'end' is chosen

    # Compute reward at end
    sentence = [idx_to_token[idx] for idx in state[1:]]  # Exclude start
    reward = compute_reward(sentence)
    rewards[-1] = reward  # Assign to last step

    return state, actions, log_probs, rewards

sample_action uses the policy to select the next token probabilistically, returning the action and its log probability for gradient computation. generate_episode builds a sentence step-by-step, assigning a reward at the end based on the full sequence.

5. Computing Discounted Returns

Rewards are discounted to prioritize immediate gains.

# Compute discounted returns
def compute_returns(rewards):
    returns = []
    G = 0
    for r in reversed(rewards):
        G = r + GAMMA * G
        returns.insert(0, G)
    return returns

6. Training the Policy

The training loop optimizes the policy using REINFORCE.

# Training loop
def train():
    policy = PolicyNetwork(STATE_DIM, HIDDEN_DIM, VOCAB_SIZE)
    optimizer = optim.Adam(policy.parameters(), lr=LR)

    for episode in range(EPISODES):
        state, actions, log_probs, rewards = generate_episode(policy)
        returns = compute_returns(rewards)
        returns = torch.tensor(returns, dtype=torch.float32)

        # Policy gradient loss: -sum(log_prob * return)
        loss = 0
        for log_prob, G in zip(log_probs, returns):
            loss += -log_prob * G

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        if episode % 100 == 0:
            sentence = ' '.join([idx_to_token[idx] for idx in state[1:]])
            print(f"Episode {episode}: Sentence: '{sentence}' | Reward: {rewards[-1]}")

    return policy

This loop generates episodes, computes losses based on log probabilities and returns, and updates the policy. It prints progress every 100 episodes.

7. Running the Script

Finally, we execute the training and test the policy.

# Run training
if __name__ == "__main__":
    trained_policy = train()

    # Test: Generate a sample sentence after training
    print("\nGenerating a sample sentence with trained policy:")
    state = [token_to_idx['start']]
    for _ in range(MAX_LENGTH):
        action, _ = sample_action(trained_policy, state)
        state.append(action)
        if idx_to_token[action] == 'end':
            break
    sentence = ' '.join([idx_to_token[idx] for idx in state[1:]])
    print(f"Sample: '{sentence}' | Reward: {compute_reward([idx_to_token[idx] for idx in state[1:]])}")

Running and Results

Save the code as rlhf.py and run it with python3 rlhf.py. On an M4 Mac, you might see output like:

Episode 0: Sentence: 'failure sad runs start sad' | Reward: -1.0
Episode 100: Sentence: 'happy happy is happy is' | Reward: 1.0
Episode 900: Sentence: 'happy happy happy happy happy' | Reward: 1.0
Generating a sample sentence with trained policy:
Sample: 'is happy happy happy happy' | Reward: 1.0

The policy learns to favor “happy”-rich sentences, reflecting the reward function’s influence. This convergence validates the MDP and RLHF setup.

My Conclusion:

This project offers a glimpse into RLHF’s mechanics within an MDP framework, bridging Markov processes and LLM training. Experiment with the code, tweak the reward function, and share your findings!


메타데이터
post_id
cd44c874334b
slug
building-a-toy-rlhf-system-with-markov-processes-a-hands-on-guide-cd44c874334b
url
https://medium.com/@5ivatej/building-a-toy-rlhf-system-with-markov-processes-a-hands-on-guide-cd44c874334b
canonical_url
https://medium.com/@5ivatej/building-a-toy-rlhf-system-with-markov-processes-a-hands-on-guide-cd44c874334b
author_url
https://medium.com/@5ivatej
status
ok
fetched_at
2026-06-12 07:40:50