About Projects Email

Using Quantum Mechanics to Train for a Marathon

(Two Problems & One Really Bad Solution)

This is the story of a very bad plan and a commitment to follow it to the bitter end. It starts with two problems in my life.

Problem #1: I only barely understand quantum mechanics

My understanding of quantum mechanics is pretty much limited to factoids from half-remembered documentaries, a few frown-filled conversations with people much cleverer than me, and jokes from Dirk Gently’s Holistic Detective Agency. However, I recently learned that:

  1. There’s a Swiss company called ID Quantique, and
  2. they make a hardware device that generates random numbers
  3. by shooting photons at a partially silvered mirror
  4. … the outcome of which is, I dunno, a string of genuinely quantum bits or whatever, in the sense that they’re genuinely random and non-deterministic.

And also:

  1. If you’re a fan of the many-worlds interpretation of quantum mechanics, each of those bits represents a different universe — or, if you’re being all poetic about it, a universe-splitting event.

Problem #2: I want to train for a marathon, but I get bored easily

Running long distances hurts my body in a way that seems, mostly, good and fun. Normal training programs, though, can get a little repetitive.

Here is the sum total of things that I (pretty much) understand about running long distances and myself:

  1. Running kinda sucks and it’s hard.
  2. I like that it’s hard.
  3. I get bored pretty easily, which is a problem when you need to run regularly in order to improve.
  4. Conventional wisdom suggests that increasing your total week-over-week mileage by more than ~10% significantly increases your chances of an injury.
  5. I really like running long distances, and I’d like to do more of it.
  6. I don’t want to increase my chances of an injury.

Solution: Train Differently in Every Universe

Given the problem of monotonous training and a poor understanding of quantum mechanics, here’s what I’m going to do: generate a weekly training plan that guarantees a safe, gradual increase in total week-over-week distance and varies according to bits generated by a Quantis brand quantum device. Provided I commit to following the training plan on pain of death and perjury, I’ll end up running a marathon distance in every universe. But I’ll be training differently for that marathon in each.

Pause, for a moment, and consider this, because it’s a tiny bit mind-blowing and a tiny bit beautiful. With a few lines of code (below) and a string of ones and zeroes generated by shooting photons at a partially silvered mirror and observing the result, I can guarantee that I’ll train for a marathon in a way that is totally unique to this universe. Run by run and week by week, the training of Current Universe Me will diverge from the training of all the Other Universe(s) Me(s). Perhaps Current Universe Me is told to do 200m sprints, while Universe B’ Me gets given a fartlek workout and Universe C’ Me goes long and slow. Universe D’ Me is a sucker, I imagine, and gets lumped with a hill climb. All of us get fitter, run further, train harder, and eventually hit a marathon distance. And yet, thanks to a chunk of quantum noise, all of us train in a way that is unique to whatever universe we’re in.

I’m gonna do this in Julia, a scientific programming language that I barely understand. All the code is available on github and discussed below. However, if you’d like to skip down and just look my final universe-specific training plan, you can.

Generating a Training Plan

Let’s say you’ve got a sample .dat file filled with a tonne of those sweet, sweet quantum bits. The first thing you’ll do is read it into memory and reinterpret it as an array of Int8 numbers:

function read_noise_as_int(path="./noise.dat")
    open(path) do f
        raw_data = read(f)
        as_int8 = reinterpret(Int8, raw_data)

You’re also going to define a function to add some extra noise to that array of 8-bit integers by shortening it a (pseudo-)random amount:

function alter_noise_array(noise_array::Array)
    n = 10000 + rand(1:10000) # the pseudorandom number
    new_noise = noise_array[n:length(noise_array)]

This isn’t, y’know, quantum randomness in the way that the original array is, but ensures that running the final program will output a different training program most times, even in this same universe. Anything else feels somehow, well, even more confusing.

We’ll return to this big ol’ chunk of 8-bit quantum in a second. First, let’s talk about the training itself.

Ultimately, what I’m looking for is the ability to run a function like plan_my_runs() and have it output a list of workouts in neat, omnifocus-readable taskpaper format. I want to be able to pass this build_plan() function my current_weekly_total_distance, my n_workouts_per_week, and my goal_distance — for example, build_plan(20, 4, 42.2) — and have it return a list of workouts specific to this universe. Ideally, it would also have recovery day tasks sprinkled in. Like do some yoga’ and eat a donut’. I may be getting ahead of myself.

The first stage of this process is converting a week’s numbers (total distance and number of workouts) to a long array of daily distances. We’ll do that in a few steps:

In the first step, we’ll calculate the week-over-week distance totals increasing at a given rate:

function calculate_week_totals(current_total, max_total, rate)
    while distances[length(distances)] < max_total
        new_distance = distances[length(distances)] * rate
        push!(distances, new_distance)

With weekly distances in hand, we can define a function to convert a given weekly distance to a set of daily workouts:

function convert_to_daily(week_distance, runs_per_week)
    daily_distance = week_distance / runs_per_week
    daily_workouts = fill(daily_distance, runs_per_week)

And then wrap those two functions in a tiny package that will collect a flat array of daily workout distances for the whole period:

function generate_daily_kms(current_km, n_runs, goal, rate=1.02)
    max_week_kms = goal * n_runs
    weeks = calculate_week_totals(current_km, max_week_kms, rate)
    day_distances = [convert_to_daily(x, n_runs) for x in weeks]
    flat_distances = collect(Iterators.flatten(day_distances))

