# ---------------------------------------------------------------------------
# 1. LOAD THE DATA (local files downloaded from the original sources)
# ---------------------------------------------------------------------------
# Source 1: Crime Statistics Agency (CSA) - Criminal incidents by LGA,
#           year ending June 2026 (XLSX). Saved into the data/ folder.
# Source 2: Australian Bureau of Statistics (ABS) - Consumer Price Index,
#           Australia, Tables 1 and 2 (640101.xlsx). Saved into data/.

# Look in the data/ folder next to this .Rmd file.
xlsx_files <- list.files("data", pattern = "\\.xlsx$", full.names = TRUE, ignore.case = TRUE)
xlsx_files <- xlsx_files[!grepl("^~\\$", basename(xlsx_files))]       # skip Excel temp files
is_abs     <- grepl("^6401", basename(xlsx_files))
csa_files  <- xlsx_files[!is_abs]
lga_file   <- c(csa_files[grepl("LGA", basename(csa_files), ignore.case = TRUE)], csa_files)[1]
cpi_files  <- xlsx_files[is_abs]

where <- paste0("\n  R is looking in: ", normalizePath("data", mustWork = FALSE),
                "\n  Files found there: ",
                ifelse(length(list.files("data")) == 0, "(none - folder missing or empty)",
                       paste(list.files("data"), collapse = ", ")))
if (is.na(lga_file)) stop("Crime Statistics Agency LGA .xlsx file not found.", where)
if (length(cpi_files) == 0) stop("ABS CPI files (640101.xlsx, 640109.xlsx) not found.", where)

# Helper: tidy column names, e.g. "Local Government Area" -> "local_government_area"
tidy_names <- function(x) {
  x |> tolower() |> str_replace_all("[^a-z0-9]+", "_") |> str_remove_all("^_|_$")
}

# Helper: the CSA workbook has several tables; find the sheet holding the
# columns we need rather than relying on the sheet number.
find_sheet <- function(path, must_have) {
  for (s in excel_sheets(path)) {
    nm <- tidy_names(names(read_excel(path, sheet = s, n_max = 1)))
    if (all(sapply(must_have, function(p) any(str_detect(nm, p))))) return(s)
  }
  stop("No sheet in ", path, " contains: ", paste(must_have, collapse = ", "))
}

# LGA totals (gives police region and lets us derive population)
s_tot <- find_sheet(lga_file, c("^year$", "local_government_area", "police_region",
                                "incidents_recorded", "rate"))
lga_tot_raw <- read_excel(lga_file, sheet = s_tot)
names(lga_tot_raw) <- tidy_names(names(lga_tot_raw))

# LGA x offence subgroup counts
s_sub <- find_sheet(lga_file, c("^year$", "local_government_area", "offence_subgroup",
                                "incidents_recorded"))
lga_sub_raw <- read_excel(lga_file, sheet = s_sub)
names(lga_sub_raw) <- tidy_names(names(lga_sub_raw))

# ABS CPI: standard ABS time-series layout (row 1 = series names, rows 2-10 =
# metadata, row 11 onwards = observations) on sheets "Data1", "Data2", ...
# The ABS moved to a monthly CPI in late 2025, so we search EVERY 6401*.xlsx file
# in data/ for the Melbourne "All groups CPI" index and keep the longest series.
# ---------------------------------------------------------------------------
# 2. PREPROCESSING
# ---------------------------------------------------------------------------
# Remove rows that are not real councils.
not_council <- "unincorporated|justice institutions|total"

# 2a. Population per LGA and year, derived from the CSA's own rate:
#     population = incidents / rate x 100,000
rate_col <- names(lga_tot_raw)[str_detect(names(lga_tot_raw), "rate")][1]
lga_pop <- lga_tot_raw |>
  transmute(year = as.integer(year),
            lga  = local_government_area,
            region = str_remove(police_region, "^\\d+\\s*"),
            population = as.numeric(incidents_recorded) / as.numeric(.data[[rate_col]]) * 1e5) |>
  filter(!str_detect(tolower(lga), not_council), is.finite(population), population > 0)

# 2b. Theft subgroups only (offence subdivision "Theft"), cleaned labels
theft <- lga_sub_raw |>
  filter(str_detect(offence_subdivision, regex("theft", ignore_case = TRUE)),
         !str_detect(tolower(local_government_area), not_council)) |>
  transmute(year = as.integer(year),
            lga  = local_government_area,
            subgroup = str_remove(offence_subgroup, "^[A-Z]\\d+\\s+"),
            incidents = as.numeric(incidents_recorded)) |>
  filter(!is.na(incidents)) |>
  group_by(year, lga, subgroup) |>
  summarise(incidents = sum(incidents), .groups = "drop")

