Is gymflation real?

Investigating with R and LLMs

Chris Bowdon

2026-07-22

About me

By day I am CTO at Polecat Intelligence, where we use AI to extract structured reputation risk insights from online media. By night I fool around with more or less the same stuff, for free.

Gymflation is nothing to do with Polecat, but we use the techniques here regularly.

Premise

Social media is distorting the perceived average strength level.

What is gymflation?

  • An excuse to show you some LLM techniques.
  • A phenomenon connected to social media’s negative effects on self-image, which is increasingly leading to mental health issues such as body dysmorphia.
  • Mostly the former though.

Comparison to superhero inflation

“When I grow up, I want to be as strong as Batman” is less achievable now than it was in the 60s.

Batman demonstrates the closely related idea “superhero inflation”.

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.

Questions:

  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 personal record (PR) videos with httr2
  • 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
  • Make extensive use of caching so errors don’t set us back
  • Plot our results with good old ggplot2

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
) |>
  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) 1
  • This model so far: 73% accurate in its extractions
  • Let’s open the model optimisation toolbox!

In-Context Learning (ICL)

extract_dl_weight_model <- function(turns = list()) {
  chat <- chat_openai_compatible(
    LLM_SERVER_URL,
    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
  )
  chat$set_turns(turns)
  chat
}

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}}}"
      )
    )))
  )
}

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

Here I’ve hand-crafted some examples based on dev failures.

New result: 92% extraction accuracy on test.

turns <- c(
  example_turn_pair(
    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
  )
)

First look at the data

Figure 1: Deadlift PRs reported by YouTube users over time. World record lifts are points overlaid in orange. The dashed horizontal lines are round-number Imperial weights (100-1000lbs).

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
)
#chat$get_tokens()

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