MitoPerturbSeq heteroplasmy analysis

Author

Abhilesh Dhawanjewar

Published

November 27, 2025

Load and preprocess data
# Load data
mtDNA_data <- read.csv(
    here(data_dir, "integrated_dataset_metadata.csv"))
# There are 76 rows with heteroplasmy values as NAs

# Load mixscape data
mixscape_data <- read.csv(
    here(data_dir, "integrated_mixscape_metadata.csv"))

# This function predicts technical measurement variance based on mtDNA depth and is used for empirical weighting in linear models
het_sim_df <- readRDS(
    here(data_dir, "het_depth_sim.rds")
)
get_measurement_variance <- readRDS(
    here(data_dir, "measurement_variance_function.rds")
)

# Change labels for "Park2" and "Nontargeting"
mtDNA_data$top_guide_final_enrich_combined <-   mtDNA_data$top_guide_final_enrich_combined %>%
    replace(mtDNA_data$top_guide_final_enrich_combined == "Park2", "Prkn") %>%
    replace(mtDNA_data$top_guide_final_enrich_combined == "Nontargeting", "NT")

gRNA_order <- c(
        "Akap1",
        "Nnt",
        "Polg",
        "Tfam",
        "Dnm1l",
        "Mtfp1",
        "Opa1",
        "Snx9",
        "Atg5",
        "Oma1",
        "Pink1",
        "Ppargc1a",
        "Prkn",
        "Eomes",
        "Neurod1",
        "Olig1",
        "Opn4",
        "Rgr",
        "Rrh",
        "NT"
    )

# Order the factor levels
mtDNA_data$top_guide_final_enrich_combined <- factor(
    mtDNA_data$top_guide_final_enrich_combined,
    levels = gRNA_order
)

mtDNA_data_by_gRNA <- mtDNA_data %>%
    group_by(top_guide_final_enrich_combined) %>%
    dplyr::summarise(
        mean_het = mean(Heteroplasmy_all_5024, na.rm = TRUE),
        var_het = var(Heteroplasmy_all_5024, na.rm = TRUE),
        mean_depth = mean(mtDNA_depth, na.rm = TRUE),
        n_cells = n()
    ) %>%
    arrange(desc(mean_het))

# Add group labels to the gRNA genes
mtDNA_data_by_gRNA <- mtDNA_data_by_gRNA %>%
    dplyr::mutate(group = top_guide_final_enrich_combined)

mtDNA_data_by_gRNA$group <- factor(
    mtDNA_data_by_gRNA$group,
    levels = gRNA_order
)

mtDNA_data_by_gRNA <- mtDNA_data_by_gRNA %>%
    dplyr::mutate(func_group = case_when(
        group %in% c("Akap1", "Nnt", "Polg", "Tfam") ~ "Maintanence Genes",
        group %in% c("Dnm1l", "Mtfp1", "Opa1", "Snx9") ~ "Fission/Fusion",
        group %in% c("Atg5", "Oma1", "Pink1", "Ppargc1a", "Prkn") ~ "Mitophagy",
        group %in% c("Eomes", "Neurod1", "Olig1", "Opn4", "Rgr", "Rrh", "NT") ~ "Control"
    ))

# Add a grouping variable to the gRNA genes
mtDNA_data <- mtDNA_data %>%
    dplyr::mutate(group = case_when(
        top_guide_final_enrich_combined %in% c("Akap1", "Nnt", "Polg", "Tfam") ~ "Maintanence Genes",
        top_guide_final_enrich_combined %in% c("Dnm1l", "Mtfp1", "Opa1", "Snx9") ~ "Fission/Fusion",
        top_guide_final_enrich_combined %in% c("Atg5", "Oma1", "Pink1", "Ppargc1a", "Prkn") ~ "Mitophagy",
        top_guide_final_enrich_combined %in% c("Eomes", "Neurod1", "Olig1", "Opn4", "Rgr", "Rrh", "NT") ~ "Control"
    ))

mtDNA_data$group <- factor(
    mtDNA_data$group,
    levels = c("Maintanence Genes", "Fission/Fusion", "Mitophagy", "Control")
)

# Define colors for the gRNAs
col_pal <- paletteer_d("ggsci::category20_d3")
names(col_pal) <- gRNA_order

# Highlight color
custom_salmon <- saturation("#FA8072", 0.4)