is_shop <- function(x) str_detect(x, regex("retail", ignore_case = TRUE))

# 2c. Shoplifting by LGA and year, with a rate per 100,000 residents
shop_lga <- theft |>
  filter(is_shop(subgroup)) |>
  group_by(year, lga) |>
  summarise(incidents = sum(incidents), .groups = "drop") |>
  inner_join(lga_pop, by = c("year", "lga")) |>
  mutate(rate = incidents / population * 1e5)

yr_first <- min(shop_lga$year)
yr_last  <- max(shop_lga$year)
fy_label <- function(y) paste0(y - 1, "-", substr(y, 3, 4))   # 2026 -> "2025-26"

# 2d. Statewide theft series (sum of councils)
state_theft <- theft |>
  group_by(year, subgroup) |>
  summarise(incidents = sum(incidents), .groups = "drop") |>
  group_by(year) |>
  mutate(share = incidents / sum(incidents) * 100) |>
  ungroup()

state_pop <- lga_pop |> group_by(year) |> summarise(population = sum(population))

state_shop_rate <- shop_lga |>
  group_by(year) |>
  summarise(incidents = sum(incidents), population = sum(population)) |>
  mutate(rate = incidents / population * 1e5)

# 2e. Melbourne CPI, averaged per financial year (July-June) to match CSA years
to_date <- function(x) {
  n <- suppressWarnings(as.numeric(x))
  if (all(!is.na(n))) as.Date(n, origin = "1899-12-30") else as.Date(x)
}
cpi_candidates <- list()
for (f in cpi_files) {
  for (sh in grep("^Data", excel_sheets(f), value = TRUE)) {
    raw   <- read_excel(f, sheet = sh, col_names = FALSE, .name_repair = "minimal")
    hdr   <- as.character(unlist(raw[1, ]))
    stype <- as.character(unlist(raw[3, ]))            # "Series Type" row
    cols  <- which(str_detect(hdr, regex("index numbers", ignore_case = TRUE)) &
                   str_detect(hdr, regex("all groups cpi", ignore_case = TRUE)) &
                   str_detect(hdr, regex("melbourne", ignore_case = TRUE)) &
                   !str_detect(stype, regex("seasonal", ignore_case = TRUE)))
    for (cl in cols) {
      d <- raw[-(1:10), c(1, cl)] |>
        setNames(c("date", "cpi")) |>
        filter(!is.na(date), !is.na(cpi)) |>
        mutate(date = to_date(date), cpi = as.numeric(cpi)) |>
        filter(!is.na(date), !is.na(cpi))
      if (nrow(d) > 0) cpi_candidates[[length(cpi_candidates) + 1]] <- d
    }
  }
}
if (length(cpi_candidates) == 0)
  stop("No Melbourne 'All groups CPI' index found. Download ABS CPI Table 1 (640101.xlsx) ",
       "and Table 9 (640109.xlsx) into data/.")
# Since late 2025 the ABS publishes a monthly CPI that only starts in April 2024
# (re-referenced to Sep 2025 = 100). The long quarterly history is in the last
# quarterly release (September quarter 2025). We join the two: the long series is
# used up to its last complete financial year, and the new monthly series is
# rescaled to match it over the years where both exist (chain-linking).
# Step 1: turn every series into quarterly averages (complete quarters only).
to_quarterly <- function(d) {
  monthly <- median(as.numeric(diff(sort(unique(d$date))))) < 45
  d |>
    mutate(m = as.integer(format(date, "%m")),
           q = as.Date(paste0(format(date, "%Y"), "-", sprintf("%02d", ((m - 1) %/% 3 + 1) * 3), "-01"))) |>
    group_by(q) |>
    summarise(cpi = mean(cpi), n = n(), .groups = "drop") |>
    filter(n >= ifelse(monthly, 3, 1)) |>
    select(q, cpi)
}
cpi_q <- lapply(cpi_candidates, to_quarterly)
cpi_q <- cpi_q[sapply(cpi_q, nrow) > 0]
q_start <- do.call(c, lapply(cpi_q, function(d) min(d$q)))
q_end   <- do.call(c, lapply(cpi_q, function(d) max(d$q)))
cpi_found <- paste0("\n  Melbourne CPI series found: ",
                    paste(format(q_start, "%b %Y"), "to", format(q_end, "%b %Y"), collapse = "; "))

