raterevision supports the mechanics around an insurance
rate revision. It does not replace a rating engine and it does not
prescribe how an actuary should derive indicated relativities. Instead,
it provides a controlled path for:
Most functions work on ordinary data frames. The package is designed
to pair naturally with ratingtables, but the
premium-comparison and target-solving functions are deliberately
rating-engine agnostic.
The examples below use two charters and two coverages. The source format is long: one row per factor value and rating key.
make_rows <- function(charter, coverage, term_name, term_value,
variable1 = NA_character_, level1 = NA_character_,
variable2 = NA_character_, level2 = NA_character_) {
n <- max(length(term_value), length(level1), length(level2), 1L)
data.frame(
state = rep("IL", n),
charter = rep(charter, n),
rate_eff_date = rep(as.Date("2026-01-01"), n),
coverage = rep(coverage, n),
term_name = rep(term_name, n),
term_value = rep(term_value, length.out = n),
variable1 = rep(variable1, length.out = n),
level1 = rep(level1, length.out = n),
variable2 = rep(variable2, length.out = n),
level2 = rep(level2, length.out = n),
stringsAsFactors = FALSE
)
}
pieces <- list()
k <- 1L
for (charter in c("A", "B")) {
for (coverage in c("BI", "CL")) {
pieces[[k]] <- make_rows(
charter, coverage, "base_rate",
if (charter == "A") c(BI = 220, CL = 180)[coverage]
else c(BI = 230, CL = 190)[coverage]
); k <- k + 1L
pieces[[k]] <- make_rows(
charter, coverage, "driver_age",
c(1.60, 1.35, 1.18, 1.05, 1.00),
"driver_age", c("16", "17", "18", "20", "52")
); k <- k + 1L
pieces[[k]] <- make_rows(
charter, coverage, "territory",
c(.90, 1.00, 1.10, 1.20),
"territory", c("T1", "T2", "T3", "T4")
); k <- k + 1L
grid <- expand.grid(
driver_age = c("16", "17", "20"),
gender = c("M", "F"), stringsAsFactors = FALSE
)
pieces[[k]] <- make_rows(
charter, coverage, "age_gender_interaction",
c(1.08, 1.05, 1.02, .99, .99, 1.00),
"driver_age", grid$driver_age,
"gender", grid$gender
); k <- k + 1L
}
}
current <- do.call(rbind, pieces)
current$factor_row_id <- seq_len(nrow(current))
rownames(current) <- NULL
head(current)
#> state charter rate_eff_date coverage term_name term_value variable1 level1
#> 1 IL A 2026-01-01 BI base_rate 220.00 <NA> <NA>
#> 2 IL A 2026-01-01 BI driver_age 1.60 driver_age 16
#> 3 IL A 2026-01-01 BI driver_age 1.35 driver_age 17
#> 4 IL A 2026-01-01 BI driver_age 1.18 driver_age 18
#> 5 IL A 2026-01-01 BI driver_age 1.05 driver_age 20
#> 6 IL A 2026-01-01 BI driver_age 1.00 driver_age 52
#> variable2 level2 factor_row_id
#> 1 <NA> <NA> 1
#> 2 <NA> <NA> 2
#> 3 <NA> <NA> 3
#> 4 <NA> <NA> 4
#> 5 <NA> <NA> 5
#> 6 <NA> <NA> 6The package requires coverage, term_name,
and term_value. Columns outside the structural
variableN/levelN slots are treated as rate-set
metadata and are preserved. This lets state, charter, company, program,
effective period, or other user-defined identifiers travel through the
workflow without being hard-coded into the package.
validate_rate_plan() is revision-oriented rather than a
replacement for a rating engine’s full plan validation. It detects
malformed factor values, duplicate keys, missing coverage/term
identifiers, inconsistent variable/level slots, and optional
non-positive multiplicative factors.
bad <- rbind(current, current[1, ])
validate_rate_plan(bad)
#> Error:
#> ! Rate-plan validation failed with 1 error(s). Run validate_rate_plan(..., error = FALSE) for details.For programmatic inspection without stopping:
rate_tables_wide() creates the package’s core
intermediate representation: a named list of ordinary data frames.
Coverages become columns and rows retain rate-set metadata, variable
names, and levels.
wide <- rate_tables_wide(current)
wide
#> <raterevision_tables>
#> sheets: 4
#> terms: 4
#> matrix sheets: 0
head(wide$driver_age, 12)
#> state charter rate_eff_date term_name variable1 level1 BI CL
#> 2 IL A 2026-01-01 driver_age driver_age 16 1.60 1.60
#> 3 IL A 2026-01-01 driver_age driver_age 17 1.35 1.35
#> 4 IL A 2026-01-01 driver_age driver_age 18 1.18 1.18
#> 5 IL A 2026-01-01 driver_age driver_age 20 1.05 1.05
#> 6 IL A 2026-01-01 driver_age driver_age 52 1.00 1.00
#> 34 IL B 2026-01-01 driver_age driver_age 16 1.60 1.60
#> 35 IL B 2026-01-01 driver_age driver_age 17 1.35 1.35
#> 36 IL B 2026-01-01 driver_age driver_age 18 1.18 1.18
#> 37 IL B 2026-01-01 driver_age driver_age 20 1.05 1.05
#> 38 IL B 2026-01-01 driver_age driver_age 52 1.00 1.00Numeric-looking levels are sorted numerically, so ages such as 16 through 90 do not sort lexicographically. Separate charters, states, and effective periods are kept as separate row groups.
A revision can be scoped explicitly instead of asking the package to guess which rate set is “current”:
a_only <- rate_tables_wide(current, filters = list(state = "IL", charter = "A"))
head(a_only$driver_age)
#> state charter rate_eff_date term_name variable1 level1 BI CL
#> 2 IL A 2026-01-01 driver_age driver_age 16 1.60 1.60
#> 3 IL A 2026-01-01 driver_age driver_age 17 1.35 1.35
#> 4 IL A 2026-01-01 driver_age driver_age 18 1.18 1.18
#> 5 IL A 2026-01-01 driver_age driver_age 20 1.05 1.05
#> 6 IL A 2026-01-01 driver_age driver_age 52 1.00 1.00The named list is useful even when Excel is not involved. A user may
inspect it, transform it with base R or purrr::map(), write
it to another system, or pass it directly to
rate_tables_long().
back_to_long <- rate_tables_long(wide)
rate_plan_diff(current, back_to_long, include_unchanged = FALSE)
#> [1] state charter rate_eff_date coverage term_name
#> [6] variable1 level1 variable2 level2 current_value
#> [11] proposed_value difference percent_change status
#> <0 rows> (or 0-length row.names)An empty result demonstrates the intended round-trip invariant.
Interactions remain long by default. A selected two-way interaction can instead be shown as a matrix, with one variable down rows and the other across columns. A separate matrix sheet/table is created for each coverage.
wide_matrix <- rate_tables_wide(
current,
matrix_terms = "age_gender_interaction"
)
interaction_names <- grep("age_gender_interaction", names(wide_matrix), value = TRUE)
interaction_names
#> [1] "age_gender_interaction_BI" "age_gender_interaction_CL"
wide_matrix[[interaction_names[1]]]
#> state charter rate_eff_date term_name variable1 level1
#> 11 IL A 2026-01-01 age_gender_interaction driver_age 16
#> 12 IL A 2026-01-01 age_gender_interaction driver_age 17
#> 13 IL A 2026-01-01 age_gender_interaction driver_age 20
#> 43 IL B 2026-01-01 age_gender_interaction driver_age 16
#> 44 IL B 2026-01-01 age_gender_interaction driver_age 17
#> 45 IL B 2026-01-01 age_gender_interaction driver_age 20
#> variable2 F M
#> 11 gender 0.99 1.08
#> 12 gender 0.99 1.05
#> 13 gender 1.00 1.02
#> 43 gender 0.99 1.08
#> 44 gender 0.99 1.05
#> 45 gender 1.00 1.02Three-way and deeper interactions are intentionally prohibited from matrix layout because a two-dimensional spreadsheet representation is no longer unambiguous. They remain in long form.
Excel support is explicit rather than accidental.
write_rate_workbook() writes the named-list representation
to .xlsx, with a visible instruction tab and a very-hidden
machine manifest that records how each sheet should be
reconstructed.
write_rate_workbook(
current,
"IL_rate_revision.xlsx",
filters = list(state = "IL"),
matrix_terms = "age_gender_interaction"
)An actuary can then overwrite factors, add rating-level rows, remove rows, or clear individual coverage cells. On import, a blank factor cell means that factor key is absent for that coverage. Structural column names and the hidden manifest should not be renamed or deleted.
proposed_from_excel <- read_rate_workbook("IL_rate_revision.xlsx")
validate_rate_plan(proposed_from_excel)
rate_plan_diff(current, proposed_from_excel, include_unchanged = FALSE)Use return = "tables" when the desired output is the
named list rather than the normalized long table.
The Excel route and programmatic route are intentionally parallel. Both produce the same normalized factor-table structure for the downstream rater.
Suppose a modeling team delivers a complete new age curve:
new_age <- current[current$term_name == "driver_age", , drop = FALSE]
age <- as.numeric(new_age$level1)
new_age$term_value <- new_age$term_value * ifelse(age < 20, 1.04, .99)
proposed <- replace_factor_set(current, new_age)
head(rate_plan_diff(current, proposed, include_unchanged = FALSE))
#> state charter rate_eff_date coverage term_name variable1 level1 variable2
#> 1 IL A 2026-01-01 BI driver_age driver_age 16 <NA>
#> 2 IL A 2026-01-01 BI driver_age driver_age 17 <NA>
#> 3 IL A 2026-01-01 BI driver_age driver_age 18 <NA>
#> 4 IL A 2026-01-01 BI driver_age driver_age 20 <NA>
#> 5 IL A 2026-01-01 BI driver_age driver_age 52 <NA>
#> 6 IL A 2026-01-01 CL driver_age driver_age 16 <NA>
#> level2 current_value proposed_value difference percent_change status
#> 1 <NA> 1.60 1.6640 0.0640 0.04 changed
#> 2 <NA> 1.35 1.4040 0.0540 0.04 changed
#> 3 <NA> 1.18 1.2272 0.0472 0.04 changed
#> 4 <NA> 1.05 1.0395 -0.0105 -0.01 changed
#> 5 <NA> 1.00 0.9900 -0.0100 -0.01 changed
#> 6 <NA> 1.60 1.6640 0.0640 0.04 changedreplace_factor_set() treats matching slices as complete.
Therefore an omitted level is removed from that slice. This is different
from a sparse update.
one_change <- proposed[
proposed$charter == "A" &
proposed$coverage == "BI" &
proposed$term_name == "territory" &
proposed$level1 == "T4",
, drop = FALSE
]
one_change$term_value <- 1.28
proposed <- update_factors(proposed, one_change)A key that does not already exist is rejected by default. This catches many mistyped levels. A genuinely new rating level must be explicit:
These operations are related but answer different questions.
If age 20 should become the reference level, divide every factor in each applicable rate-set/coverage table by the age-20 factor:
rebased <- rebase_factor_set(
proposed,
term_name = "driver_age",
base_level = "20"
)
rebased[
rebased$term_name == "driver_age" & rebased$level1 == "20",
c("charter", "coverage", "level1", "term_value")
]
#> charter coverage level1 term_value
#> 48 A BI 20 1
#> 53 A CL 20 1
#> 58 B BI 20 1
#> 63 B CL 20 1This preserves pairwise relativities. It does not preserve aggregate premium unless a corresponding rate-level adjustment is made elsewhere.
Normalization instead scales a factor set so its weighted mean equals a target. For example, attach portfolio exposure weights to the territory rows:
territory <- proposed[proposed$term_name == "territory", , drop = FALSE]
territory$exposure <- c(T1 = 40, T2 = 30, T3 = 20, T4 = 8, T5 = 2)[territory$level1]
normalized_territory <- normalize_factor_set(
territory,
term_name = "territory",
weights = "exposure",
target_mean = 1
)
head(normalized_territory)
#> state charter rate_eff_date coverage term_name term_value variable1 level1
#> 2 IL A 2026-01-01 BI territory 0.8916188 territory T1
#> 3 IL A 2026-01-01 BI territory 0.9906875 territory T2
#> 4 IL A 2026-01-01 BI territory 1.0897563 territory T3
#> 5 IL A 2026-01-01 BI territory 1.2680800 territory T4
#> 13 IL A 2026-01-01 CL territory 0.9036885 territory T1
#> 14 IL A 2026-01-01 CL territory 1.0040984 territory T2
#> variable2 level2 exposure factor_row_id
#> 2 <NA> <NA> 40 1
#> 3 <NA> <NA> 30 2
#> 4 <NA> <NA> 20 3
#> 5 <NA> <NA> 8 4
#> 13 <NA> <NA> 40 5
#> 14 <NA> <NA> 30 6The exposure column is analysis metadata and would normally be removed before putting the normalized factor set back into a production factor table.
changes <- rate_plan_diff(current, proposed, include_unchanged = FALSE)
table(changes$status)
#>
#> added changed
#> 1 21
head(changes, 15)
#> state charter rate_eff_date coverage term_name variable1 level1 variable2
#> 1 IL A 2026-01-01 BI driver_age driver_age 16 <NA>
#> 2 IL A 2026-01-01 BI driver_age driver_age 17 <NA>
#> 3 IL A 2026-01-01 BI driver_age driver_age 18 <NA>
#> 4 IL A 2026-01-01 BI driver_age driver_age 20 <NA>
#> 5 IL A 2026-01-01 BI driver_age driver_age 52 <NA>
#> 6 IL A 2026-01-01 BI territory territory T4 <NA>
#> 7 IL A 2026-01-01 CL driver_age driver_age 16 <NA>
#> 8 IL A 2026-01-01 CL driver_age driver_age 17 <NA>
#> 9 IL A 2026-01-01 CL driver_age driver_age 18 <NA>
#> 10 IL A 2026-01-01 CL driver_age driver_age 20 <NA>
#> 11 IL A 2026-01-01 CL driver_age driver_age 52 <NA>
#> 12 IL B 2026-01-01 BI driver_age driver_age 16 <NA>
#> 13 IL B 2026-01-01 BI driver_age driver_age 17 <NA>
#> 14 IL B 2026-01-01 BI driver_age driver_age 18 <NA>
#> 15 IL B 2026-01-01 BI driver_age driver_age 20 <NA>
#> level2 current_value proposed_value difference percent_change status
#> 1 <NA> 1.60 1.6640 0.0640 0.04000000 changed
#> 2 <NA> 1.35 1.4040 0.0540 0.04000000 changed
#> 3 <NA> 1.18 1.2272 0.0472 0.04000000 changed
#> 4 <NA> 1.05 1.0395 -0.0105 -0.01000000 changed
#> 5 <NA> 1.00 0.9900 -0.0100 -0.01000000 changed
#> 6 <NA> 1.20 1.2800 0.0800 0.06666667 changed
#> 7 <NA> 1.60 1.6640 0.0640 0.04000000 changed
#> 8 <NA> 1.35 1.4040 0.0540 0.04000000 changed
#> 9 <NA> 1.18 1.2272 0.0472 0.04000000 changed
#> 10 <NA> 1.05 1.0395 -0.0105 -0.01000000 changed
#> 11 <NA> 1.00 0.9900 -0.0100 -0.01000000 changed
#> 12 <NA> 1.60 1.6640 0.0640 0.04000000 changed
#> 13 <NA> 1.35 1.4040 0.0540 0.04000000 changed
#> 14 <NA> 1.18 1.2272 0.0472 0.04000000 changed
#> 15 <NA> 1.05 1.0395 -0.0105 -0.01000000 changedThe diff classifies factor keys as added,
removed, changed, or unchanged
and reports absolute and percentage changes. This is useful after either
Excel or programmatic revision.
Classification changes often produce some rate effect before the
base-rate change is applied. solve_rate_target() finds one
scalar parameter that makes an arbitrary proposed rating workflow hit a
target aggregate premium.
The simplest example is algebraically obvious but demonstrates the interface:
solution <- solve_rate_target(
current_premium = 1e6,
target = .06,
rate_function = function(x) 980000 * x,
interval = c(.5, 1.5),
parameter_name = "base-rate multiplier"
)
solution
#> <raterevision_target_solution>
#> base-rate multiplier: 1.081632653
#> target change: 6.0000%
#> achieved change: 6.0000%
#> target premium: 1,060,000
#> achieved premium: 1,060,000The important feature is that rate_function() may
contain arbitrary R code. It may create a new ratingtables
spec, rate drivers, aggregate driver factors, join them to vehicles,
rate vehicles, apply minimums/rounding, and finally return premium. Only
the one scalar controlled by x must be identified.
For example, the pattern for a structurally different proposed workflow is:
solution <- solve_rate_target(
current_premium = sum(current_rated$premium),
target = .06,
rate_function = function(x) {
proposed_rated <- completely_new_proposed_workflow(
input_data,
base_multiplier = x
)
proposed_rated$premium
}
)This is why the solver does not accept a special “rater object”: the callback is the abstraction.
Separate implemented territory bases do not require a different
solver when the revision has one intended rate-level degree of freedom.
Put the proposed territorial shape into the callback and
multiply every base by the same x:
current_bases <- c(T1 = 250, T2 = 275, T3 = 300, T4 = 330)
proposed_bases <- c(T1 = 245, T2 = 280, T3 = 315, T4 = 360)
counts <- c(T1 = 400, T2 = 350, T3 = 180, T4 = 70)
territory_solution <- solve_rate_target(
current_premium = sum(current_bases * counts),
target = .08,
rate_function = function(x) proposed_bases * x * counts,
interval = c(.5, 1.5),
parameter_name = "common territory-base multiplier"
)
territory_solution
#> <raterevision_target_solution>
#> common territory-base multiplier: 1.06231738
#> target change: 8.0000%
#> achieved change: 8.0000%
#> target premium: 295,218
#> achieved premium: 295,218
proposed_bases * territory_solution$parameter
#> T1 T2 T3 T4
#> 260.2678 297.4489 334.6300 382.4343The function deliberately does not attempt to solve four independently movable territory bases from one statewide target. That problem has infinitely many solutions unless the actuary provides additional constraints or targets.
A common auto workflow cannot be represented as one call to one policy rater:
drivers
-> rate driver factors
-> average by household/policy
-> join averages to vehicles
-> rate vehicles
-> aggregate/analyze premium
With ratingtables, the orchestration can be an ordinary
R function:
rate_book <- function(inputs, factor_table, base_multiplier = 1) {
factors <- factor_table
factors$term_value[factors$term_name == "base_rate"] <-
factors$term_value[factors$term_name == "base_rate"] * base_multiplier
driver_plan <- ratingtables::new_rating_plan(
driver_factors, driver_spec,
coverages = c("BI", "CL"),
use_rate_set_key = TRUE
)
drivers <- ratingtables::rate_entities(inputs$drivers, driver_plan)
driver_avg <- ratingtables::aggregate_entity_values(
drivers$rated_data,
group_col = "household_id",
value_cols = c("indicated_BI", "indicated_CL"),
aggregation = "mean",
output_names = c("avg_driver_BI", "avg_driver_CL")
)
vehicles <- ratingtables::join_entity_values(
inputs$vehicles, driver_avg, by = "household_id"
)
vehicle_plan <- ratingtables::new_rating_plan(
vehicle_factors, vehicle_spec,
coverages = c("BI", "CL"),
use_rate_set_key = TRUE
)
ratingtables::rate_policies(vehicles, vehicle_plan)
}compare_rating_outputs() does not care how
current_output and proposed_output were
produced, and solve_rate_target() can repeatedly call this
entire function. The repository’s
scripts/03_driver_averaging_rate_change.R and
scripts/04_solve_rate_target.R contain complete runnable
versions of this workflow.
A practical rate revision can therefore follow this pattern:
rate_plan_diff() and
validate_rate_plan() before rating;compare_rating_outputs();The Excel route and programmatic route are intentionally interchangeable. Excel is supported as a first-class actuarial interface, while the canonical data structures remain ordinary R data frames and lists.