Distributions of mtDNA depth and heteroplasmy

Figure 1: Ridge plots showing heteroplasmy distributions across gRNAs. TFAM, POLG, and OPA1 show broader distributions.

Applying mtDNA depth cutoff > 20

In-silico modelling of heteroplasmy calling based on sub-sampling of a simulated heteroplasmic mtDNA population suggested an increase in sampling noise at mtDNA depths < 20. However, the most dramatic perturbations (TFAM, POLG, OPA1) also exhibit significant mtDNA copy number reduction.

Figure 2: Number of cells and proportion passing mtDNA depth > 20 threshold by gRNA. Top panel shows total cells (blue) and cells passing threshold (green). Bottom panel shows proportion passing threshold. TFAM, POLG, and OPA1 show the largest drops in cells passing threshold.

The gRNAs associated with mtDNA copy number reduction also show the greatest drop of cells passing the mtDNA depth > 20 threshold.

gRNA Total Cells Cells Passing Threshold Proportion Passing
Tfam 271 50 0.18
Polg 287 103 0.36
Opa1 307 153 0.5

Applying a read depth threshold increases the accuracy of the per-cell heteroplasmy calls, which is an important consideration for downstream analyses that aim to identify subtle shifts in mean heteroplasmy. However, in the context of gene perturbations that result in a depletion of mtDNA copy number, it also risks losing genuine biological signal by excluding the cells with the most pronounced perturbations.

Heteroplasmy variance by gRNA

Figure 3: Bar plot showing the variance of heteroplasmy levels across different gRNA perturbations.

We used the Brown-Forsythe test to compare heteroplasmy variance between each gRNA and the NT cells. P-values were adjusted for multiple testing using the Holm method. Significant results (p.adj.holm < 0.05) are highlighted in the table below.

Pairwise Brown-Forsythe test for heteroplasmy variance between each gRNA and Non-targeting cells
run_bf_analysis <- function(data,
                            group_col = "top_guide_final_enrich_combined",
                            value_col = "Heteroplasmy_all_5024",
                            control_label = "NT") {

    # Isolate Control Data
    nt_data <- data %>% filter(.data[[group_col]] == control_label)

    # Global Safety Check: If control has too few points, abort early
    if (nrow(nt_data) < 2) {
        warning("Control group has insufficient data (<2 rows). Returning NULL.")
        return(NULL)
    }

    # Identify Test Groups
    test_data <- data %>% filter(.data[[group_col]] != control_label)
    test_groups <- unique(test_data[[group_col]])

    # Run Tests
    map_dfr(test_groups, function(gRNA) {

        # Isolate current target
        current_target <- test_data %>% filter(.data[[group_col]] == gRNA)

        # Local Safety Check
        if (nrow(current_target) < 2) {
            return(data.frame(gRNA = as.character(gRNA), p_value = NA))
        }

        pair_data <- bind_rows(current_target, nt_data) %>%
            mutate(Group = factor(
                ifelse(.data[[group_col]] == gRNA,
                "Target", "Control")))

        # Levene's Test (Brown-Forsythe center=median)
        bf_test <- tryCatch({
            leveneTest(
                as.formula(paste(value_col, "~ Group")),
                data = pair_data,
                center = median)
        }, error = function(e) NULL)

        p_val <- if (!is.null(bf_test)) bf_test$`Pr(>F)`[1] else NA

        data.frame(gRNA = as.character(gRNA), p_value = p_val)

    }) %>%
        filter(!is.na(p_value)) %>%
        mutate(
        p.adj.holm = p.adjust(p_value, method = "holm"),
        significant = p.adj.holm < 0.05
        ) %>%
        arrange(p.adj.holm)
    }


# Base cleaning
clean_mtDNA <- mtDNA_data %>%
    drop_na(Heteroplasmy_all_5024)

# Create the subset for the cutoff (Depth > 20)
clean_mtDNA_cutoff <- clean_mtDNA %>%
    filter(mtDNA_depth > 20)

# Run without cutoff
bf_results_no_cutoff <- run_bf_analysis(clean_mtDNA)

