# Data preparation is documented in kohli_odi_all.R. This chunk adds first-innings
# totals (to get chase targets) once, then caches the result.
inn_file <- here("data/clean/kohli_innings_enriched.csv")
if (!file.exists(inn_file)) {
inn0 <- read_csv(here("data/clean/kohli_innings.csv"),
col_types = cols(match_id = col_character()), show_col_types = FALSE)
balls <- readRDS(here("data/raw/all_odi_balls.rds"))
totals <- balls |> filter(innings == 1) |> group_by(match_id) |>
summarise(inn1_total = sum(runs_off_bat + extras, na.rm = TRUE))
inn0 |> left_join(totals, by = "match_id") |> write_csv(inn_file)
}
inn <- read_csv(inn_file, col_types = cols(match_id = col_character()),
show_col_types = FALSE) |>
mutate(
situation = factor(if_else(chasing, "Chasing", "Batting first"),
levels = c("Batting first", "Chasing")),
target = if_else(chasing, inn1_total + 1, NA_real_),
status = if_else(out, "Dismissed", "Not out"),
result = case_when(is.na(winner) ~ "No result / tie",
winner == batting_team ~ "Won", TRUE ~ "Lost"),
era = cut(year, c(2007, 2012, 2017, 2022, 2030),
labels = c("2008-12", "2013-17", "2018-22", "2023+")),
tip = paste0(format(start_date, "%d %b %Y"), " v ", bowling_team, "<br>",
runs, " off ", balls_faced, " balls (", status, ")<br>",
venue, "<br>Result: ", result)
)
summ <- function(d) d |>
summarise(n = n(), dismissals = sum(out), balls = sum(balls_faced),
total_runs = sum(runs),
average = total_runs / dismissals,
not_out_pct = 100 * mean(!out),
fifty_pct = 100 * mean(runs >= 50),
dismissals_per_100 = 100 * dismissals / balls, .groups = "drop")
I come from the same part of Delhi as Virat Kohli, and I have followed him since he first played for India. The innings I remember best come from India’s 2018 tour of South Africa, where he scored three hundreds in six innings and seemed to carry the team almost on his own. Later he set a world record with his 50th ODI hundred. These memories shaped what I believed about him, and this report asks whether the data supports it.
The standing that Virat Kohli enjoys as a chaser rests upon a single statistic. When one examines the 303 One Day International innings contained within this dataset, the picture that emerges is as follows:
cat('<div class="stats">',
card(r1(f$average), "batting average when batting first", pal[["Batting first"]], "#FFFFFF"),
card(r1(c_$average), "batting average when chasing", pal[["Chasing"]], "#000000"),
card(paste0("+", r1(c_$average - f$average)), "runs: the gap this story examines", "#EEEEEE", "#111111"),
'</div>', sep = "\n")
Of what, precisely, is that gap actually composed, and to what extent can it legitimately be relied upon? If you position your cursor over any point or line, further detail will be displayed; thereafter, you may employ the dropdown menu in chart 3 together with the filters in chart 5 in order to verify the claim for yourself.
# The two innings that anchor the story, found by date and checked against the data
story_dates <- as.Date(c("2013-10-16", "2023-11-15"))
inn_h <- inn |> arrange(start_date) |> mutate(n_h = cumsum(runs >= 100))
fastest_balls <- min(inn$balls_faced[inn$runs >= 100])
story <- inn_h |> filter(start_date %in% story_dates) |>
mutate(fastest = runs >= 100 & balls_faced == fastest_balls,
lab = paste0(runs, if_else(out, "", "*"), " off ", balls_faced, " balls\n",
if_else(fastest, "Fastest ODI hundred, ", ""),
if_else(chasing, paste0("chasing ", target, "\n"), "batting first\n"),
if_else(n_h == 50 & runs >= 100, "50th ODI hundred\n", ""),
"v ", bowling_team),
hj = if_else(start_date < as.Date("2018-01-01"), 0.2, 0.8))
if (nrow(story) != 2) stop("Expected two story innings; check the dates against the data.")
s13 <- story[1, ]; s23 <- story[2, ]
p1 <- ggplot(inn, aes(start_date, runs, colour = situation, shape = status,
size = balls_faced, text = tip)) +
geom_point(alpha = 0.75) +
geom_point(data = story, aes(x = start_date, y = runs), inherit.aes = FALSE,
shape = 21, size = 7, fill = NA, colour = "black", stroke = 1) +
geom_text(data = story, aes(x = start_date, y = runs, label = lab, hjust = hj),
inherit.aes = FALSE, vjust = 0, nudge_y = 10, size = 3.2,
colour = "grey15", lineheight = 0.95) +
scale_colour_manual(values = pal) +
scale_shape_manual(values = c("Dismissed" = 16, "Not out" = 1)) +
scale_size_continuous(range = c(1.5, 5)) +
scale_y_continuous(expand = expansion(mult = c(0.02, 0.15))) +
labs(title = paste0("Every ODI innings, ", min(inn$year), "-", max(inn$year),
": chases more often end not out"),
x = NULL, y = "Runs scored", colour = "Situation", shape = "Outcome",
size = "Balls faced") +
theme_minimal(base_size = 12)
avg_lab <- paste0("Career average ", r1(career_avg))
p1w <- ggplotly(p1, tooltip = "text")
# Tidy the legend labels ggplotly builds from combined aesthetics, e.g. "(Chasing,Not out)"
for (i in seq_along(p1w$x$data)) {
nm <- p1w$x$data[[i]]$name
if (!is.null(nm)) p1w$x$data[[i]]$name <- sub(",", ", ", gsub("^\\(|\\)$", "", nm))
}
p1w |>
# Drawn as its own trace so it sits on top of the points, and appears in the legend
add_lines(x = as.numeric(range(inn$start_date)), y = career_avg, inherit = FALSE,
name = avg_lab, line = list(dash = "dash", color = "#222222", width = 2),
text = avg_lab, hoverinfo = "text") |>
add_annotations(x = as.numeric(max(inn$start_date)), y = career_avg, text = avg_lab,
xanchor = "right", yanchor = "bottom", showarrow = FALSE,
bgcolor = "rgba(255,255,255,0.85)", font = list(size = 12))
Data: Cricsheet (n.d.). Each point is one innings. Hollow points are not-out innings. The dashed line is his career average, and the two circled innings are described below. Hover for opponent, venue and result. Text description: a scatter of 303 innings from 2008 to 2026, coloured by situation, with the two circled innings labelled.
Within this dataset, two innings reveal contrasting facets of his batting. In the match dated 16 October 2013, he scored 100 not out from 52 deliveries faced against Australia, which stood as the fastest hundred of his ODI career in this data, while pursuing a target of 360. In the match dated 15 November 2023, he made 117 against New Zealand, which was his 50th ODI hundred, a world record that surpassed Tendulkar’s 49 (Guinness World Records, n.d.), at a point when the team was batting first. The first of these represents precisely the type of run chase upon which his reputation was established. The second, by contrast, does not constitute a chase in any sense.
avg <- inn |> group_by(situation) |> summ()
plot_ly() |>
add_trace(data = inn, x = ~situation, y = ~runs, color = ~situation, colors = pal,
type = "violin", box = list(visible = TRUE), points = "all",
jitter = 0.3, pointpos = 0, text = ~tip, hoverinfo = "text") |>
add_trace(data = avg, x = ~situation, y = ~average, inherit = FALSE,
type = "scatter", mode = "markers+text",
marker = list(symbol = "diamond", size = 12, color = "black"),
text = ~paste0("Average ", round(average, 1)), textposition = "middle right",
name = "Batting average", hoverinfo = "text") |>
layout(title = list(text = "Similar spread of scores, but a higher average when chasing",
x = 0.02),
xaxis = list(title = ""), yaxis = list(title = "Runs per innings"),
showlegend = FALSE)
Data: Cricsheet (n.d.). Diamonds mark the batting average (runs per dismissal). Based on 136 first-innings and 167 chasing innings. Text description: violin and box plots of runs per innings for each situation, with the average marked.
The overall pattern exhibited by his scores is comparable across both of these situations. In percentage terms, he attained a score of 50 or above in 44.3% of his chases and in 42.6% of his first innings.
# Kaplan-Meier survival: not-out innings are treated as "still batting" when they ended
inn_s <- inn |> mutate(t = pmax(balls_faced, 1))
fit <- survfit(Surv(t, out) ~ situation, data = inn_s)
lv <- levels(inn$situation)
km <- tibble(strata = rep(names(fit$strata), fit$strata), t = fit$time, surv = fit$surv) |>
mutate(situation = factor(sub("situation=", "", strata), levels = lv)) |>
select(situation, t, surv)
km <- bind_rows(tibble(situation = factor(lv, levels = lv), t = 0, surv = 1), km) |>
arrange(situation, t) |>
mutate(tip = paste0(situation, "<br>After ", t, " balls: ",
round(100 * surv), "% of innings still going"))
at <- summary(fit, times = c(50, 100), extend = TRUE)
at_tbl <- tibble(situation = factor(sub("situation=", "", as.character(at$strata)), levels = lv),
t = at$time, surv = 100 * at$surv, lo = 100 * at$lower, hi = 100 * at$upper)
pick <- function(sit, tt) at_tbl |> filter(situation == sit, t == tt)
pct <- function(sit, tt) with(pick(sit, tt), paste0(round(surv), "% (95% interval ", round(lo), "-", round(hi), "%)"))
# One survival curve pair per era, switched with a dropdown
subsets <- c("All years", levels(inn$era))
km_for <- function(d) {
d <- d |> mutate(t = pmax(balls_faced, 1))
fit <- survfit(Surv(t, out) ~ situation, data = d)
tab <- tibble(strata = rep(names(fit$strata), fit$strata), t = fit$time, surv = fit$surv) |>
mutate(situation = factor(sub("situation=", "", strata), levels = lv)) |>
select(situation, t, surv)
tab <- bind_rows(tibble(situation = factor(lv, levels = lv), t = 0, surv = 1), tab) |>
arrange(situation, t) |>
mutate(tip = paste0(situation, "<br>After ", t, " balls: ", round(100 * surv), "% still batting"))
s50 <- summary(fit, times = 50, extend = TRUE)
list(km = tab, s50 = setNames(100 * s50$surv, sub("situation=", "", as.character(s50$strata))),
n = table(d$situation))
}
sets <- map(subsets, \(sb) km_for(if (sb == "All years") inn else filter(inn, era == sb)))
set_title <- function(sb, x) paste0(sb, ": after 50 balls he is still batting in ",
round(x$s50[["Chasing"]]), "% of chases and ", round(x$s50[["Batting first"]]),
"% of first innings<br><sup>", x$n[["Chasing"]], " chases, ", x$n[["Batting first"]], " first innings</sup>")
fig3 <- plot_ly()
for (i in seq_along(subsets)) {
for (sit in lv) {
fig3 <- fig3 |> add_lines(data = filter(sets[[i]]$km, situation == sit), x = ~t, y = ~surv,
name = sit, legendgroup = sit, showlegend = (i == 1),
line = list(shape = "hv", color = pal[[sit]], width = 2.5),
visible = (i == 1), text = ~tip, hoverinfo = "text")
}
}
buttons <- map(seq_along(subsets), \(i) list(
method = "update", label = subsets[i],
args = list(list(visible = rep(seq_along(subsets) == i, each = 2)),
list(title = list(text = set_title(subsets[i], sets[[i]]), x = 0.02)))))
fig3 |> layout(
title = list(text = set_title(subsets[1], sets[[1]]), x = 0.02),
xaxis = list(title = "Balls faced", range = c(0, 160)),
yaxis = list(title = "Share of innings still going", tickformat = ".0%", range = c(0, 1)),
margin = list(t = 120),
updatemenus = list(list(type = "dropdown", buttons = buttons, x = 0.02, xanchor = "left",
y = 1.0, yanchor = "bottom", pad = list(t = 4))),
shapes = list(
list(type = "line", x0 = 50, x1 = 50, y0 = 0, y1 = 1, line = list(dash = "dot", color = "grey")),
list(type = "line", x0 = 100, x1 = 100, y0 = 0, y1 = 1, line = list(dash = "dot", color = "grey"))))
Data: Cricsheet (n.d.). Each step down is a dismissal. Not-out innings are treated as still batting when they ended. Use the dropdown to switch era; the small eras give noisy curves. Text description: two step curves fall from 100% as balls are faced, one for batting first and one for chasing, with dotted lines at 50 and 100 balls.
A batting average is calculated by dividing the number of runs scored by the number of dismissals, and since a chase concludes once the target has been reached, innings played while chasing are consequently left not out more frequently (21.6% compared with 8.1%). Were Kohli’s chasing innings to have ended not out at the same rate as his first innings, the resulting chasing average would stand at approximately 56.1, rather than the figure of 65.7. This adjustment is responsible for accounting for roughly 65% of the difference.
The survival curve constitutes a more equitable assessment, in that it accounts for not-outs in an accurate manner. Having faced 50 deliveries, he remains at the crease in 53% (95% interval 46-61%) of chases and in 49% (95% interval 41-58%) of first innings. Upon reaching 100 deliveries, the corresponding figures stand at 26% (95% interval 20-35%) and 16% (95% interval 11-24%). Since the intervals overlap, the survival curves are consistent in direction with the dismissal rates, yet they do not establish the point conclusively: he is dismissed 1.8 times per 100 balls while batting first, and 1.4 times while chasing. Consequently, the adjustment described above is most appropriately interpreted as an upper-bound estimate of the extent to which the gap merely reflects the manner in which chases conclude.
era_tbl <- inn |> group_by(era, situation) |> summ()
p4 <- ggplot(era_tbl, aes(era, average, colour = situation, group = situation)) +
geom_line(linewidth = 0.8) +
geom_point(aes(size = n,
text = paste0(situation, ", ", era, "<br>Average: ",
round(average, 1), "<br>Innings: ", n))) +
geom_text(aes(label = round(average)), vjust = -1.4, size = 3.3, show.legend = FALSE) +
scale_colour_manual(values = pal) +
scale_size_continuous(range = c(3, 8)) +
scale_y_continuous(limits = c(0, NA), expand = expansion(mult = c(0, 0.15))) +
labs(title = paste0("The chasing edge has shrunk: ", r1(first_era$gap), " runs in ",
first_era$era, ", ", r1(last_era$gap), " in ", last_era$era),
x = NULL, y = "Batting average", colour = "Situation", size = "Innings") +
theme_minimal(base_size = 12)
ggplotly(p4, tooltip = "text")
Data: Cricsheet (n.d.). Point size shows the number of innings behind each average. Each point rests on a modest sample, so read differences between eras as indicative only. Text description: two lines of batting average by era, one per situation, with the gap narrowing over time.
You may filter the data by year, by opposition, or by result, and both charts will update accordingly as you make your selections. On the left-hand side, every chase is plotted according to the target that he was set and the number of runs that he scored, with the colour indicating whether India emerged victorious. On the right-hand side, each innings is plotted according to the number of balls faced and the number of runs scored. Selections that are small in size tend to produce results that are unreliable, and therefore it is advisable to keep a close watch on how many points remain.
result_pal <- c("Won" = "#009E73", "Lost" = "#D55E00", "No result / tie" = "#999999")
sd <- SharedData$new(inn, key = ~match_id)
chase <- plot_ly(sd, x = ~target, y = ~runs, color = ~result, colors = result_pal,
type = "scatter", mode = "markers",
text = ~paste0(tip, "<br>Target: ", target), hoverinfo = "text") |>
layout(xaxis = list(title = "Target to chase (chases only)"),
yaxis = list(title = "Runs scored"))
sc <- plot_ly(sd, x = ~balls_faced, y = ~runs, color = ~situation, colors = pal,
type = "scatter", mode = "markers", text = ~tip, hoverinfo = "text") |>
layout(xaxis = list(title = "Balls faced"), yaxis = list(title = "Runs"))
bscols(widths = c(3, NA),
list(filter_slider("year", "Year", sd, ~year, sep = ""),
filter_select("opp", "Opposition", sd, ~bowling_team),
filter_checkbox("res", "Result", sd, ~result)),
subplot(chase, sc, nrows = 1, titleX = TRUE, titleY = TRUE, margin = 0.05) |>
layout(title = list(text = "Runs against the target (chases) and against balls faced, for your selection",
x = 0.02)))
Data: Cricsheet (n.d.). Text description: two linked scatter plots of his innings that respond to the year, opposition and result filters.
The average that Kohli achieves while chasing is greater than the average he records in the first innings; however, the magnitude of this difference remains uncertain, since a bootstrap interval for the gap extends from 0.3 to 29.6 runs. In both situations, he accumulates runs at a comparable rate and reaches a score of 50 or more with a comparable frequency. A portion of the gap, amounting to approximately 65%, arises from the manner in which chases conclude, while the remainder is consistent with the possibility that he is dismissed less frequently per ball when he is chasing, namely 1.4 as opposed to 1.8 per 100 balls.
cat('<div class="stats">',
card(paste0(r1(sr_f), " vs ", r1(sr_c)), "strike rate: batting first vs chasing", "#EEEEEE"),
card(paste0(r1(f$fifty_pct), "% vs ", r1(c_$fifty_pct), "%"), "innings reaching 50: batting first vs chasing", "#EEEEEE"),
card(paste0(r1(f$dismissals_per_100), " vs ", r1(c_$dismissals_per_100)), "dismissals per 100 balls: batting first vs chasing", "#EEEEEE"),
'</div>', sep = "\n")
Cricsheet. (n.d.). Cricsheet: Freely available structured data for cricket [Data set]. https://cricsheet.org/
ESPNcricinfo. (n.d.). Batting records: One-Day Internationals, V Kohli [Data set]. Statsguru. Retrieved 27 October 2026, from https://stats.cricinfo.com/ci/engine/player/253802.html?class=2;template=results;type=batting
Guinness World Records. (n.d.). Most hundreds in a One-Day International career (male). Retrieved 27 October 2026, from https://guinnessworldrecords.com/world-records/most-hundreds-in-a-one-day-international-career-male
I used Claude to help in writing and debugging the R scripts that download and prepare the Cricsheet data. I chose the subject (Virat Kohli’s ODI career), chose to compare batting first with chasing, ran the code, and reviewed the results.
Anthropic. (2026). Claude (Claude Sonnet 5.5) [Large language model]. https://claude.ai