Now let’s think about how a given workout distance might be varied. If the workout is too much longer or shorter, we’re screwed in an obvious way: the week-over-week distance increase gets messy and potentially quite exhausting. If the workout varies too little, we’ll fail to diverge from the other worlds in any substantial way. While there’s less of a chance of an IT Band injury with this one, it’s still catastrophic. I’ve settled on the following for handling each number in the Int8 noise array:

function create_multiplier(noise::Int8)
    noise_multiplier = 1 + (noise/256)/4

Now, we can look at adding noise to our raw array of daily distances:

function add_noise_to_distance_array(distances, noise)
    noisy_distances = []
    for i in 1:length(distances)
        new_distance = distances[i] * create_multiplier(noise[i])
        push!(noisy_distances, new_distance)

Because the quantum randomness may have caused us to reach our final goal a little earlier, we also need to slice the new array of daily distances so that we increase, hit the final distance goal, and then stop:

function slice_distance_array_to_goal(distances, goal_distance)
    indexes = find(distances .< goal_distance)
    new_distances = vcat(distances[indexes], [goal_distance])

At this point, we’ve got something pretty bizarre: an array of distances for daily runs, varied according to the action of photons. Unfortunately, it’s not quite enough to go on. It’s going to suck if every workout instruction simply reads Go for a run: [n] kilometers’. That’s boring, and also a pretty bad way of training. Instead, we’re aiming for a mix of High-Intensity Interval Training, tempo runs, hill training, and similar.

First, let’s think about turning those distances into a set number of repeats for 200m, 400m, and 800m sprint workouts:

function distance_to_sprints(distance_km, sprint_length_m)
    denom = sprint_length_m / 75 # longer sprints have fewer reps
    n_sprints = ((distance_km*1000) / denom) / sprint_length_m

This is, by all accounts, an ugly but effective way of estimating sprint repeats. But it works. We’ll cap the range of repeats for each normal distance and call it a day.

God I hate sprints.

Let’s think about building a dataframe for common workout types from raw distances:

function build_workout_dataframe(distances)
    # Create workout arrays:
    sprints_200m = [distance_to_200m(x) for x in distances]
    sprints_400m = [distance_to_400m(x) for x in distances]
    sprints_800m = [distance_to_800m(x) for x in distances]
    fartleks = [round((0.55*x), 1) for x in distances]
    hill_runs = [round((0.35*x), 1) for x in distances]
    long_runs = [round((1*x), 1) for x in distances]
    tempo_runs = [round((0.7*x), 1) for x in distances]

    # Build dataframe:
    df = DataFrame(raw_distance = distances,
                   sprints_200m = sprints_200m,
                   sprints_400m = sprints_400m,
                   sprints_800m = sprints_800m,
                   fartlek = fartleks,
                   hill_run = hill_runs,
                   long_run = long_runs,
                   tempo = tempo_runs)


We choose randomly among these workouts for each given day, making sure that the final workout of each week is a long run. We also weight workouts longer distances’ come with an increased proportion of sprints, hill climbs, and other nastiness:

function get_workout_options(distance)
    hard_w = ["hill_run",
    easy_w = ["fartlek",
    if distance < 10
        options = easy_w
        options = vcat(repeat(easy_w, outer=4),
                       repeat(hard_w, outer=Int(div(distance,10))))

function choose_workout_plan(workout_df, n_workouts)
    plan_df = DataFrame(workout_type = String[],
                        workout_n = Float64[])
    for i in 1:size(workout_df,1)
        if i == (length(workout_df[1])-1)
            workout = ["sprints_200m", workout_df[i,:sprints_200m]]
            push!(plan_df, workout)
        elseif i % n_workouts == 0 || i == length(workout_df[1])
            workout = ["long_run", workout_df[i,:long_run]]
            push!(plan_df, workout)
            distance = workout_df[i,:raw_distance]
            workout_options = get_workout_options(distance)
            n = rand(1:length(workout_options))
            chosen_workout = workout_options[n]
            workout_n = workout_df[i, Symbol(chosen_workout)]
            workout = [chosen_workout, workout_n]
            push!(plan_df, workout)

This is pretty much the end of the quantum_coach workflow. Package each workout into a taskpaper format, sprinkle recovery and cross-training into the off-days, and we’re done!

The Current Plan

As of 2018-07-21, I’m committed to a 814 day training plan generated using quantum_coach.jl.

I’m using beeminder to help increase the chances that I’ll run as planned. Honestly, though, I rarely need it, and this cuts to the core of what I love about this quantum training plan. On the one hand, yes, the training plan is strange and silly and inefficient. On the other hand, thanks to some deep psychological brokenness or cognitive bias, I feel a solidarity with my Other World Selves. I feel a duty to train because those other selves are training. Every time I run, my life diverges from those other selves. Yet, somehow, paradoxically, it diverges in the same direction. Thanks to the behaviour of photons, we suffer differently and together.

[Note: This post describes a version of the code that, while certainly workable, I now (as of 2018-09-03) regard as pretty inelegant. The current github page for QuantumCoach.jl reflects a more interesting solution that maintains the quantum randomness but favours day-by-day generation of the workout plan over building a huge dataframe and then selecting from it’.]


-<< Next    ·    Past >>-