# Run with cutoff
bf_results_w_cutoff <- run_bf_analysis(clean_mtDNA_cutoff)
Pairwise Brown-Forsythe Tests
No Cutoff
With Cutoff (Depth > 20)
gRNA
p-value
p.adj (Holm)
p-value
p.adj (Holm)
Tfam 1.74e-23 3.31e-22 4.56e-02 8.67e-01
Polg 9.89e-10 1.78e-08 3.55e-01 1.00e+00
Opa1 3.98e-04 6.76e-03 7.00e-01 1.00e+00
Neurod1 6.15e-02 9.83e-01 1.54e-01 1.00e+00
Dnm1l 2.61e-01 1.00e+00 2.03e-01 1.00e+00
Akap1 4.59e-01 1.00e+00 9.48e-01 1.00e+00
Prkn 5.85e-01 1.00e+00 7.81e-01 1.00e+00
Oma1 8.84e-01 1.00e+00 9.83e-01 1.00e+00
Mtfp1 1.13e-01 1.00e+00 7.97e-01 1.00e+00
Pink1 1.76e-01 1.00e+00 9.59e-02 1.00e+00
Opn4 1.83e-01 1.00e+00 2.87e-01 1.00e+00
Ppargc1a 3.26e-01 1.00e+00 9.94e-01 1.00e+00
Olig1 2.32e-01 1.00e+00 7.07e-01 1.00e+00
Rgr 7.64e-01 1.00e+00 3.86e-01 1.00e+00
Atg5 5.75e-01 1.00e+00 1.84e-01 1.00e+00
Nnt 4.93e-01 1.00e+00 6.05e-01 1.00e+00
Eomes 8.42e-01 1.00e+00 9.64e-01 1.00e+00
Rrh 2.50e-01 1.00e+00 2.51e-01 1.00e+00
Snx9 7.64e-01 1.00e+00 6.15e-01 1.00e+00


The gRNA KO groups for Tfam, Opa1, Polg, and Dnm1l show significantly increased heteroplasmy variance compared to Control gRNAs (Holm-adjusted p-value < 0.05).

Heteroplasmy variance by Mixscape class

Mixscape is a computational method that classifies cells as knockout (KO) or non-perturbed (NP) based on their transcriptional perturbation signature. This classification allows us to distinguish cells with successful gene knockdowns from cells that received the gRNA but show no transcriptional perturbation, providing an internal control for off-target effects within each gRNA group.

Figure 4: Heteroplasmy variance by Mixscape classification. Bar plot shows variance for knockout (KO) and non-perturbed (NP) cells within each gRNA group. KO cells generally show higher variance than their NP counterparts.
Pairwise Brown-Forsythe test for Mixscape KO vs NP comparison
run_mixscape_bf <- function(data,
                            genes,
                            value_col = "Heteroplasmy_all_5024") {

    map_dfr(genes, function(gene) {

        # Define dynamic labels
        ko_label <- paste(gene, "KO")
        np_label <- paste(gene, "NP")

        # Filter data for this specific pair
        gene_data <- data %>%
            filter(mixscape_class %in% c(ko_label, np_label)) %>%
            mutate(Group = factor(ifelse(
            mixscape_class == ko_label, "KO", "NP"
        )))

        # Safety Check: Need at least 2 observations in BOTH groups
        if (sum(gene_data$Group == "KO") < 2 || sum(gene_data$Group == "NP") < 2) {
            return(data.frame(gRNA = gene, p_value = NA))
        }

        # Levene's Test (Brown-Forsythe center=median)
        bf_test <- tryCatch({
            leveneTest(
                as.formula(paste(value_col, "~ Group")),
                data = gene_data,
                center = median
            )
        }, error = function(e) NULL)

        p_val <- if (!is.null(bf_test)) bf_test$`Pr(>F)`[1] else NA

        data.frame(gRNA = gene, p_value = p_val)

    }) %>%
        filter(!is.na(p_value)) %>%
        mutate(
        p.adj.holm = p.adjust(p_value, method = "holm"),
        significant = p.adj.holm < 0.05
        ) %>%
        arrange(p.adj.holm)
    }

# Define genes of interest
genes_of_interest <- c("Tfam", "Opa1", "Polg", "Atg5")

# Filter Mixscape data to relevant rows and remove NAs
mixscape_clean <- mixscape_data %>%
    filter(str_detect(mixscape_class, paste(genes_of_interest, collapse = "|"))) %>%
    drop_na(Heteroplasmy_all_5024)

