Is gymflation real?

Investigating with R and LLMs

Chris Bowdon

2026-07-22

Blog post: https://cbowdon.github.io/posts/gymflation

Code and data: https://github.com/cbowdon/cbowdon.github.io

About me

  • CTO at Polecat Intelligence (www.polecat.com)
  • We use AI to extract structured reputation risk insights at scale from online media
  • Gymflation is nothing to do with Polecat - it’s a personal interest - but we use the techniques here regularly

Premise

Social media is increasingly distorting perceived strength standards.

Plan

How can we investigate this?

Measure if feats of strength reported in online videos are increasing.

Taking a narrow definition of strength: deadlift personal record (PR) videos on YouTube.

  1. Is the average reported deadlift PR weight increasing over time?
  2. Are the number of reported heavy deadlift PRs increasing over time?

Steps

  • Scrape the YouTube API for 10 years of deadlift PR videos
  • Use structured responses to extract the lifted weight with an LLM
  • Evaluate the quality of our extraction and improve it with In-Context Learning (ICL)
  • Use a multimodal model to judge the lifter’s gender
  • Plot and analyse our results

Data gathering

YouTube API

We can use httr2 for this.

library(httr2)

req <- request("https://youtube.googleapis.com/youtube/v3/search") |>
1  req_cache(".cache/httr2") |>
  req_url_query(
    part = "snippet",
    q = query,
    type = "video",
    maxResults = 50,
    duration = "short",
    region = "UK",
    published_after = published_after,
    published_before = published_before,
    key = YOUTUBE_API_KEY
  ) |>
  req_headers(Accept = "application/json") |>
  req_timeout(30)

2req_perform(req) |>
3  resp_body_json(simplifyVector = TRUE)
1
Client-side caching saves you from wasting time or request quota.
2
Other useful methods include req_dry_run and req_perform_parallel.
3
Getting data back as a dataframe is very convenient, so I recommend simplifyVector = TRUE.

Data

id.videoId publishedAt channelTitle title description thumbnails.high.url
tWnuZ1i_JuY 2019-07-08T20:34:15Z All Out Performance [PERFORM] Beltless Deadlift PR Coach Ash hit a new beltless PR on his Deadlift during this session! Throughout the AOP Programming we focus heavily on … https://i.ytimg.com/vi/tWnuZ1i_JuY/hqdefault.jpg
dv1IFeprYlM 2022-02-15T16:40:00Z All Gains No Pains 485lbs deadlift PR https://i.ytimg.com/vi/dv1IFeprYlM/hqdefault.jpg
qIgSChVszHg 2019-02-17T08:45:15Z Matt Schaub 605 Deadlift PR at 20 years old and 205 pounds https://i.ytimg.com/vi/qIgSChVszHg/hqdefault.jpg
awfZk6d3wM0 2024-02-26T17:09:11Z Grzegorz Heinze deadlift PR 363,5kg bw120 21y.o. https://i.ytimg.com/vi/awfZk6d3wM0/hqdefault.jpg
53YV4xy7tkE 2016-11-19T07:56:30Z The SideStep 500lb Deadlift PR https://i.ytimg.com/vi/53YV4xy7tkE/hqdefault.jpg
Table 1: Sample of the raw data from the YouTube API.

Regular expressions wouldn’t be sufficient here…

Model 1: fact extraction

Using an LLM to extract the weights from the messy text.

Structured responses

library(ellmer)

1LLM_SERVER_URL <- "http://localhost:8080/v1"

system.prompt <- "The user will provide the title and description of a YouTube video. If they describe a weight being deadlifted, you must extract the weight that was deadlifted, with the unit, and the number of repetitions.

Note that the deadlift world record is currently 510kg/1122lb.
Return only a JSON object with keys:

- `weight` (numeric)
- `unit` (kg, lb or NA)
- `reps` (numeric, assume 1 if not provided). 

Do not perform unit conversion.
Return NA as the unit if the description doesn't mention a weight deadlifted or the unit isn't clear."

extract_dl_weight_model <- function() {
  chat_openai_compatible(
    LLM_SERVER_URL,
2    model = "mistralai/ministral-3-3b",
    credentials = function() "", # not used - local model
    params = params(max_tokens = 50), # don't generate more than we need
    system_prompt = system.prompt
  )
}

extract.dl.weight.type <- type_object(
  weight = type_number("The weight deadlifted, as a number."),
  unit = type_enum(values = c("kg", "lb", "NA")),
  reps = type_integer("Number of repetitions (1 if unspecified)")
3)
1
Local caching proxy
2
A small open source model, running on my laptop via LM Studio
3
The response structure definition

Example structured response

extract_dl_weight_model()$chat_structured(
  "Title: 665 lbs deadlift at 188 lbs bodyweight #deadlift #pr #powerlifting #powerlifter #usapl #ipf #shortsfeed\nDescription: ",
  type = extract.dl.weight.type # provide schema
) |>
  as_tibble() |>
  kable()
weight unit reps
665 lb 1