# Step 2: the longest-running series is the base; the series that ends latest extends it.
long   <- cpi_q[[order(q_start, -as.numeric(q_end))[1]]]
recent <- cpi_q[[which.max(q_end)]]
if (min(long$q) > as.Date(paste0(yr_first - 1, "-09-01")))
  stop("No CPI series goes back to ", yr_first - 1, ". Add Table 1 from the ABS ",
       "'September Quarter 2025' release to data/ (renamed 640101_sep2025.xlsx).", cpi_found)

# Step 3: chain-link. Rescale the recent series so it matches the long series over the
# quarters where both exist, then add the quarters the long series doesn't have.
overlap <- inner_join(long, recent, by = "q", suffix = c("_long", "_recent"))
if (max(recent$q) > max(long$q) && nrow(overlap) > 0) {
  link <- mean(overlap$cpi_long / overlap$cpi_recent)
  long <- bind_rows(long, recent |> filter(q > max(long$q)) |> mutate(cpi = cpi * link))
}

# Step 4: average the quarters within each financial year (July-June).
cpi_fy <- long |>
  mutate(year = as.integer(format(q, "%Y")) + (as.integer(format(q, "%m")) >= 7)) |>
  group_by(year) |>
  summarise(cpi = mean(cpi), n = n(), .groups = "drop") |>
  filter(year >= yr_first, year <= yr_last, n == 4 | year == yr_last)
if (!all(c(yr_first, yr_last) %in% cpi_fy$year))
  stop("CPI data does not cover ", fy_label(yr_first), " to ", fy_label(yr_last), ".", cpi_found)
cpi_partial_note <- if (cpi_fy$n[cpi_fy$year == yr_last] < 4)
  paste0(" For ", fy_label(yr_last), " only ", cpi_fy$n[cpi_fy$year == yr_last],
         " quarter(s) of CPI were available, so that year's figure is the average of those quarters.") else ""

# 2f. Index everything to the first year = 100 (so different units can share one axis
#     honestly, without a misleading dual axis)
idx <- function(x) x / x[1] * 100
chart1_data <- bind_rows(
  state_theft |> filter(is_shop(subgroup)) |>
    group_by(year) |> summarise(value = sum(incidents)) |>
    mutate(series = "Shoplifting incidents"),
  state_theft |> filter(!is_shop(subgroup)) |>
    group_by(year) |> summarise(value = sum(incidents)) |>
    mutate(series = "All other theft incidents"),
  cpi_fy |> transmute(year, value = cpi, series = "Cost of living (Melbourne CPI)")
) |>
  arrange(series, year) |>
  group_by(series) |>
  mutate(index = idx(value)) |>
  ungroup() |>
  mutate(fy = fy_label(year))

# 2g. Key numbers used in the text (calculated, never typed in by hand)
grab <- function(s, y) chart1_data$index[chart1_data$series == s & chart1_data$year == y]
shop_chg  <- grab("Shoplifting incidents", yr_last) - 100
other_chg <- grab("All other theft incidents", yr_last) - 100
cpi_chg   <- grab("Cost of living (Melbourne CPI)", yr_last) - 100
shop_n_last <- sum(shop_lga$incidents[shop_lga$year == yr_last])
shop_per_day <- shop_n_last / 365
shop_share_first <- state_theft |> filter(year == yr_first, is_shop(subgroup)) |> pull(share) |> sum()
shop_share_last  <- state_theft |> filter(year == yr_last,  is_shop(subgroup)) |> pull(share) |> sum()

# 2h. One row per council: first vs last year (for Charts 3 and 4)
lga_compare <- shop_lga |>
  filter(year %in% c(yr_first, yr_last)) |>
  select(lga, region, year, rate, incidents) |>
  pivot_wider(names_from = year, values_from = c(rate, incidents)) |>
  setNames(c("lga", "region", "rate_first", "rate_last", "inc_first", "inc_last")) |>
  filter(!is.na(rate_first), !is.na(rate_last), rate_first > 0) |>
  mutate(change_pct = (rate_last / rate_first - 1) * 100,
         tip = paste0("<b>", lga, "</b> (", region, ")",
                      "<br>", fy_label(yr_first), ": ", comma(rate_first, 1), " per 100k",
                      "<br>", fy_label(yr_last), ": ", comma(rate_last, 1), " per 100k",
                      "<br>Change: ", ifelse(change_pct >= 0, "+", ""), round(change_pct), "%",
                      "<br>Incidents ", fy_label(yr_last), ": ", comma(inc_last)))