# Create the subset for the cutoff (Depth > 20)
mixscape_clean_cutoff <- mixscape_clean %>%
    filter(mtDNA_depth > 20)

# Run without cutoff
mixscape_results_no_cutoff <- run_mixscape_bf(
    data = mixscape_clean,
    genes = genes_of_interest
)

# Run with cutoff
mixscape_results_w_cutoff <- run_mixscape_bf(
    data = mixscape_clean_cutoff,
    genes = genes_of_interest
)
Pairwise Brown-Forsythe Tests: Mixscape KO vs NP
No Cutoff
With Cutoff (Depth > 20)
gRNA
p-value
p.adj (Holm)
p-value
p.adj (Holm)
Polg 4.73e-13 1.89e-12 1.74e-02 6.98e-02
Opa1 6.99e-10 2.10e-09 5.41e-01 8.38e-01
Tfam 4.12e-08 8.23e-08 2.79e-01 8.38e-01
Atg5 9.84e-01 9.84e-01 3.07e-01 8.38e-01

Heteroplasmy variance by mtDNA depth

To quantify the expected range of technical variance as a function of sequencing depth, we performed quantile regression with bootstrap confidence intervals. We binned all cells (excluding TFAM, POLG, and OPA1) into 100 equal-sized depth bins to establish a background variance-depth relationship, while the three target perturbations were each binned into 15 depth bins.

We fit quantile regression models at the 5th and 95th percentiles using a \(\frac{1}{\text{depth}}\) functional form, consistent with binomial sampling theory. To estimate uncertainty, we performed 200 bootstrap resamples of the control bins. The shaded ribbon represents the 95% confidence interval of the 5th-95th percentile range, while the dashed line shows the best-fit 95th percentile (upper bound of technical variance).

Heteroplasmy variance decreases with sequencing depth following a 1/depth relationship predicted by binomial sampling. Gray ribbon shows the 95% confidence interval for the 5th-95th percentile range of technical variance, estimated by bootstrap quantile regression on all cells excluding TFAM, POLG, and OPA1. Gray points represent 100 equal-sized depth bins across the background population. Dashed line shows the best-fit 95th percentile (upper bound of technical variance). Colored points show variance within 15 depth bins for each target perturbation. TFAM, POLG, and OPA1 exhibit variance exceeding the technical baseline, particularly at higher depths where technical variance is minimal.

TFAM, POLG and OPA1 cells follow the same depth-variance trend as the control cells.

Binomial Sampling Simulations

To distinguish technical from biological sources of heteroplasmy variance, we performed binomial sampling simulations to model the expected variance arising purely from technical noise at reduced mtDNA depths.

For each gRNA perturbation, we:

  1. Sampled control cells (without replacement) to match the gRNA group size.
  2. Assigned each sampled cell a sequencing depth at the heteroplasmic sites (m.5024) drawn from the gRNA depth distribution (with replacement).
  3. Simulated alternative allele counts via binomial sampling: \(X_i \sim \text{Binomial}(n = \text{depth}_{\text{5024}, i}, \ p = \text{het}_{\text{control}, i})\)
  4. Calculated simulated heteroplasmy: \(\text{het}_{\text{sim}, i} = X_i / \text{depth}_{\text{5024}, i}\)
  5. Computed variance of simulated heteroplasmies across all cells,
  6. Repeated 5,000 times to generate a null distribution of technical variance

We tested for differences between the observed variance and the simulated null distribution using a two-tailed test:

  • \(H_0\): Observed variance \(=\) Simulated technical variance (technical noise only)
  • \(H_1\): Observed variance \(\neq\) Simulated technical variance (biological effect present)

The empirical p-value was calculated as the proportion of simulations where the absolute difference between the simulated variance and the mean simulated variance was greater than or equal to the absolute difference between the observed variance and the mean simulated variance. P-values were adjusted for multiple comparisons (Bonferroni-Holm) across all perturbation groups.

Control group included cells with the following gRNA: Eomes, Neurod1, Olig1, Opn4, Rgr, Rrh, NT