Evaluating our model

  • AI is jagged intelligence: screws up in unexpected ways
  • Need to measure its performance
  • Build high-quality test set and score accuracy (dev + test, manually annotated)
  • This model so far: 73% accurate in its extractions
  • Let’s open the model optimisation toolbox!

In-Context Learning (ICL)

A.k.a. few-shot learning, you condition the LLM on example inputs and outputs.

extract_dl_weight_model <- function(turns = list()) {
  chat <- chat_openai_compatible(
    LLM_SERVER_URL,
    model = "mistralai/ministral-3-3b",
    credentials = function() "",
    params = params(max_tokens = 50),
    system_prompt = system.prompt
  )
1  chat$set_turns(turns)
  chat
}

2
example_turn_pair <- function(title, description, weight, unit, reps) {
  list(
    UserTurn(list(ContentText(
      interpolate("Title: {{title}}\nDescription: {{description}}")
    ))),
    AssistantTurn(list(ContentText(
      interpolate(
        "{\"weight\":{{weight}}, \"unit\":\"{{unit}}\", \"reps\":{{reps}}}"
      )
    )))
  )
}
1
Seed chat with example turns
2
Helper function to format the turns
turns <- c(
  example_turn_pair(
3    title = "deadlift PR 220 @ 165 bodyweight",
    description = "hit this at my meet",
    weight = 220,
    unit = "NA",
    reps = 1
  ),
  example_turn_pair(
    title = "NEW PR 585",
    description = "new deadlift personal record",
    weight = 585,
    unit = "NA",
    reps = 1
  ),
  example_turn_pair(
    title = "NEW PR 330x3",
    description = "",
    weight = 330,
    unit = "NA",
    reps = 3
  ),
  example_turn_pair(
    title = "180kg x 3 deadlift PR",
    description = "81kg 18 years old.",
    weight = 180,
    unit = "kg",
    reps = 3
  ),
  example_turn_pair(
    title = "100kg/220lb deadlift PR",
    description = "smashed it",
    weight = 100,
    unit = "kg",
    reps = 1
  ),
  example_turn_pair(
    title = "365 lb Deadlift PR @ 165 lbs",
    description = "Christal hitting a new deadlift PR @ 365 lbs, beltless. And before anyone says anything about her form, her deadlift PRs have ...",
    weight = 365,
    unit = "lb",
    reps = 1
  )
)
3
Hand-crafted examples
  • New result: 92% extraction accuracy on test.

Plot of deadlift PR weights over time

Figure 1: Deadlift PRs reported by YouTube users over time don’t show any change. The red line is world record lifts; values above this should be viewed with suspicion. The dashed horizontal lines are round-number Imperial weights (100-1000lbs), where we expect to see some clumping.

Model 2: image classification

Male and female lifters have different strength distributions. Changes in one could mask changes in the other.

Image classification model

gender.classifier.prompt <- "The user will provide a YouTube video thumbnail of a deadlift PR. You must predict the gender of the person lifting the weight using the image and the video metadata (channel and title).
  Respond with `M` if the person appears male or `F` if the person appears female. If no real human is visible enough or if there are multiple people lifting the weight, respond with `NA`.
  Return your answer in a JSON object with a single key `gender` with value `M`, `F` or `NA`."

predict_gender_chat <- function(model = "mistralai/ministral-3-3b") {
1
  chat_openai_compatible(
    LLM_SERVER_URL,
    model = model,
    credentials = function() "",
    params = params(max_tokens = 1000),
    system_prompt = gender.classifier.prompt
  )
}

predict.gender.type <- type_object(
  gender = type_enum(values = c("M", "F", "NA"))
)

img_prompt <- function(row) {
  list(
    sprintf(
      "YouTube Channel: %s\nVideo title: %s",
      row$channelTitle,
      row$title
    ),
2    content_image_file(row$image.path)
  )
}
1
Ministral 3 3B is capable of understanding images too.
2
We can provide both text and an image file in a single turn.

Example usage

prompt <- img_prompt(df.weights.imgs[1, ])

chat <- predict_gender_chat()

chat$chat_structured(
  prompt[[1]],
  prompt[[2]],
  type = predict.gender.type
)

# { "gender": "M" }

Evaluating our image model

  • Another cycle of dataset annotation, evaluation and optimisation.
  • This time I’ve vibe-coded an annotation UI.

Second look at the data

Figure 2: The distribution of deadlift 1RM weights by gender - clearly different.
Figure 3: Female lifters are still the minority, but have arguably improved relative to male lifters.

Summing up

We looked at:

  • Some tricks for scraping data from the YouTube API with httr2
  • Using structured responses with Ellmer to extract data with LLMs
  • How to use ICL to improve our LLM extraction model
  • How to do image classification with VLMs
  • Building an evaluation set with a vibe-coded annotator UI
  • Finding a better model on OpenRouter
  • I hope you weren’t too invested in the gymflation idea itself

Blog post with all the code and data: https://cbowdon.github.io/posts/gymflation