New Value: increasing cost of UKRI research grants
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
A chance comment from a colleague led to today’s post.
Competition for research grants in the UK has become more intense recently. Success rates have dropped, and this has been attributed to more applications, against a backdrop of a flat investment in science by the government.
My colleague thought that the cost of each proposal has increased in recent years, meaning that fewer grants can be funded. They thought that this leads to PIs making more applications, and potentially increasing the budget of these proposals as they try to land bigger fish; a vicious cycle which drives down the success rates further.
Let’s see if the data agrees with this idea.
Using data from GtR, we can look at awards for research grants from UKRI agencies. We’ll look at BBSRC and MRC as the main funders in the biological/biomedical sphere. Plots first, code later.
Some details and caveats to the analysis (click for more)
Disclaimer: the goal of the post is to use publicly-available information to answer whether or not my colleague’s hunch is correct. It is not meant to be a rigorous analysis to get a definitive answer.
Data quality: to get the data from GtR, I filtered for “Research Grants” from either MRC or BBSRC. The Research Grant category includes standard research grants, but also some smaller project funds as well as big projects and institutional level funding. I couldn’t figure out a quick way to disambiguate project grants from the other awards. The median is a good way to exclude the big and small awards. It also, matched the biggest bulge in the ridge plot so it serves as a good indicator for project cost. The assumptions later in the post about salary costs are more uncertain. A proper breakdown of the awarded grants is needed to know for sure what costs are rising.
Accessing data: There’s a package for R to access the API (gtR), I forked this and tried to update it to automate the pulldown of data, but the API is geared towards queries for projects, rather than by funder. In the end it was easier to download a csv from GtR directly.
Project costs: a project can have several components at different centres. I consolidated split awards into one and took the total cost, and the earliest start date and the latest end date to do the calculation. It might be cleaner to remove these, as split awards may have higher costs.
Completeness: GtR is missing most of the data from 2025 and 2026 (not added yet), so I only analysed up to 2024.
FEC: UKRI awarded costs are 80% of the full economic cost (FEC) of the project. I have not corrected for this here.
If we look at total award value for each research grant we can a small upward shift since 2020.

Obviously there are projects of different duration in here and we need to know what is the “going rate” for a research project. We can calculate an Annual Cost by dividing the total award by the total duration of the project.

I have truncated the y-axis at £1M per year because we are not interested in the very large projects that are captured in the dataset. There are a bunch of very small awards that also do not really concern us, but we can see the stripe of annual costs for projects over time and it does appear to trend upwards in the last five years.

We can see this a bit more clearly by making a ridgeplot and truncating at £0.5M/year.
Isn’t this to be expected though? Salaries have increased, consumables are more expensive. We need to factor in inflation to understand if this increase is real or just reflecting the fact that research just costs more in 2025 than it did in 2015.

If we take the median annual cost of all projects in a given year and make a bar chart, we can overlay a line with an indication of the cost with inflation factored in. The ONS publishes their measure of inflation which is normalised to 2015 prices. Using this data, we can project where the current annual cost should sit due to inflation since 2015 alone.
For BBSRC in 2024, the median annual cost for research projects was £190.9 K/yr, whereas the inflation-adjusted 2015 price would be £142.5 K/yr. 2024 was not isolated, the three or four years proceeding it were also over-and-above the 2015 price adjusted for inflation.
The picture at MRC is broadly similar.
We can make the same plots and look at the project annual costs and how that would be expected to increase due to inflation since 2015.




For MRC, the annual project cost in 2022-24 was also above the cost when adjusted for inflation, although this follows several years where the cost was below or on par with the inflation adjusted value.
It does seem that the requested (and awarded) project costs have increased at both funders in the last few years. What is the source of this increase?
Almost by definition, a project has an associated salary cost. We can look at this cost and see how it fared with inflation. Anyone working in UK higher ediucation will be ahead of me here…

If we look at the salary with on-costs and compare the value to the 2015 value adjusted for inflation, we can see that the salary cost of 1 FTE spine point 33 researcher has not kept pace in the last few years.
This means that the above inflation rises in project annual costs would be even greater if the salary component were to be removed. We can remove the cost of one researcher to estimate what the non-salary project annual costs would look like for BBSRC.