Function to simulate heteroplasmy variance for a given gRNA perturbation
simulate_het_for_gRNA <- function(
    mtDNA_data,
    gRNA_name,
    n_perms = 5000,
    n_sim_points_to_plot = 2000,
    col_pal = NULL
) {

    # Filter KO and Control data
    KO_df <- mtDNA_data %>%
        dplyr::filter(top_guide_final_enrich_combined == gRNA_name) %>%
        dplyr::select(Heteroplasmy_all_5024, mtDNA_depth, Depth_all_5024) %>%
        dplyr::filter(
            Depth_all_5024 >= 0,
            Heteroplasmy_all_5024 >= 0,
            Heteroplasmy_all_5024 <= 1) %>%
        drop_na() %>%
        dplyr::mutate(group = gRNA_name)

    control_df <- mtDNA_data %>%
        dplyr::filter(group == "Control") %>%
        dplyr::select(Heteroplasmy_all_5024, mtDNA_depth, Depth_all_5024) %>%
        dplyr::filter(
            Depth_all_5024 >= 0,
            Heteroplasmy_all_5024 >= 0,
            Heteroplasmy_all_5024 <= 1) %>%
        drop_na() %>%
        dplyr::mutate(group = "Control")

    # Combine KO and Control data
    sim_input_df <- bind_rows(KO_df, control_df)

    # Get observed stats
    sim_input_stats <- sim_input_df %>%
        group_by(group) %>%
        dplyr::summarise(
            n_cells = n(),
            mean_depth = mean(Depth_all_5024, na.rm = TRUE),
            var_het = var(Heteroplasmy_all_5024, na.rm = TRUE)
        )

    obs_ko_var <- sim_input_stats %>% dplyr::filter(group == gRNA_name) %>% dplyr::pull(var_het)
    obs_control_var <- sim_input_stats %>% dplyr::filter(group == "Control") %>% dplyr::pull(var_het)

    all_sim_var <- numeric(n_perms)
    all_sim_hets_list <- vector("list", n_perms)
    all_input_hets_list <- vector("list", n_perms)

    # Iterate through each permutation
    for (perm in seq_len(n_perms)) {

        # Sample cells from control group equal to the number of cells in KO group
        control_sample <- sim_input_df %>%
            dplyr::filter(group == "Control") %>%
            sample_n(
                min(nrow(control_df), nrow(KO_df)),
                replace = FALSE) %>%
            dplyr::mutate(group = "Control")

        # Sample mtDNA depths from KO group
        sampled_depths <- sample(KO_df$Depth_all_5024,
                                    size = nrow(control_sample),
                                    replace = TRUE)

        # Sample heteroplasmy levels from control group
        sampled_hets <- control_sample$Heteroplasmy_all_5024
        all_input_hets_list[[perm]] <- sampled_hets

        # Simulate mtDNA copy number
        sim_alt_counts <- numeric(nrow(control_sample))
        sim_hets <- rep(NA_real_, nrow(control_sample))
        valid_indices <- which(sampled_depths > 0)

        # Simulate alt counts for valid indices
        # where sampled_depths > 0
        if (length(valid_indices) > 0) {
            sim_alt_counts[valid_indices] <- rbinom(
                n = length(valid_indices),
                size = sampled_depths[valid_indices],
                prob = sampled_hets[valid_indices]
            )
            # Calculate heteroplasmy for valid indices
            sim_hets[valid_indices] <- sim_alt_counts[valid_indices] / sampled_depths[valid_indices]
        }
        all_sim_var[perm] <- var(sim_hets, na.rm = TRUE)
        all_sim_hets_list[[perm]] <- sim_hets[!is.na(sim_hets)]
    }

    # Flatten the list of simulated heteroplasmies
    all_sim_hets_flat <- na.omit(unlist(all_sim_hets_list))

    # Observed KO stats
    obs_ko_homoplasmy_prop <- mean(KO_df$Heteroplasmy_all_5024 %in% c(0,1), na.rm = TRUE)
    obs_ko_mean_het <- mean(KO_df$Heteroplasmy_all_5024, na.rm = TRUE)

    # Simulated stats
    sim_ko_homoplasmy_prop <- mean(all_sim_hets_flat %in% c(0,1), na.rm = TRUE)
    sim_ko_mean_het <- mean(all_sim_hets_flat, na.rm = TRUE)
    sim_ko_var <- var(all_sim_hets_flat, na.rm = TRUE)

    # Empirical p-values

    # Two-tailed: proportion of simulated variances as extreme or more extreme than observed
    mean_sim_var <- mean(all_sim_var)
    p_val_two_tailed <- mean(abs(all_sim_var - mean_sim_var) >= abs(obs_ko_var - mean_sim_var))

    plot_df_hets <- data.frame(
        Heteroplasmy = c(
            control_df$Heteroplasmy_all_5024,
            all_sim_hets_flat,
            KO_df$Heteroplasmy_all_5024
        ),
        Group = factor(c(
            rep("Control", nrow(control_df)),
            rep("Simulated", length(all_sim_hets_flat)),
            rep(gRNA_name, nrow(KO_df))
        ), levels = c("Control", "Simulated", gRNA_name))
    )

    # Subsample points to avoid cluttering
    # Set the number of points to plot for the jitter layer
    n_sim_points <- min(length(all_sim_hets_flat), n_sim_points_to_plot)

    # Filter the main dataframe, subsample only the 'Simulated' group
    jitter_data <- plot_df_hets %>%
        group_by(Group) %>%
        # If the group is 'Simulated', sample n_sim_points, otherwise take all rows
        dplyr::filter(
            if (cur_group()$Group == "Simulated") row_number() %in% sample(row_number(), n_sim_points) else TRUE
        ) %>%
        ungroup()

    # Set colors for Control, Simulated, and gRNA
    if (!is.null(col_pal)) {
        group_colors <- c(
            "Control" = scales::alpha(shades::saturation(col_pal["NT"], 0.8), 0.6),
            "Simulated" = scales::alpha(shades::saturation(col_pal[gRNA_name], 0.4), 0.6),
            gRNA_name = scales::alpha(shades::saturation(col_pal[gRNA_name], 0.8), 0.6)
        )
        names(group_colors)[3] <- gRNA_name
    } else {
        group_colors <- c(
            "Control" = "#2CA02CFF",
            "Simulated" = "#1F77B4FF",
            gRNA_name = "#D62728FF"
        )
        names(group_colors)[3] <- gRNA_name
    }

    het_dist_sim <- ggplot(
        plot_df_hets,
        aes(x = Group, y = Heteroplasmy, fill = Group)
    ) +
        geom_violin(
            data = jitter_data,
            width = 1.2,
            alpha = 0.3,
            color = NA
        ) +
        geom_jitter(
            data = jitter_data,
            width = 0.15,
            size = 1.2,
            alpha = 0.3,
            aes(color = Group)
        ) +
        geom_boxplot(
            data = jitter_data,
            width = 0.2,
            outlier.shape = NA,
            alpha = 0.6,
            position = "identity"
        ) +
        scale_fill_manual(values = group_colors) +
        scale_color_manual(values = group_colors) +
        theme_minimal(base_size = 14) +
        labs(
            title = paste("Distribution of Heteroplasmies (", gRNA_name, " KO Simulation)", sep = ""),
            x = NULL,
            y = "Heteroplasmy (m.5024)"
        ) +
        theme(legend.position = "none")

    return(list(
        plot = het_dist_sim,
        sim_input_stats = sim_input_stats,
        all_sim_var = all_sim_var,
        all_sim_hets_list = all_sim_hets_list,
        obs_ko_mean_het = obs_ko_mean_het,
        obs_ko_var = obs_ko_var,
        obs_ko_homoplasmy_prop = obs_ko_homoplasmy_prop,
        sim_ko_mean_het = sim_ko_mean_het,
        sim_ko_var = sim_ko_var,
        sim_ko_homoplasmy_prop = sim_ko_homoplasmy_prop,
        p_value_two_tailed = p_val_two_tailed
    ))
}
Summary of Observed and Simulated Heteroplasmy Metrics
Mean Heteroplasmy
Heteroplasmy Variance
Homoplasmy Proportion
Two-tailed
Observed
Simulated
Observed
Simulated
Observed
Simulated
p-value
p.adj
Akap1 0.5880217 0.5776135 0.0126753 0.0153655 0.0027100 0.0054710 4.72e-02 7.08e-01
Nnt 0.5915305 0.5775519 0.0154287 0.0164752 0.0068493 0.0096575 4.71e-01 1.00e+00
Polg 0.5810198 0.5777841 0.0408603 0.0379483 0.0921986 0.0821738 4.74e-01 1.00e+00
Tfam 0.5928463 0.5773099 0.0700025 0.0597197 0.1889764 0.1711031 7.06e-02 9.88e-01
Dnm1l 0.5776302 0.5776483 0.0158225 0.0153187 0.0049628 0.0059151 7.00e-01 1.00e+00
Mtfp1 0.5854568 0.5776550 0.0114118 0.0146509 0.0000000 0.0039096 2.02e-02 3.64e-01
Opa1 0.5980599 0.5777005 0.0252051 0.0199621 0.0163399 0.0157379 1.64e-02 3.12e-01
Snx9 0.5909775 0.5775079 0.0139536 0.0156488 0.0035461 0.0070752 2.99e-01 1.00e+00
Atg5 0.5902907 0.5778441 0.0148085 0.0152402 0.0032051 0.0060321 7.75e-01 1.00e+00
Oma1 0.5824195 0.5775616 0.0155447 0.0167409 0.0132626 0.0129183 4.81e-01 1.00e+00
Pink1 0.5771050 0.5775235 0.0128299 0.0156138 0.0042644 0.0070742 2.90e-02 4.93e-01
Ppargc1a 0.5822394 0.5778366 0.0127014 0.0153093 0.0063291 0.0059127 8.28e-02 9.94e-01
Prkn 0.5793903 0.5776031 0.0136172 0.0153762 0.0056818 0.0067324 2.12e-01 1.00e+00
Eomes 0.5688203 0.5775873 0.0151203 0.0157963 0.0042017 0.0080269 7.33e-01 1.00e+00
Neurod1 0.5724236 0.5775879 0.0109754 0.0145406 0.0000000 0.0032497 1.20e-02 2.40e-01
Olig1 0.5817256 0.5775159 0.0117112 0.0147953 0.0000000 0.0037280 3.62e-02 5.79e-01
Opn4 0.5798140 0.5775947 0.0122437 0.0150627 0.0034130 0.0057584 7.14e-02 9.88e-01
Rgr 0.5720849 0.5777302 0.0144841 0.0162845 0.0065789 0.0082980 2.82e-01 1.00e+00
Rrh 0.5795061 0.5775662 0.0132620 0.0165438 0.0080000 0.0113376 1.07e-01 1.00e+00
NT 0.5883744 0.5776616 0.0132568 0.0150399 0.0000000 0.0049557 2.31e-01 1.00e+00
Calculate 95% CI for simulated homoplasmy proportions and assess concordance with observed
# Calculate CI for homoplasmy proportions from simulation iterations
homoplasmy_ci_results <- bind_rows(
    lapply(names(het_sim_results), function(gRNA) {
        res <- het_sim_results[[gRNA]]

        # Calculate homoplasmy proportion for each simulation iteration
        sim_homoplasmy_props <- sapply(res$all_sim_hets_list, function(sim_hets) {
            mean(sim_hets %in% c(0, 1), na.rm = TRUE)
        })

        # Calculate 95% CI
        ci_lower <- quantile(sim_homoplasmy_props, 0.025, na.rm = TRUE)
        ci_upper <- quantile(sim_homoplasmy_props, 0.975, na.rm = TRUE)

        # Check if observed falls within CI
        obs_in_ci <- res$obs_ko_homoplasmy_prop >= ci_lower &
                     res$obs_ko_homoplasmy_prop <= ci_upper

        # Calculate difference between observed and simulated
        diff <- res$obs_ko_homoplasmy_prop - res$sim_ko_homoplasmy_prop

        data.frame(
            gRNA = gRNA,
            obs_homoplasmy = res$obs_ko_homoplasmy_prop,
            sim_homoplasmy_mean = res$sim_ko_homoplasmy_prop,
            sim_homoplasmy_ci_lower = ci_lower,
            sim_homoplasmy_ci_upper = ci_upper,
            obs_in_ci = obs_in_ci,
            diff = diff
        )
    })
)
Figure 5: Forest plot showing concordance between observed and simulated homoplasmy proportions. Points represent observed values, horizontal lines show 95% CI from 5,000 bootstrap simulations. Green points indicate observed values falling within the simulated CI (concordant), red points indicate discordance. gRNAs are ordered by functional group and observed homoplasmy proportion.