state_rate_first <- state_shop_rate$rate[state_shop_rate$year == yr_first]
state_rate_last  <- state_shop_rate$rate[state_shop_rate$year == yr_last]
top_lga <- lga_compare |> slice_max(rate_last, n = 1)
n_doubled <- sum(lga_compare$change_pct >= 100)
my_row <- lga_compare |> filter(lga == my_lga)
if (nrow(my_row) == 0) stop("params$my_lga '", my_lga, "' is not a council name in the CSA file.")

# 2i. Extra numbers used in the commentary (so every sentence matches the data)
shop_series <- chart1_data |> filter(series == "Shoplifting incidents") |>
  mutate(pct = (value / lag(value) - 1) * 100)
jump <- shop_series |> filter(!is.na(pct)) |> slice_max(pct, n = 1, with_ties = FALSE)
v <- function(y) shop_series$value[shop_series$year == y]
share_after_jump <- (v(yr_last) - v(jump$year - 1)) / (v(yr_last) - v(yr_first))
flat_before <- all(abs(shop_series$index[shop_series$year < jump$year] - 100) <= 15)
covid_dip <- all(c(2019, 2021) %in% shop_series$year) && v(2021) < v(2019)
still_peak <- v(yr_last) == max(shop_series$value)
cpi_growth <- cpi_fy |> mutate(pct = (cpi / lag(cpi) - 1) * 100, fy = fy_label(year)) |>
  filter(!is.na(pct))
cpi_peak <- cpi_growth |> slice_max(pct, n = 1, with_ties = FALSE)
shop_since_cpi_peak <- (v(yr_last) / v(cpi_peak$year) - 1) * 100
n_lga <- nrow(lga_compare)
n_up  <- sum(lga_compare$change_pct > 0)
my_rank <- rank(-lga_compare$rate_last, ties.method = "min")[lga_compare$lga == my_lga]
ordinal <- function(n) paste0(n, ifelse(n %% 100 %in% 11:13, "th",
                              c("th", "st", "nd", "rd", rep("th", 6))[n %% 10 + 1]))

The story in one line

In 2025-26, Victorian police recorded 33,006 shoplifting incidents, about 90 every day. Since 2016-17, shoplifting has risen 77%, while prices in Melbourne rose 30% and every other kind of theft rose 25%.

Sometime in the last couple of years, my local supermarket started locking razors and baby formula behind plastic cases, and now there’s a security guard standing by the self-checkouts most evenings. At first I thought it was just my area. Then friends in other suburbs said their shops had done the same, and every news story I read seemed to blame the cost of living. I wanted to check that for myself. Is shoplifting really rising faster than prices? And is it happening everywhere, or only in a few places?

This report follows the question through five interactive charts: when shoplifting broke away, what it replaced, where it is concentrated, who changed the most, and what it looks like up close in the councils hit hardest.

Charts were built in R with plotly (Sievert, 2020), ggplot2 (Wickham, 2016) and crosstalk. All charts are interactive: hover for exact values, click legend items to hide or show a series, and drag to zoom (double-click to reset).


1. When: shoplifting broke away from prices and from other theft

lbl <- chart1_data |> filter(year == yr_last)

p1 <- plot_ly(chart1_data, x = ~fy, y = ~index, color = ~series,
              colors = c("All other theft incidents" = col_other,
                         "Cost of living (Melbourne CPI)" = col_cpi,
                         "Shoplifting incidents" = col_shop),
              type = "scatter", mode = "lines+markers",
              line = list(width = 3), marker = list(size = 7),
              text = ~paste0(series, ": ", comma(value, 1)),
              hovertemplate = paste0("%{text}<br>Index: %{y:.0f} (", fy_label(yr_first), " = 100)<extra></extra>")) |>
  add_annotations(data = lbl, x = ~fy, y = ~index,
                  text = ~paste0("<b>", ifelse(index >= 100, "+", ""), round(index - 100), "%</b>"),
                  xanchor = "left", xshift = 10, showarrow = FALSE,
                  font = list(size = 14, color = c(col_other, col_cpi, col_shop)[
                    match(lbl$series, c("All other theft incidents",
                                        "Cost of living (Melbourne CPI)",
                                        "Shoplifting incidents"))])) |>
  add_annotations(x = jump$fy, y = jump$index,
                  text = paste0("Biggest one-year jump:<br><b>+", round(jump$pct), "% in ",
                                jump$fy, "</b>"),
                  showarrow = TRUE, arrowhead = 2, arrowcolor = col_shop, ax = -90, ay = -40,
                  font = list(size = 12, color = col_shop), bgcolor = "rgba(255,255,255,0.85)") |>
  layout(title = list(text = paste0("<b>Shoplifting has ",
                                    ifelse(shop_chg > cpi_chg, "outpaced", "trailed"),
                                    " the cost of living</b><br><sup>Index, ",
                                    fy_label(yr_first), " = 100. Financial years ending June.</sup>"),
                      x = 0, xanchor = "left"),
         xaxis = list(title = "", fixedrange = FALSE),
         yaxis = list(title = paste0("Index (", fy_label(yr_first), " = 100)")),
         hovermode = "x unified",
         shapes = list(list(type = "line", xref = "paper", x0 = 0, x1 = 1,
                            y0 = 100, y1 = 100, line = list(dash = "dot", color = "#999"))),
         legend = list(orientation = "h", y = -0.15),
         margin = list(t = 80, r = 60),
         font = list(size = 13)) |>
  config(displaylogo = FALSE)
