Author: Charles Thraves, Pablo Ubilla, Daniel Hermosilla
Date: 2026-05-08

# Demonstration of the package usage

#### Charles Thraves, Pablo Ubilla, Daniel Hermosilla

#### 2026-05-08

The `fastei` library implements the Expectation-Maximization algorithm to estimate probabilities in Ecological Inference for the RxC case. It offers both an exact method and four approximation methods suitable for large datasets. The application demonstrated here is based on an electoral context where, for each ballot box, we know the number of voters in each demographic group and the number of votes received by each candidate.

# Estimate Voting Probabilities

We use data from the First Round of the Chilean Presidential Election of 2021, where at each ballot box we have the voters of eight age ranges (groups), and candidate votes obtained. The function `get_eim_chile()` loads this data at either the country, region, or electoral district level.

    library(fastei)

    eim_apo <- get_eim_chile(elect_district = "APOQUINDO")

    eim_apo

    ## eim ecological inference model
    ## Candidates' vote matrix (X) [b x c]:
    ##     C1  C2 C3 C4 C5 C6 C7 C8
    ## 37M 36  91 19 48  2  7  2  5
    ## 38M 29 103 17 54  1  6  3  3
    ## 39M 32  81 13 73  1  5  5  1
    ## 40M 25  95 21 38  3  4  2  2
    ## 41M 31  86 19 48  0  4  5  2
    ## .
    ## .
    ## .
    ## Group-level voter matrix (W) [b x g]:
    ##     X18.19 X20.29 X30.39 X40.49 X50.59 X60.69 X70.79 X80.
    ## 37M      5     27     11     50     23     39     36   19
    ## 38M      5     33     11     48     22     42     36   19
    ## 39M      4     19     11     71     29     30     35   11
    ## 40M      6     21      6     49     36     20     29   23
    ## 41M      3     27      6     55     23     29     33   19
    ## .
    ## .
    ## .

As shown in the output, it returns an eim object that contains two matrices: the number of votes per candidate and the number of votes per group. The rows of both matrices correspond to the specific ballot-box, and the columns are candidates and groups, respectively. This eim object is used as input to run the EM algorithm that estimates the voting probabilities. In this example, it uses the default method `mult`, which is the most efficient in terms of runtime. Running the algorithm is done by calling `run_em()`.

    eim_apo <- run_em(eim_apo)
    round(eim_apo$prob, 4)

    ##            C1     C2     C3     C4     C5     C6     C7     C8
    ## X18.19 0.1835 0.3760 0.0875 0.3194 0.0108 0.0191 0.0036 0.0000
    ## X20.29 0.2038 0.3398 0.0485 0.3485 0.0087 0.0164 0.0192 0.0151
    ## X30.39 0.1612 0.4154 0.0406 0.3497 0.0032 0.0071 0.0189 0.0039
    ## X40.49 0.0889 0.4940 0.0786 0.3283 0.0022 0.0025 0.0022 0.0034
    ## X50.59 0.1591 0.3954 0.0971 0.2913 0.0005 0.0384 0.0155 0.0027
    ## X60.69 0.0932 0.5093 0.0919 0.2739 0.0011 0.0217 0.0050 0.0039
    ## X70.79 0.1057 0.5332 0.0753 0.2281 0.0072 0.0191 0.0204 0.0110
    ## X80.   0.0659 0.5152 0.1450 0.2147 0.0067 0.0323 0.0112 0.0089

Note that each row corresponds to the probability that a demographic group (`g`) voted for a candidate (`c`). It is worth noting how the estimated probabilities differ substantially across groups.

# Standard deviation estimates