The table shows both one-tailed and two-tailed p-values with Holm correction for multiple testing. Raw p-values < 0.05 are highlighted with light shading, while Holm-adjusted p-values < 0.05 are highlighted with darker shading and bold text.

Based on the one-tailed adjusted p-values (testing if observed variance > simulated variance), only Tfam and Opa1 gRNAs show significantly higher heteroplasmy variance compared to the simulated technical variance from the Control group, suggesting these gRNAs have a true biological effect on heteroplasmy variance. The Polg gRNA does not show a significant difference after multiple testing correction.

Figure 6: Comparison of observed and simulated heteroplasmy distributions for TFAM, OPA1, and POLG perturbations. Violin plots show the distribution of heteroplasmy values at m.5024. ‘Observed’ distributions represent actual KO cells. ‘Simulated’ distributions represent technical variance expected from binomial sampling of control cells at KO depth distributions. TFAM and OPA1 show broader observed distributions than simulated, indicating biological variance exceeds technical noise.
Figure 7: Distribution of simulated heteroplasmy variance for TFAM, POLG, and OPA1. Histograms and density curves show 5,000 bootstrap simulations of technical variance for each gRNA. Dashed vertical lines indicate observed variance in KO cells (colored) and control cells (gray). Two-tailed p-values (Holm-adjusted) test whether observed variance differs significantly from simulated technical variance.
Figure 8: Distribution of simulated heteroplasmy variance for all gRNA perturbations. Each row shows the 5,000-simulation null distribution (shaded density) for a gRNA. The white dot indicates the observed variance for that gRNA, while the vertical dashed line shows the observed control variance for reference. Holm-adjusted p-values (2-tailed) are listed on the right.