p1

Source: Crime Statistics Agency (2026); Australian Bureau of Statistics (2025, 2026). Indexing puts counts and prices on one honest scale without a second y-axis.

Text description of Chart 1 (for screen readers) Line chart of three indexes from 2016-17 (all set to 100) to 2025-26. Shoplifting incidents reach 177, Melbourne CPI reaches 130 and all other theft reaches 125. The largest one-year rise in shoplifting was 44% in 2023-24.

What to notice: all three lines start at 100. The orange line is shoplifting. By 2025-26 it sits at 177, compared with 130 for prices and 125 for other theft. Rising prices alone do not explain the jump: the cost of living rose steadily, but shoplifting accelerated far beyond it.

The orange line wobbles in the early years, and it even dips during the pandemic, when many shops were shut. Then, in 2023-24, shoplifting jumped 44% in a single year. Melbourne prices rose fastest in 2022-23 (6.9%), but even after inflation eased, shoplifting kept climbing, up another 82% since then. If theft simply followed prices, the two lines would move together. They clearly don’t.


2. What: shoplifting is taking a bigger slice of all theft

sub_levels <- state_theft |> filter(year == yr_last) |> arrange(desc(incidents)) |> pull(subgroup)
sub_cols <- setNames(rep(okabe, length.out = length(sub_levels)), sub_levels)
sub_cols[is_shop(names(sub_cols))] <- col_shop
sub_cols[!is_shop(names(sub_cols))] <- c("#56B4E9", "#009E73", "#CC79A7", "#E69F00", "#000000",
                                         "#999999", "#F0E442")[seq_len(sum(!is_shop(names(sub_cols))))]

c2 <- state_theft |> mutate(fy = fy_label(year), subgroup = factor(subgroup, sub_levels))
k <- length(sub_levels)

p2 <- plot_ly()
for (s in sub_levels) {
  d <- filter(c2, subgroup == s); w <- ifelse(is_shop(s), 5, 2)
  p2 <- add_trace(p2, data = d, x = ~fy, y = ~incidents, name = s, type = "scatter",
                  mode = "lines+markers", line = list(color = sub_cols[[s]], width = w),
                  marker = list(color = sub_cols[[s]]), legendgroup = s,
                  hovertemplate = paste0(s, ": %{y:,} incidents<extra></extra>"))
}
for (s in sub_levels) {
  d <- filter(c2, subgroup == s); w <- ifelse(is_shop(s), 5, 2)
  p2 <- add_trace(p2, data = d, x = ~fy, y = ~share, name = s, type = "scatter",
                  mode = "lines+markers", line = list(color = sub_cols[[s]], width = w),
                  marker = list(color = sub_cols[[s]]), legendgroup = s, visible = FALSE,
                  showlegend = TRUE,
                  hovertemplate = paste0(s, ": %{y:.1f}% of all theft<extra></extra>"))
}
p2 <- p2 |>
  layout(title = list(text = paste0("<b>Shoplifting went from ", round(shop_share_first),
                                    "% to ", round(shop_share_last), "% of all theft</b>",
                                    "<br><sup>Theft incidents by type, Victoria. Use the buttons to switch view.</sup>"),
                      x = 0, xanchor = "left"),
         xaxis = list(title = ""), yaxis = list(title = "Incidents recorded", rangemode = "tozero"),
         hovermode = "x unified", legend = list(orientation = "h", y = -0.15),
         margin = list(t = 110), font = list(size = 13),
         updatemenus = list(list(
           type = "buttons", direction = "right", x = 0, y = 1.13, xanchor = "left",
           buttons = list(
             list(label = "Number of incidents", method = "update",
                  args = list(list(visible = c(rep(TRUE, k), rep(FALSE, k))),
                              list(yaxis = list(title = "Incidents recorded", rangemode = "tozero")))),
             list(label = "Share of all theft (%)", method = "update",
                  args = list(list(visible = c(rep(FALSE, k), rep(TRUE, k))),
                              list(yaxis = list(title = "Share of all theft incidents (%)",
                                                rangemode = "tozero")))))))) |>
  config(displaylogo = FALSE)