We can compute the standard deviation of the estimated probabilities using bootstrapping. This can be done with the function `bootstrap()`.

    eim_apo <- bootstrap(eim_apo, seed = 42, nboot = 30)

    ## Applying a D'Hondt correction for correcting mismatches in W

    round(eim_apo$sd, 4)

    ##            C1     C2     C3     C4     C5     C6     C7     C8
    ## X18.19 0.0367 0.0880 0.0141 0.0582 0.0052 0.0078 0.0077 0.0018
    ## X20.29 0.0089 0.0115 0.0043 0.0075 0.0013 0.0038 0.0029 0.0014
    ## X30.39 0.0100 0.0120 0.0056 0.0132 0.0010 0.0021 0.0033 0.0015
    ## X40.49 0.0081 0.0144 0.0082 0.0099 0.0014 0.0021 0.0020 0.0019
    ## X50.59 0.0129 0.0213 0.0120 0.0222 0.0008 0.0074 0.0057 0.0015
    ## X60.69 0.0138 0.0270 0.0134 0.0209 0.0011 0.0060 0.0035 0.0029
    ## X70.79 0.0168 0.0287 0.0132 0.0252 0.0039 0.0075 0.0079 0.0049
    ## X80.   0.0211 0.0408 0.0268 0.0385 0.0058 0.0099 0.0060 0.0046

The standard deviations obtained in the district “Apoquindo” are low in general. One reason for this is the high number of ballot boxes in this district. In contrast, standard deviations of estimated probabilities in districts with fewer ballot boxes, such as “Navidad”, are larger.

    eim_nav <- get_eim_chile(elect_district = "NAVIDAD")
    eim_nav <- bootstrap(eim_nav, seed = 42, nboot = 30)

    ## Applying a D'Hondt correction for correcting mismatches in W

    round(eim_nav$sd, 4)

    ##            C1     C2     C3     C4     C5     C6     C7     C8
    ## X18.19 0.1081 0.2733 0.0000 0.3643 0.1387 0.1517 0.2578 0.0842
    ## X20.29 0.1693 0.0807 0.0610 0.1490 0.0349 0.0001 0.1155 0.0056
    ## X30.39 0.1068 0.0879 0.0625 0.0903 0.0206 0.0351 0.0451 0.0106
    ## X40.49 0.1101 0.1331 0.0509 0.0698 0.0105 0.0539 0.0312 0.0072
    ## X50.59 0.0976 0.1096 0.0960 0.0939 0.0015 0.0357 0.0179 0.0268
    ## X60.69 0.1375 0.1530 0.1530 0.1034 0.0169 0.0945 0.0264 0.0001
    ## X70.79 0.1895 0.1811 0.1497 0.0897 0.0242 0.0496 0.0393 0.0000
    ## X80.   0.1827 0.0960 0.2181 0.0632 0.1216 0.2356 0.2762 0.1219

It is possible to see the difference in a visual way by plotting the standard deviations of the estimated probabilities across groups.

    library(ggplot2)
    library(reshape2)
    library(viridis)

    plot_district <- function(matrix1, district1, matrix2, district2, sd = FALSE) {
        value <- ifelse(sd == FALSE, "prob", "sd")
        df1 <- melt(matrix1)
        df2 <- melt(matrix2)
        df1$Matrix <- district1
        df2$Matrix <- district2
        combined_df <- rbind(df1, df2)
        color <- ifelse(value == "prob", "plasma", "viridis")

        # Add text to each cell of the matrix
        combined_df$label <- sprintf("%.3f", combined_df$value)
        combined_df$text_color <- ifelse(combined_df$value > round(max(combined_df$value) * 0.75 + min(combined_df$value) * 0.25, 2), "black", "white")
        districts <- sort(c(district1, district2))

        # Call the plot
        ggplot(combined_df, aes(x = Var2, y = Var1, fill = value)) +
            geom_tile() +
            geom_text(aes(label = label, color = text_color), size = 2.5) +
            scale_fill_viridis(
                name = value,
                option = color,
            ) +
            scale_color_identity() +
            facet_wrap(~Matrix) +
            coord_fixed() +
            theme_bw() +
            labs(
                title = ifelse(value == "prob",
                    paste("Estimated probabilities in districts:", districts[1], "and", districts[2]),
                    paste("Standard deviation of estimated probabilities in districts:", districts[1], "and", districts[2])
                ),
                x = "Candidates' votes", y = "Voters' age range", fill = value
            )
    }

    plot_district(
        matrix1 = eim_nav$sd, district1 = "Navidad",
        matrix2 = eim_apo$sd, district2 = "Apoquindo", sd = TRUE
    )