P-values in the above plot are one-tailed, testing the null hypothesis that the observed heteroplasmy variance in the gRNA KO group is not significantly greater than the distribution of simulated variances derived from the Control group. P-values were adjusted for multiple testing using the Holm method.

R version 4.4.2 (2024-10-31)
Platform: aarch64-apple-darwin20
Running under: macOS 26.1

Matrix products: default
BLAS:   /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRblas.0.dylib 
LAPACK: /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.0

locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8

time zone: Europe/London
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] car_3.1-3           carData_3.0-5       kableExtra_1.4.0.19
 [4] knitr_1.50          paletteer_1.6.0     plotly_4.11.0      
 [7] shades_1.4.0        scales_1.4.0        ggrepel_0.9.6      
[10] quantreg_6.1        SparseM_1.84-2      ggridges_0.5.6     
[13] patchwork_1.3.1     broom_1.0.9         lubridate_1.9.4    
[16] forcats_1.0.0       dplyr_1.1.4         purrr_1.1.0        
[19] readr_2.1.5         tidyr_1.3.1         tibble_3.3.0       
[22] ggplot2_3.5.2       tidyverse_2.0.0     stringr_1.5.1      
[25] here_1.0.1          conflicted_1.2.0   

loaded via a namespace (and not attached):
 [1] gtable_0.3.6       xfun_0.52          htmlwidgets_1.6.4  lattice_0.22-6    
 [5] tzdb_0.5.0         vctrs_0.6.5        tools_4.4.2        generics_0.1.4    
 [9] pkgconfig_2.0.3    Matrix_1.7-1       data.table_1.17.8  RColorBrewer_1.1-3