p2

Source: Crime Statistics Agency (2026). Theft subgroups within offence subdivision “Theft”.

Text description of Chart 2 (for screen readers) Line chart of theft incidents by type in Victoria from 2016-17 to 2025-26, with buttons to show counts or each type’s share of all theft. Shoplifting’s share rose from 13% to 17%.

What to notice: switch to “Share of all theft”. Counts can rise simply because there are more people, but the share shows shoplifting growing relative to other theft. Shoplifting made up 13% of theft in 2016-17 and 17% in 2025-26.


3. Where: the councils with the highest shoplifting rates

top_n_lga <- 20
c3 <- lga_compare |> slice_max(rate_last, n = top_n_lga)
if (!my_lga %in% c3$lga) c3 <- bind_rows(c3, my_row)      # always show my council
c3 <- c3 |> mutate(lga_f = factor(lga, levels = lga[order(rate_last)]),
                   lab = ifelse(lga == my_lga, paste0("<b>", lga, " (my council)</b>"), lga))

sd3 <- SharedData$new(c3, key = ~lga, group = "councils")

p3 <- plot_ly(sd3, y = ~lga, text = ~tip, hoverinfo = "text", height = 640) |>
  add_segments(x = ~rate_first, xend = ~rate_last, yend = ~lga,
               line = list(color = "#BBBBBB", width = 3), showlegend = FALSE) |>
  add_markers(x = ~rate_first, name = fy_label(yr_first),
              marker = list(color = "#9E9E9E", size = 10)) |>
  add_markers(x = ~rate_last, name = fy_label(yr_last),
              marker = list(color = col_shop, size = 12)) |>
  layout(title = list(text = paste0("<b>", top_lga$lga, " has the highest shoplifting rate in Victoria</b>",
                                    "<br><sup>Top ", top_n_lga, " councils, incidents per 100,000 residents: ",
                                    fy_label(yr_first), " (grey) vs ", fy_label(yr_last), " (orange)</sup>"),
                      x = 0, xanchor = "left"),
         xaxis = list(title = "Shoplifting incidents per 100,000 residents", rangemode = "tozero"),
         yaxis = list(title = "", categoryorder = "array", categoryarray = levels(c3$lga_f),
                      tickvals = c3$lga, ticktext = c3$lab, automargin = TRUE),
         shapes = list(list(type = "line", x0 = state_rate_last, x1 = state_rate_last,
                            yref = "paper", y0 = 0, y1 = 1,
                            line = list(dash = "dash", color = col_cpi))),
         annotations = list(list(x = state_rate_last, y = 1.02, yref = "paper",
                                 text = paste0("Victoria ", fy_label(yr_last), ": ",
                                               round(state_rate_last)),
                                 showarrow = FALSE, font = list(color = col_cpi))),
         legend = list(orientation = "h", y = -0.16), margin = list(t = 90, b = 90),
         font = list(size = 13)) |>
  highlight(on = "plotly_click", off = "plotly_doubleclick", color = "#000000") |>
  config(displaylogo = FALSE)
p3

Source: Crime Statistics Agency (2026). Rates use the CSA’s resident population estimates. Central-city councils have very high rates partly because many shoppers live elsewhere.

Text description of Chart 3 (for screen readers) Dumbbell chart of the 20 councils with the highest shoplifting rates in 2025-26, comparing each council’s rate per 100,000 in 2016-17 and 2025-26. Melbourne is highest at 1267 per 100,000, against a state rate of 459.

What to notice: using rates per 100,000 residents (not raw counts) stops big councils from looking worse simply because they are big. The dashed blue line is the state rate. Click a council to highlight it here and in Chart 4 below.


4. Who changed: almost every council moved the same way

sd4 <- SharedData$new(lga_compare, key = ~lga, group = "councils")
lim <- c(0, max(c(lga_compare$rate_first, lga_compare$rate_last)) * 1.05)