Navidad and Apoquindo standard deviation comparison

# Reduce Estimation Error using Group Aggregation

Demographic groups can be merged to have probability estimates with lower error. A greedy strategy that maximizes the variability of the distribution of groups across ballot boxes is used, which ensures standard deviations are below a specific threshold. The package provides the following function for the latter:

    eim_nav_proxy <- get_agg_proxy(eim_nav, seed = 6, sd_threshold = 0.03, sd_statistic = "maximum")

    ## Applying a D'Hondt correction for correcting mismatches in W

    eim_nav_proxy$group_agg

    ## [1] 4 8

As shown in the output, the heuristic finds a group aggregation that merges the first and last four age ranges, namely, macro-groups with voters aged 18-49, and older than 50. We can evaluate the effectiveness of this grouping by comparing the mean standard deviation to the original formulation:

    mean(eim_nav$sd) - mean(eim_nav_proxy$sd)

    ## [1] 0.08265811

It is possible to see the aggregated standard deviations visually:

    plot_matrix <- function(mat, sd = FALSE, y_labels = NULL) {
        # Initial configurations
        if (!sd) mat <- t(mat)
        df <- reshape2::melt(mat)
        colnames(df) <- c("Row", "Column", "Value")
        df$Row <- factor(df$Row, levels = rev(sort(unique(df$Row))))
        df$Column <- factor(df$Column, levels = sort(unique(df$Column)))
        if (!sd) {
            df$Label <- sprintf("%d", df$Value)
            title_text <- "Voters distribution"
            x_lab <- "Ballot Box"
            y_lab <- "Dem. Group"
            fill_lab <- "Voters"
            df$text_color <- ifelse(df$Value > 30, "black", "white")
            option <- "inferno"
            start <- 0.5
            limits <- NULL
        } else {
            df$Label <- sprintf("%.3f", df$Value)
            title_text <- "Standard deviation of estimated probabilities on district: Navidad"
            x_lab <- "Candidates' votes"
            y_lab <- "Voters' age range"
            fill_lab <- "sd"
            df$text_color <- ifelse(df$Value > 0.13, "black", "white")
            option <- "viridis"
            start <- 0
            limits <- c(0, 0.1)
        }

        # Plot
        p <- ggplot(df, aes(x = Column, y = Row, fill = Value)) +
            geom_tile() +
            geom_text(aes(label = Label, color = text_color), size = 3) +
            scale_color_identity() +
            scale_fill_viridis_c(option = option, begin = start, limits = limits) +
            coord_fixed() +
            theme_bw() +
            theme(axis.text.y = element_text(size = 7), axis.text.x = element_text(size = 7)) +
            labs(
                title = title_text,
                x = x_lab,
                y = y_lab,
                fill = fill_lab
            )
        # Add custom y-axis labels if provided
        if (!is.null(y_labels)) {
            p <- p + scale_y_discrete(labels = y_labels)
        }
        p
    }

We can visualize the standard deviations of the estimated probabilities.

    plot_matrix(eim_nav_proxy$sd, sd = TRUE, y_labels = c("X18.49", "X50."))

Navidad aggregated standard deviation with proxy method

An exhaustive algorithm is also included in the package. It explores all combinations of adjacent groups in order to maximize the log-likelihood subject to having standard deviations below a given threshold. It might require substantial computation time; therefore it is recommended to use the default method, “mult”.

    eim_nav_opt <- get_agg_opt(eim_nav, seed = 0, sd_threshold = 0.03, sd_statistic = "maximum")

    ## Applying a D'Hondt correction for correcting mismatches in W

    eim_nav_opt$group_agg

    ## [1] 4 8

The optimal group aggregation differs slightly from one obtained before. In this case, the group aggregation consists in the three age range: 18-29, 30-49, and older than 70.

We can visualize the standard deviations of the estimated probabilities.

    plot_matrix(eim_nav_opt$sd, sd = TRUE, y_labels = c("X18.29", "X30.49", "X50."))

Navidad aggregated standard deviation with opt method

# Test difference between estimates