There may be a trend towards asking for more staff time per year relative to grants in 2015, or costing them higher up the scale, which would change this estimate. Although the salary costs are likely only around 30% of the total project annual cost, so the impact of such changes would not be huge.
The increased cost of projects is most likely being driven from the other (non-salary) lines on the grant: the indirects, the running costs, equipment/travel and so on.
Conclusion
Looking at awarded projects up to 2024, it is true that the cost of each project has increased. We know that the funder’s budget for awarded projects has not gone up, so it does seem logical that this is a factor in driving success rates down and increasing competition.
The code
library(ggplot2)
library(dplyr)
library(ggridges)
library(ggtext)
sysfonts::font_add_google("Roboto", "roboto")
showtext::showtext_auto()
showtext::showtext_opts(dpi = 300)
## Plot styling ----
qColor <- "red"
cap <- paste0(
"**Data:** UKRI Gateway to Research (GtR) on 2026-04-20.<br>**Graphic:** ",
"reproduced from quantixed.org"
)
theme_q <- function() {
theme_minimal() +
theme(
plot.caption = element_textbox_simple(
colour = "grey25",
hjust = 0,
halign = 0,
margin = margin(b = 0, t = 10),
size = rel(0.6)
),
text = element_text(family = "roboto", size = 10),
plot.title = element_text(size = rel(1.2), face = "bold")
)
}
## Functions ----
automate_dataviz <- function(path) {
# load the csv file
df <- read.csv(path)
# funder is found in FundingOrgName
funder <- df$FundingOrgName[1]
# StartDate and EndDate are character in the format of "DD/MM/YYYY", we need to convert them to Date format
# Convert StartDate and EndDate to Date format
df$StartDate <- as.Date(df$StartDate, format = "%d/%m/%Y")
df$EndDate <- as.Date(df$EndDate, format = "%d/%m/%Y")
# we are interested in the per project cost over time
# ProjectReference is "BB/K002341/1" or "BB/K002341/2", we want to extract the first two parts "BB/K002341"
df$ProjectID <- sapply(strsplit(df$ProjectReference, "/"), function(x) paste(x[1:2], collapse = "/"))
# some projects might have the ProjectReference like "BB/K002341" so we need to remove "/NA" from ProjectID
df$ProjectID <- gsub("/NA", "", df$ProjectID)
# remove any grants were the TotalCost is NA
df <- df[!is.na(df$AwardPounds), ]
# we need to group by ProjectID and calculate the total cost and total duration for each project
# to do this we need to find the earliest StartDate and latest EndDate for each project
project_summary <- df %>%
group_by(ProjectID) %>%
summarise(TotalCost = sum(AwardPounds),
StartDate = min(StartDate),
EndDate = max(EndDate))
# Calculate the duration in years
project_summary$DurationYears <- as.numeric(difftime(project_summary$EndDate, project_summary$StartDate, units = "days")) / 365.25
# Calculate the cost per year
project_summary$CostPerYear <- project_summary$TotalCost / project_summary$DurationYears
# Now we have a summary of each project with total cost, duration, and cost per year
# check for NA, NaN or Inf values in CostPerYear and remove those rows
project_summary <- project_summary[!is.na(project_summary$CostPerYear) & !is.infinite(project_summary$CostPerYear), ]
# we have incomplete data for 2025 and 2026, so we will remove projects starting in those years
project_summary <- project_summary[format(project_summary$StartDate, "%Y") < "2025", ]
# ggplot of CostPerYear by StartDate
ggplot(project_summary, aes(x = StartDate, y = CostPerYear)) +
geom_point(size = 1, shape = 16, color = qColor, alpha = 0.5) +
labs(title = paste0(funder, ": Cost per Year of Projects Over Time"),
x = "Project Start Date",
y = "Project Annual Cost (GBP/yr)",
caption = cap) +
# scales should read 250K instead of 250000
scale_y_continuous(labels = scales::label_number(scale = 1e-3, suffix = "K"), limits = c(0, 1e6)) +
theme_q()
ggsave(paste0("Output/Plots/",funder,"_CostPerYearOverTime.png"), width = 8, height = 6, dpi = 300)
ggplot(project_summary, aes(x = StartDate, y = TotalCost)) +
geom_point(size = 1, shape = 16, color = qColor, alpha = 0.5) +
labs(title = paste0(funder, ": Total Award for Projects Over Time"),
x = "Project Start Date",
y = "Project Total Cost (GBP)",
caption = cap) +
# scales should read 1M instead of 1000000
scale_y_continuous(labels = scales::label_number(scale = 1e-6, suffix = "M"), limits = c(0, 5e6)) +
theme_q()
ggsave(paste0("Output/Plots/",funder,"_TotalAwardOverTime.png"), width = 8, height = 6, dpi = 300)
# extract the year from StartDate and add it as a new column
project_summary$Year <- format(project_summary$StartDate, "%Y")
# ggridge plot of CostPerYear by Year-Quarter
ggplot(project_summary, aes(x = CostPerYear, y = Year, fill = Year)) +
geom_density_ridges(alpha = 0.7) +
scale_fill_cyclical(values = colorRampPalette(colors = c("grey50", qColor))(20)) +
labs(title = paste0(funder, ": Distribution of Project Annual Cost by Year"),
x = "Project Annual Cost (GBP/yr)",
y = "Year",
caption = cap) +
theme_q() +
# scales should read 250K instead of 250000
scale_x_continuous(labels = scales::label_number(scale = 1e-3, suffix = "K"), limits = c(0,5e5)) +
theme(legend.position = "none") +
coord_flip()
ggsave(paste0("Output/Plots/",funder,"_CostPerYearDistributionByYear.png"), width = 8, height = 6, dpi = 300)
# overlay the mean cost per year for each year
yearly_summary <<- project_summary %>%
group_by(Year) %>%
summarise(MeanCostPerYear = mean(CostPerYear, na.rm = TRUE),
MedianCostPerYear = median(CostPerYear, na.rm = TRUE))
# we'll store that in our environemnt for use outside the function
# inflation information from ONS
inflation <- read.csv("Data/series-200426.csv")
# take rows 8 to 45 (exclude non-data rows)
inflation <- inflation[8:45, ]
# reset row names
rownames(inflation) <- NULL
# name columns "Year" and "Inflation"
colnames(inflation) <- c("Year", "Inflation")
# convert Inflation to numeric
inflation$Inflation <- as.numeric(inflation$Inflation)
# scale the inflation value by the median value of the cost per year in 2015
inflation$InflationScaled <- (inflation$Inflation / 100) * yearly_summary$MedianCostPerYear[yearly_summary$Year == "2015"]
# plot the inflation scaled by the median cost per year in 2015
ymax <- ceiling(max(c(yearly_summary$MedianCostPerYear, inflation$InflationScaled)) / 1e5) * 1e5
ggplot(yearly_summary, aes(x = as.numeric(Year), y = MedianCostPerYear)) +
geom_bar(stat = "identity", fill = "grey50") +
geom_line(data = inflation[19:38,], aes(x = as.numeric(Year), y = InflationScaled, group = 1), color = qColor, size = 1) +
labs(title = paste0(funder, ": Recent project annual costs have risen above inflation"),
x = "Year",
y = "Median Project Annual Cost (GBP/yr)",
caption = cap) +
# set limit to nearest 100K above the maximum value of MedianCostPerYear or InflationScaled, whichever is higher
scale_y_continuous(labels = scales::label_number(scale = 1e-3, suffix = "K"), limits = c(0, ymax)) +
theme_q() +
theme(legend.position = "none")
ggsave(paste0("Output/Plots/",funder,"_CostPerYearDistributionWithInflation.png"), width = 8, height = 6, dpi = 300)
# print the 2024 values for MedianCostPerYear and InflationScaled
cat(paste0("In 2024, the median cost per year for ", funder, " projects is ",
round(yearly_summary$MedianCostPerYear[yearly_summary$Year == "2024"] / 1e3, 1),
"K GBP/yr, while the inflation scaled by the median cost per year in 2015 is ",
round(inflation$InflationScaled[inflation$Year == "2024"] / 1e3, 1), "K GBP/yr.\n"))
}
## Main Script ----
## download csvs from GtR for MRC and BBSRC
# BBSRC = projectsearch-1776682579105
# MRC = projectsearch-1776753976401
automate_dataviz("Data/projectsearch-1776753976401.csv")
automate_dataviz("Data/projectsearch-1776682579105.csv")
# load salary scale information from 2015 to 2025
cap2 <- paste0(
"**Data:** Single Pay Spine for Academic Staff, Spine Point 33. <br>**Graphic:** ",
"reproduced from quantixed.org"
)
# salary scale information rekeyed from University of Glasgow website.
salary <- read.csv("Data/salary.csv")
# the date is in Date column in 01/11/2023 format, convert to numeric year
salary$Year <- as.numeric(format(as.Date(salary$Date, format = "%d/%m/%Y"), "%Y"))
# inflation data downloaded from ONS
inflation <- read.csv("Data/series-200426.csv")
# take rows 8 to 45
inflation <- inflation[8:45, ]
# reset row names
rownames(inflation) <- NULL
# name columns "Year" and "Inflation"
colnames(inflation) <- c("Year", "Inflation")
# convert Inflation to numeric
inflation$Inflation <- as.numeric(inflation$Inflation)
# convert Year to numeric
inflation$Year <- as.numeric(inflation$Year)
# scale the inflation value by the median value of the cost per year in 2015
inflation$InflationScaled <- (inflation$Inflation / 100) * salary$WithOnCosts[salary$Year == 2015]
ggplot(salary, aes(x = Year)) +
geom_bar(aes(y = WithOnCosts), stat = "identity", fill = "grey25") +
geom_bar(aes(y = Salary), stat = "identity", fill = "grey50") +
geom_line(data = inflation[28:38,], aes(x = Year, y = InflationScaled, group = 1), color = qColor, size = 1) +
labs(title = "Average Salary and On-Costs for Research Staff Over Time",
x = "Year",
y = "Cost (GBP)",
caption = cap2) +
scale_y_continuous(labels = scales::label_number(scale = 1e-3, suffix = "K"), limits = c(0, 60000)) +
theme_q() +
theme(legend.position = "none")
ggsave("Output/Plots/SalaryAndOnCostsOverTime.png", width = 8, height = 6, dpi = 300)
# convert yearly_summary Year to numeric
yearly_summary$Year <- as.numeric(yearly_summary$Year)
# merge salary and yearly_summary by Year
nonsalary <- merge(yearly_summary, salary, by = "Year")
# subtract WithOnCosts from MedianCostPerYear
nonsalary$NewAnnualCost <- nonsalary$MedianCostPerYear - nonsalary$WithOnCosts
# add column with inflation adjusted figure
inflation$InflationAdjusted <- (inflation$Inflation / 100) * nonsalary$NewAnnualCost[nonsalary$Year == 2015]
ggplot(nonsalary, aes(x = Year)) +
geom_bar(aes(y = NewAnnualCost), stat = "identity", fill = "grey50") +
geom_line(data = inflation[28:37,], aes(x = Year, y = InflationAdjusted, group = 1), color = qColor, size = 1) +
labs(title = "BBSRC: Estimated non-salary project annual cost, with inflation",
x = "Year",
y = "Project Annual Cost (GBP)",
caption = paste0("**Graphic:** reproduced from quantixed.org")) +
scale_y_continuous(labels = scales::label_number(scale = 1e-3, suffix = "K"), limits = c(0, 150000)) +
# x-axis ticks should be integers only
scale_x_continuous(breaks = seq(2015, 2024, by = 1)) +
theme_q() +
theme(legend.position = "none")
ggsave("Output/Plots/BBSRCEstimate.png", width = 8, height = 6, dpi = 300)
—
The post title comes from the Iggy Pop LP “New Value”.
R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.