p4 <- plot_ly(sd4, x = ~rate_first, y = ~rate_last, text = ~tip, hoverinfo = "text",
              color = ~region, colors = okabe[c(1, 2, 3, 6, 5)],
              type = "scatter", mode = "markers",
              marker = list(size = ~rescale(sqrt(inc_last), to = c(10, 40)),
                            sizemode = "diameter", opacity = 0.8,
                            line = list(color = "white", width = 1)),
              height = 560) |>
  add_lines(data = data.frame(x = lim, y = lim), x = ~x, y = ~y, inherit = FALSE,
            line = list(dash = "dot", color = "#777"), name = "No change line",
            hoverinfo = "skip") |>
  layout(title = list(text = paste0("<b>", n_doubled, " of ", nrow(lga_compare),
                                    " councils at least doubled their shoplifting rate</b>",
                                    "<br><sup>Each bubble is a council. Above the dotted line = rate went up. ",
                                    "Bubble size = incidents in ", fy_label(yr_last), ".</sup>"),
                      x = 0, xanchor = "left"),
         xaxis = list(title = paste0("Rate per 100,000 in ", fy_label(yr_first)), range = lim),
         yaxis = list(title = paste0("Rate per 100,000 in ", fy_label(yr_last)), range = lim),
         legend = list(title = list(text = "<b>Police region</b>")),
         margin = list(t = 90), font = list(size = 13)) |>
  highlight(on = "plotly_click", off = "plotly_doubleclick", color = "#000000",
            opacityDim = 0.15) |>
  config(displaylogo = FALSE)

bscols(widths = c(3, 9),
       list(filter_select("pick_lga", "Find a council", sd4, ~lga),
            filter_checkbox("pick_region", "Police region", sd4, ~region),
            filter_slider("pick_change", "Change in rate (%)", sd4, ~change_pct,
                          round = TRUE, width = "100%")),
       p4)

Source: Crime Statistics Agency (2026). The filters also apply to Chart 3, because both charts share the same council data.

Text description of Chart 4 (for screen readers) Bubble scatter plot of 76 councils, with the 2016-17 shoplifting rate on the horizontal axis and the 2025-26 rate on the vertical axis. 66 councils sit above the no-change line, and 25 at least doubled their rate.

What to notice: if shoplifting were just a “bad suburb” problem, most bubbles would sit on or below the dotted line, with a few outliers above it. Instead, 66 of 76 councils are above it. Try the filters: tick one police region at a time, or drag the slider to show only councils that more than doubled.

Hume, the council I live in, went from 232 to 262 shoplifting incidents per 100,000 residents (+13%). That puts it below the Victorian rate of 459, and 50th highest of 76 councils. Seeing my own council climb like this made the statewide numbers feel a lot less abstract.


5. Up close: ten years in the hardest-hit councils

focus <- shop_lga |> filter(year == yr_last) |> slice_max(incidents, n = 5) |> pull(lga)
focus <- unique(c(focus, my_lga))

c5 <- shop_lga |>
  filter(lga %in% focus) |>
  mutate(lga = factor(lga, levels = focus),
         fy = fy_label(year),
         tip = paste0("<b>", lga, "</b>, ", fy, "<br>", comma(rate, 1),
                      " per 100k (", comma(incidents), " incidents)"))
state_ref <- state_shop_rate |>
  tidyr::crossing(lga = factor(focus, levels = focus)) |>
  mutate(tip = paste0("Victoria, ", fy_label(year), ": ", comma(rate, 1), " per 100k"))

g5 <- ggplot(c5, aes(x = year, y = rate, group = lga, text = tip)) +
  geom_line(data = state_ref, aes(group = lga), colour = col_cpi,
            linetype = "dashed", linewidth = 0.6) +
  geom_line(colour = col_shop, linewidth = 1.2) +
  geom_point(colour = col_shop, size = 2) +
  facet_wrap(~ lga, ncol = 3) +
  scale_x_continuous(breaks = c(yr_first, round((yr_first + yr_last) / 2), yr_last),
                     labels = function(y) paste0(substr(y - 1, 3, 4), "-", substr(y, 3, 4)),
                     expand = expansion(mult = 0.08)) +
  scale_y_continuous(labels = comma, limits = c(0, NA)) +
  labs(x = "Financial year (e.g. 25-26 = July 2025 to June 2026)",
       y = "Shoplifting incidents per 100,000 residents") +
  theme_minimal(base_size = 13) +
  theme(strip.text = element_text(face = "bold", hjust = 0),
        panel.grid.minor = element_blank())