A relevant question is how significantly different are the probability estimates of two sets of data, such as two different districts. For instance, we can compare the estimated probabilities of the district “LO BARNECHEA” and “LA GRANJA”, whose voting tendencies are expected to differ.

    eim_gra <- get_eim_chile("LA GRANJA")
    eim_gra <- run_em(eim_gra)
    eim_bar <- get_eim_chile("LO BARNECHEA")
    eim_bar <- run_em(eim_bar)

    plot_district(eim_gra$prob, "La Granja", eim_bar$prob, "Lo Barnechea")

Lo Barnechea and La Granja comparison

A Wald test can be applied to each component of the two probability matrix estimates:

    comparison2 <- waldtest(
        object1 = eim_gra,
        object2 = eim_bar,
        method = "mult",
        nboot = 30,
        seed = 42,
    )

    ## Applying a D'Hondt correction for correcting mismatches in W
    ## Applying a D'Hondt correction for correcting mismatches in W

    round(comparison2$pvals, 3)

    ##           C1    C2    C3    C4    C5    C6    C7    C8
    ## X18.19 0.000 0.000 0.285 0.000 0.657 0.078 0.521 0.870
    ## X20.29 0.000 0.000 0.007 0.000 0.000 0.000 0.000 0.849
    ## X30.39 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.010
    ## X40.49 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000
    ## X50.59 0.000 0.000 0.000 0.000 0.217 0.000 0.001 0.182
    ## X60.69 0.005 0.000 0.003 0.000 0.095 0.000 0.744 0.623
    ## X70.79 0.815 0.007 0.001 0.556 0.508 0.978 0.024 0.627
    ## X80.   0.821 0.179 0.078 0.687 0.890 0.316 0.258 0.545

Conversely, one may examine the alternative scenario in which ballot boxes within a given region are partitioned into equally sized groups. In this configuration, both groups are expected to exhibit identical probability distributions.

    eimRM <- get_eim_chile(region = "METROPOLITANA DE SANTIAGO")

    n <- nrow(eimRM$X)
    n_train <- floor(n * 0.5)
    train_indices <- sample(seq_len(n), n_train)

    eimRMhalf1 <- eim(X = eimRM$X[train_indices, ], W = eimRM$W[train_indices, ])
    eimRMhalf2 <- eim(X = eimRM$X[-train_indices, ], W = eimRM$W[-train_indices, ])

    eimRMhalf1 <- run_em(eimRMhalf1)
    eimRMhalf2 <- run_em(eimRMhalf2)

    plot_district(eimRMhalf1$prob, "Half 1", eimRMhalf2$prob, "Half 2")

Equally sorted districts comparison

    comparison2 <- waldtest(
        object1 = eimRMhalf1,
        object2 = eimRMhalf2,
        nboot = 10,
        seed = 42
    )

    ## Applying a D'Hondt correction for correcting mismatches in W
    ## Applying a D'Hondt correction for correcting mismatches in W

    round(comparison2$pvals, 4)

    ##            C1     C2     C3     C4     C5     C6     C7     C8
    ## X18.19 0.1066 0.1804 0.9452 0.0486 0.0619 0.5606 0.3286 0.3946
    ## X20.29 0.0251 0.0585 0.1945 0.1309 0.3871 0.2608 0.8356 0.0418
    ## X30.39 0.0163 0.0700 0.8288 0.5985 0.9211 0.9820 0.9688 0.5702
    ## X40.49 0.9854 0.7308 0.7304 0.0735 0.0207 0.0120 0.3969 0.1973
    ## X50.59 0.6462 0.4603 0.4713 0.6301 0.6732 0.7900 0.4769 0.6751
    ## X60.69 0.0735 0.0035 0.6508 0.6630 0.7413 0.5764 0.2517 0.0460
    ## X70.79 0.8307 0.7197 0.7937 0.4507 0.2104 0.3245 0.9197 0.0301
    ## X80.   0.8918 0.6069 0.4835 0.6386 0.8000 0.9034 0.4232 0.1012

# Simulating Election Results