[13] lifecycle_1.0.4    compiler_4.4.2     farver_2.1.2       textshaping_1.0.1 
[17] MatrixModels_0.5-4 htmltools_0.5.8.1  yaml_2.3.10        lazyeval_0.2.2    
[21] Formula_1.2-5      pillar_1.11.0      MASS_7.3-61        cachem_1.1.0      
[25] abind_1.4-8        tidyselect_1.2.1   digest_0.6.37      stringi_1.8.7     
[29] rematch2_2.1.2     labeling_0.4.3     splines_4.4.2      rprojroot_2.1.0   
[33] fastmap_1.2.0      grid_4.4.2         cli_3.6.5          magrittr_2.0.3    
[37] survival_3.7-0     withr_3.0.2        backports_1.5.0    timechange_0.3.0  
[41] rmarkdown_2.29     httr_1.4.7         ragg_1.4.0         hms_1.1.3         
[45] memoise_2.0.1      evaluate_1.0.4     viridisLite_0.4.2  rlang_1.1.6       
[49] Rcpp_1.1.0         glue_1.8.0         xml2_1.3.8         svglite_2.2.1     
[53] rstudioapi_0.17.1  jsonlite_2.0.0     R6_2.6.1           prismatic_1.1.2   
[57] systemfonts_1.2.3