ggplotly(g5, tooltip = "text") |>
  layout(title = list(text = paste0("<b>The five councils with the most shoplifting, plus mine (",
                                    my_lga, ")</b><br><sup>Orange = council rate; ",
                                    "dashed blue = Victoria</sup>"),
                      x = 0, xanchor = "left"),
         margin = list(t = 90)) |>
  config(displaylogo = FALSE)

Source: Crime Statistics Agency (2026). Each panel shares the same y-axis so councils can be compared fairly.

Text description of Chart 5 (for screen readers) Six small line charts, one each for Melbourne, Greater Geelong, Casey, Wyndham, Darebin, Hume, showing the shoplifting rate per 100,000 from 2016-17 to 2025-26, each compared with the Victorian rate (dashed line), which went from 296 to 459.

What to notice: each panel compares one council (orange) with the state (dashed blue) on the same scale. Look for where the orange line turns upward, and whether each council turned at the same time as the state. A shared turning point points to a statewide cause, not a local one.


Conclusion: so, is it the cost of living?

Partly, maybe, but the data won’t let me say that cleanly. Over 9 years, shoplifting in Victoria rose 77%, while Melbourne prices rose 30% and all other theft rose 25%. Most of that rise came from 2023-24 onwards. It isn’t a story about a few “bad suburbs” either: 66 of 76 councils recorded higher rates, and 25 at least doubled.

What the charts can’t tell me is why. Rising prices, organised retail crime, repeat offenders and easier online reporting would all produce a line that looks like this one, and recorded crime data can’t separate them. So I’d be wary of anyone, on any side, who says the answer is simple.

For me, the numbers made the locked cabinets and the guard at my local supermarket make more sense. They aren’t an overreaction to a few viral videos. With Victorians voting on 28 November 2026, a better question for candidates than “will you be tough on shoplifting?” might be: “what will you do about why it’s rising, and how will we know if it’s working?”

Notes on the data and limitations

  • Incidents, not people or offences. The CSA counts criminal incidents: one incident can include several offences, so these totals are lower than the “offences” figures quoted in the media.
  • Recorded crime only. Changes can reflect reporting as well as behaviour. Police have said new online reporting systems improved the visibility of retail theft (Australian Associated Press, 2026), so part of the recent rise may reflect more reporting rather than more theft.
  • Correlation is not causation. CPI and shoplifting rising together does not prove that rising prices caused the theft. Other explanations include organised retail crime, repeat offenders and changes in policing.
  • Rates for central councils (e.g. Melbourne) are inflated because shoppers and workers are not residents.
  • Years are financial years ending June (e.g. 2025–26 = July 2025 to June 2026). CPI is the average of the observations within each financial year. The ABS moved to a monthly CPI in late 2025, so the older quarterly Melbourne series is joined to the new monthly series over the quarters where both exist. For 2025-26 only 1 quarter(s) of CPI were available, so that year’s figure is the average of those quarters.

References

Australian Associated Press. (2026, September 24). Crime unacceptably high despite dip in violence. The Canberra Times. https://www.canberratimes.com.au/story/9356554/crime-unacceptably-high-despite-dip-in-violence/

Australian Bureau of Statistics. (2025). Consumer price index, Australia, September quarter 2025 [Data set]. https://www.abs.gov.au/statistics/economy/price-indexes-and-inflation/consumer-price-index-australia/sep-quarter-2025

Australian Bureau of Statistics. (2026). Consumer price index, Australia, August 2026 [Data set]. https://www.abs.gov.au/statistics/economy/price-indexes-and-inflation/consumer-price-index-australia

Cheng, J., & Sievert, C. (2023). crosstalk: Inter-widget interactivity for HTML widgets (Version 1.2.1) [R package]. https://CRAN.R-project.org/package=crosstalk

Crime Statistics Agency. (2026). Criminal incident by LGA: Year ending June 2026 [Data set]. Data Vic. https://discover.data.vic.gov.au/dataset/criminal-incident/resource/c5750abf-4440-4e86-8bf0-11abd04741a9

Sievert, C. (2020). Interactive web-based data visualization with R, plotly, and shiny. Chapman and Hall/CRC. https://plotly-r.com

Wickham, H. (2016). ggplot2: Elegant graphics for data analysis. Springer-Verlag. https://ggplot2.tidyverse.org

Acknowledgements

Crime data © State of Victoria (Crime Statistics Agency), licensed under Creative Commons Attribution 4.0. CPI data © Commonwealth of Australia (ABS), licensed under Creative Commons Attribution 4.0. Colour palette: Okabe-Ito, chosen to be readable for people with colour-vision deficiency.