It is possible to simulate an artificial election and get its values as an eim object with the simulated probability. This is useful for testing the package’s performance and comparing the different methods available.

    eim_sim <- simulate_election(num_ballots = 15, num_groups = 2, num_candidates = 3, seed = 42)
    eim_sim

    ## eim ecological inference model
    ## Candidates' vote matrix (X) [b x c]:
    ##      [,1] [,2] [,3]
    ## [1,]   23   31   46
    ## [2,]   22   35   43
    ## [3,]   30   20   50
    ## [4,]   24   35   41
    ## [5,]   24   29   47
    ## .
    ## .
    ## .
    ## Group-level voter matrix (W) [b x g]:
    ##      [,1] [,2]
    ## [1,]   75   25
    ## [2,]   77   23
    ## [3,]   73   27
    ## [4,]   80   20
    ## [5,]   73   27
    ## .
    ## .
    ## .

The real voting probabilities of each group for each candidate can be obtained as follows:

    eim_sim$real_prob

    ##           [,1]      [,2]      [,3]
    ## [1,] 0.2588692 0.3615164 0.3796145
    ## [2,] 0.2913730 0.1829977 0.5256293

In this case, we can access these values since this is a simulation; however, this is not possible when working with real data.

The estimated voting probabilities are:

    eim_sim <- run_em(eim_sim)
    eim_sim$prob

    ##           [,1]      [,2]      [,3]
    ## [1,] 0.2391396 0.3308645 0.4299960
    ## [2,] 0.3021938 0.1811355 0.5166707

It is possible to provide a specific voting probability for each candidate for each group.

    input_probability <- matrix(c(0.9, 0.05, 0.05, 0.2, 0.3, 0.5), nrow = 2, byrow = TRUE)
    input_probability

    ##      [,1] [,2] [,3]
    ## [1,]  0.9 0.05 0.05
    ## [2,]  0.2 0.30 0.50

    eim_sim2 <- simulate_election(
        num_ballots = 30, num_groups = 2, num_candidates = 3, seed = 42,
        prob = input_probability
    )
    eim_sim2

    ## eim ecological inference model
    ## Candidates' vote matrix (X) [b x c]:
    ##      [,1] [,2] [,3]
    ## [1,]   69   12   19
    ## [2,]   75    5   20
    ## [3,]   68   13   19
    ## [4,]   64   16   20
    ## [5,]   72   10   18
    ## .
    ## .
    ## .
    ## Group-level voter matrix (W) [b x g]:
    ##      [,1] [,2]
    ## [1,]   79   21
    ## [2,]   78   22
    ## [3,]   73   27
    ## [4,]   70   30
    ## [5,]   73   27
    ## .
    ## .
    ## .

    eim_sim2$real_prob

    ##      [,1] [,2] [,3]
    ## [1,]  0.9 0.05 0.05
    ## [2,]  0.2 0.30 0.50

There is a parameter, lambda, that controls for the heterogeneity of voters’ groups across ballot boxes. A value of lambda = 0 indicates that voters are all randomly assigned in ballot boxes; in contrast to lambda = 1, in which case voters are assigned in ballot boxes according to their demographic group. This is explained in detail in the documentation in `simulate_election()`.

    eim_sim3 <- simulate_election(
        num_ballots = 20, num_groups = 4, num_candidates = 2, seed = 42,
        lambda = 0.1
    )

    plot_matrix(eim_sim3$W)

Voters’ heatmap for a low lambda value

Having the following estimated probabilities:

    run_em(eim_sim3)$prob

    ##           [,1]      [,2]
    ## [1,] 0.3685063 0.6314937
    ## [2,] 0.6627709 0.3372291
    ## [3,] 0.2102872 0.7897128
    ## [4,] 0.1804356 0.8195644

On the other side, it is possible to simulate an heterogeneous sample

    eim_sim4 <- simulate_election(
        num_ballots = 20, num_groups = 4, num_candidates = 2, seed = 42,
        lambda = 0.9
    )

    plot_matrix(eim_sim4$W)

Voters’ heatmap for a high lambda value

With the underlying probabilities

    run_em(eim_sim4)$prob

    ##           [,1]       [,2]
    ## [1,] 0.3503533 0.64964674
    ## [2,] 0.5082051 0.49179489
    ## [3,] 0.9715275 0.02847253
    ## [4,] 0.8959142 0.10408583