Timeform Pace Maps
If you subscribe to Timeform, you will most likely be familiar with its Flat-Racing Pace-Maps (see the first image below, horse names blurred out). The map aims to reflect the most likely early position scenarios for a specific race.
🔍 NOTE: The pace map and the Monte-Carlo simulations model early race positions (typically after two furlongs), not finishing positions. They help predict how the race will unfold tactically, not which horse will ultimately win.
Timeform Racecard Pace Map
Pace Map EPF Probability Analyzer
I confess that converting colour gradations into probability estimates is not my strong suit, but I wondered if my neighbourhood Large Language Model (LLM) might have such expertise.
Early Position Figures (EPFs) indicate where a horse is likely to be positioned in the early stages of a race. In Timeform's system, EPF 1 represents a horse that leads/front-runner, while higher numbers (up to 9) indicate horses that will be positioned further back in the field.
We can analyze these pace maps, as follows:
-
Image Encoding and Preparation:
The pace map, typically provided as an image (screenshot), is first converted into a base64-encoded string suitable for analysis.
def encode_image_to_base64(image_path):
"""Convert image to base64 string for API submission"""
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
...
def prepare_image(state: dict) -> dict:
"""Prepare the image for API submission"""
new_state = state.copy()
try:
base64_image = encode_image_to_base64(new_state['image_path'])
new_state['base64_image'] = base64_image
print(f"Successfully encoded image for API submission")
except Exception as e:
print(f"Error encoding image: {str(e)}")
raise
return new_state
-
LLM-based Image Analysis:
Utilising a state-of-the-art Large Language Model (LLM) from an external API, the analyser decodes the graphical data. It identifies the predicted positions (marked with black dots) and interprets the colour intensities (shades of red) as probability distributions for each horse's Early Position Figure (EPF). Darker shades of red indicate higher probability, while lighter shades represent lower probability of a horse taking that position.
def analyze_with_llm(state: dict) -> dict:
"""Send the image to LLM API for analysis"""
new_state = state.copy()
client = llm.LLM(api_key=LLM_API_KEY)
# Prepare the system prompt
system_prompt = """
You are an expert in analyzing horse racing pace maps. You will analyze the uploaded "Pace Map" image and extract Early Position Figure (EPF) data.
For each horse, identify:
1. The predicted EPF position (where the black dot is located)
2. The probability distribution (from color intensity)
Convert this data into parameters for a triangular probability distribution with:
- EPFProbMin: the minimum probability estimate based on color intensity
- EPFProbMode: the peak probability at the predicted position (black dot)
- EPFProbMax: the maximum probability estimate
Return results in CSV format with header: Horse;EPF;EPFProbMin;EPFProbMode;EPFProbMax
Only include the CSV data in your response, no additional text or explanations.
"""
# Prepare the user prompt
user_prompt = "Using the attached 'Pace Map' for the horse race, analyze each horse's Early Position Figure (EPF) data and convert it into parameters for a triangular probability distribution. The black dots indicate predicted positions, while the heat map colors show probability densities. Please output only the CSV data with the following fields: Horse;EPF;EPFProbMin;EPFProbMode;EPFProbMax"
try:
# Create the message with the image
response = client.messages.create(
model="llm-3",
system=system_prompt,
max_tokens=6144, # 4096,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": new_state['base64_image']
}
},
{
"type": "text",
"text": user_prompt
}
]
}
]
)
new_state['llm_response'] = response.content[0].text
print("Successfully received analysis from LLM API")
except Exception as e:
print(f"Error calling LLM API: {str(e)}")
raise
return new_state
- Probability Distribution Generation:
The program then translates the graphical data into parameters suitable for a triangular probability distribution. A triangular distribution is a simple probability model that uses three points - minimum, maximum, and most likely (mode) - making it ideal for this kind of analysis where we have limited information but can estimate these three key values. For each horse, it identifies:- EPFProbMin: the minimum likely probability,
- EPFProbMode: the peak probability (indicated by the black dot),
- EPFProbMax: the maximum likely probability.
def process_llm_response(state: dict) -> dict:
"""Extract and process CSV data from LLM's response"""
new_state = state.copy()
try:
# Extract just the CSV data (removing any markdown formatting)
response_text = new_state['llm_response']
# Handle potential markdown code blocks
if "```" in response_text:
csv_data = response_text.split("```")[1]
if csv_data.startswith("csv"):
csv_data = csv_data[3:].strip()
else:
csv_data = response_text.strip()
# Parse CSV data into a DataFrame
df = pd.read_csv(io.StringIO(csv_data), sep=';')
new_state['epf_results'] = df
print(f"Successfully processed data for {len(df)} horses")
print("\nPreview of parsed data:")
print(tabulate(df.head(11), headers='keys', tablefmt='psql', showindex=False))
except Exception as e:
print(f"Error processing LLM response: {str(e)}")
raise
return new_state
-
Structured Data Output:
The final insights are presented in a clear CSV format, enabling easy integration with further analysis or betting models. The second image below shows this structured output, with each horse's EPF and associated probability parameters clearly displayed. -
Data Preparation for Simulation:
Once we have the structured EPF data generated previously (minimum, mode, and maximum probabilities for each horse's predicted early position), we can load and prepare this data for a Monte Carlo simulation. This bridges our pace map analysis to a more dynamic model of race positioning.
def prepare_simulation_data(state: dict) -> dict:
""" Prepare data for Monte Carlo simulation by organizing horse data. """
new_state = state.copy()
df = new_state['csv_results']
# Create a data structure for each horse with its parameters
horses_data = []
for _, row in df.iterrows():
horse_data = {
'Horse': row['Horse'],
'EPF': row['EPF'],
'EPFProbMin': row['EPFProbMin'],
'EPFProbMode': row['EPFProbMode'],
'EPFProbMax': row['EPFProbMax']
}
horses_data.append(horse_data)
new_state['horses_data'] = horses_data
print(f"Prepared simulation data for {len(horses_data)} horses.")
return new_state
- Executing the Simulation:
Each horse's early position is simulated numerous times (e.g., 100,000 iterations) using a triangular probability distribution. Each iteration introduces slight random variations, reflecting real-world uncertainties in how races unfold.
def run_monte_carlo_simulation(state: dict) -> dict:
""" Run Monte Carlo simulation to predict early position running order. """
new_state = state.copy()
horses_data = new_state['horses_data']
num_simulations = new_state.get('num_simulations', 10000)
# Storage for all simulation results
all_orders = []
for sim in range(num_simulations):
horse_positions = []
for horse in horses_data:
# Sample from triangular distribution to get certainty level
certainty = np.random.triangular(
horse['EPFProbMin'],
horse['EPFProbMode'],
horse['EPFProbMax']
)
# Calculate maximum possible variation based on certainty
# Higher certainty = less variation
max_variation = 3.0 * (1.0 - certainty)
# Generate random variation within the max range
variation = np.random.uniform(-max_variation, max_variation)
# Calculate realized position
realized_position = horse['EPF'] + variation
horse_positions.append({
'Horse': horse['Horse'],
'EPF': horse['EPF'],
'RealizedPosition': realized_position
})
# Sort horses by realized position (ascending)
sorted_horses = sorted(horse_positions, key=lambda x: x['RealizedPosition'])
# Extract the running order
running_order = [horse['Horse'] for horse in sorted_horses]
all_orders.append(running_order)
new_state['simulation_results'] = all_orders
print(f"Completed {num_simulations} Monte Carlo simulations.")
return new_state
- Analyzing Simulation Results:
- All simulated outcomes are aggregated, identifying the most frequent early running orders.
- The method calculates the probability of each horse occupying each possible early position, delivering clear insights into each horse's likely early placement.
def analyze_simulation_results(state: dict) -> dict:
""" Analyze the results of Monte Carlo simulations. """
new_state = state.copy()
all_orders = new_state['simulation_results']
# Count frequency of each running order
order_counter = Counter(tuple(order) for order in all_orders)
total_simulations = len(all_orders)
# Convert to probability and sort by frequency
order_probabilities = [
{
'Running Order': order,
'Count': count,
'Probability': count / total_simulations
}
for order, count in order_counter.items()
]
# Sort by probability (descending)
order_probabilities.sort(key=lambda x: x['Probability'], reverse=True)
# Take top N most likely running orders
top_n = min(10, len(order_probabilities))
top_orders = order_probabilities[:top_n]
# Also analyze position probabilities for each horse
horse_positions = {horse['Horse']: [0] * len(new_state['horses_data']) for horse in new_state['horses_data']}
for order in all_orders:
for position, horse in enumerate(order):
horse_positions[horse][position] += 1
# Convert to probabilities
for horse in horse_positions:
for position in range(len(horse_positions[horse])):
horse_positions[horse][position] /= total_simulations
# Store results
new_state['top_running_orders'] = top_orders
new_state['horse_position_probabilities'] = horse_positions
print(f"Analyzed simulation results. Found {len(order_probabilities)} unique running orders.")
return new_state
- Early Position Probabilities Ultimately, we display the detailed position probabilities for each horse in a comprehensive table, as shown in the third image below. The green highlighted values indicate the most likely position for each horse. For example, horse "India" has a 0.757 (75.7%) probability of taking the first position (EP-1), while "Golf" has a 0.4386 (43.86%) probability of starting in position 9 (EP-9).
Practical Applications
These probability distributions can be extremely valuable for handicappers and bettors who consider race dynamics in their analysis. For instance:
- Identifying potential pace scenarios to spot races likely to favor frontrunners or closers
- Finding horses that might be disadvantaged by their running style given the expected pace
- Looking for overlays where a horse's tactical position might give it an advantage not fully reflected in its odds
- Constructing exotic wagers (exactas, trifectas) based on likely early running positions
As ever, the code snippets are only a starting point for your own explorations. Tread carefully.
Enjoy!
Note: The final draft of this post was sanity checked by ChatGPT.