Group members:
library(data.table)
library(dplyr)
library(ggplot2)
library(splines)
library(tidyr)
library(patchwork)
library(data.table)
library(dplyr)
library(ggplot2)
library(splines)
# Load chromosome 1 sequence from 5 parts
load_rda_string <- function(path) {
obj_name <- load(path)
get(obj_name)
}
s1 <- load_rda_string("C:/Users/97250/Downloads/chr1_str_0M_50M.rda")
s2 <- load_rda_string("C:/Users/97250/Downloads/chr1_str_50M_100M.rda")
s3 <- load_rda_string("C:/Users/97250/Downloads/chr1_str_100M_150M.rda")
s4 <- load_rda_string("C:/Users/97250/Downloads/chr1_str_150M_200M.rda")
s5 <- load_rda_string("C:/Users/97250/Downloads/chr1_str_200M_end.rda")
chr1_seq <- paste0(s1, s2, s3, s4, s5)
chr1_full <- strsplit(chr1_seq, "")[[1]] # full sequence
In this lab, we explored different ways to estimate copy number variation (CNV) from sequencing data using coverage and GC content information. Starting from cleaned data of tumor and normal samples, we revisited the GC bias correction we implemented in Lab 7 and applied it to compare multiple models.
We focused on specific regions of chromosome 1 (like 25M–30M and 75M–80M), and created visualizations to analyze how well each model captured potential CNV signals. Along the way, we learned how normalization, GC correction, and comparison to normal samples each impact the reliability of the copy number estimates.
To start this part of the lab, we reconstructed the full chromosome 1
sequence by loading five .rda files and combining them into
a single character vector of bases. Using this, we calculated GC content
and coverage across 5K bins for both the tumor and normal samples based
on their read mapping files. We then built a general function that
outputs a clean data frame with these values. Building on what we did in
Lab 7, we added several improvements: we filtered out noisy bins using
LOESS smoothing and IQR thresholds, removed the top 2% of extreme
residuals, and fitted a cubic spline with five knots to estimate the
expected coverage function \(f(gc)\).
Finally, we used that to compute the estimated copy number per bin \(a_i = \frac{Y_i}{f(gc_i)}\). This entire process was done separately for each sample to account for differences in GC bias, and now we’re ready to begin the comparative analysis.
# General function: load reads and compute GC + Coverage
prepare_raw_gc_coverage <- function(reads_file, chr1_full, bin_size = 5000, sample_label = "Sample") {
reads <- fread(reads_file)
colnames(reads) <- c("Chrom", "Loc", "FragLen")
n_bins <- floor(length(chr1_full) / bin_size)
breaks <- c(seq(0, length(chr1_full) + bin_size, by = bin_size))
coverage <- hist(reads$Loc, breaks = breaks, plot = FALSE)$counts[1:n_bins]
GC_per_bin <- sapply(1:n_bins, function(i) {
bin <- chr1_full[((i - 1) * bin_size + 1):(i * bin_size)]
sum(bin %in% c("G", "C")) / bin_size
})
data.frame(
Bin = seq_len(n_bins),
Coverage = coverage,
GC_Content = GC_per_bin,
Cell_Size = bin_size,
Sample = sample_label
)
}
# Process sample: clean + spline + compute aᵢ
process_sample <- function(df_raw) {
df <- df_raw %>%
filter(GC_Content >= 0.3, Coverage > 0)
# LOESS fit
loess_fit <- loess(Coverage ~ GC_Content, data = df, span = 0.5)
df$LOESS_Fit <- predict(loess_fit)
df$Residual <- df$Coverage - df$LOESS_Fit
# IQR filtering
iqr_val <- IQR(df$Residual, na.rm = TRUE)
q1 <- quantile(df$Residual, 0.25, na.rm = TRUE)
q3 <- quantile(df$Residual, 0.75, na.rm = TRUE)
lower_bound <- q1 - 3.25 * iqr_val
upper_bound <- q3 + 3.25 * iqr_val
df <- df %>% filter(Residual >= lower_bound, Residual <= upper_bound)
# Remove top 2% of absolute residuals
threshold <- quantile(abs(df$Residual), 0.98)
df <- df %>% filter(abs(Residual) <= threshold)
# Fit spline
knots_gc <- quantile(df$GC_Content, probs = seq(0.2, 0.8, length.out = 5))
fit <- lm(Coverage ~ bs(GC_Content, knots = knots_gc, degree = 3), data = df)
f_fun <- function(gc) {
pmax(predict(fit, newdata = data.frame(GC_Content = gc)), 0)
}
# Compute aᵢ
df <- df %>%
mutate(
f_hat = f_fun(GC_Content),
a_i = Coverage / f_hat
)
return(df)
}
# Run for tumor and normal samples
tumor_raw <- prepare_raw_gc_coverage(
reads_file = "C:/Users/97250/Downloads/TCGA-13-0723-01A_lib1_all_chr1.forward",
chr1_full = chr1_full,
bin_size = 5000,
sample_label = "Tumor"
)
normal_raw <- prepare_raw_gc_coverage(
reads_file = "C:/Users/97250/Downloads/TCGA-13-0723-10B_lib1_all_chr1.forward",
chr1_full = chr1_full,
bin_size = 5000,
sample_label = "Normal"
)
# Clean and compute aᵢ
tumor_df <- process_sample(tumor_raw)
normal_df <- process_sample(normal_raw)
# Combine for further analysis
combined_df <- bind_rows(tumor_df, normal_df)
# Summary
cat(
"- Tumor bins:", nrow(tumor_df), "\n",
"- Normal bins:", nrow(normal_df), "\n",
"- Combined range of aᵢ:", round(range(combined_df$a_i), 2), "\n")
## - Tumor bins: 42200
## - Normal bins: 42474
## - Combined range of aᵢ: 0.28 1.6
After running the full preprocessing pipeline on both the tumor and normal datasets, we ended up with around 42,000 clean bins in each sample, which gives us a detailed view of chromosome 1 with high resolution.
The estimated copy numbers \(a_i\) ranged from 0.28 to 1.6, which makes sense biologically—most values are centered near 1, as expected for diploid regions, but we also see lower and higher values that could reflect real deletions or amplifications, especially in the tumor sample.
This step was super important because now we have clean data that’s been corrected for GC bias in a sample-specific way, and for every bin we know how much the actual coverage deviates from what we’d expect based on its GC content.
We also merged the two datasets into one combined data frame
(combined_df) so we can compare the tumor and normal
samples side by side in upcoming analyses, like plotting copy number
along specific genomic regions or checking for patterns of change
between the two. Overall, we’re now fully ready to dive into the
comparison stage.
Before diving into model comparisons, we first looked at how coverage
depends on GC content in each sample.
To make the tumor and normal samples comparable on the same scale, we
normalized the normal sample’s coverage so that its
median matches the tumor’s.
This adjustment lets us focus on the shape of the GC bias
without being misled by differences in overall sequencing depth.
library(ggplot2)
library(dplyr)
# Calculate median coverage for tumor and normal
tumor_median <- median(tumor_raw$Coverage, na.rm = TRUE)
normal_median <- median(normal_raw$Coverage, na.rm = TRUE)
# Compute normalization factor
normalization_factor <- tumor_median / normal_median
# Adjust normal coverage by multiplying with the factor
combined_df <- combined_df %>%
mutate(Coverage_adj = ifelse(Sample == "Normal", Coverage * normalization_factor, Coverage))
# Plot adjusted LOESS smoothed coverage vs GC
ggplot(combined_df, aes(x = GC_Content, y = Coverage_adj)) +
geom_point(aes(color = Sample), alpha = 0.3, size = 0.7) +
geom_smooth(data = combined_df %>% filter(Sample == "Tumor"),
aes(x = GC_Content, y = Coverage_adj),
method = "loess", se = FALSE, span = 0.4,
color = "#e41a1c", size = 1.2) +
geom_smooth(data = combined_df %>% filter(Sample == "Normal"),
aes(x = GC_Content, y = Coverage_adj),
method = "loess", se = FALSE, span = 0.4,
color = "black", size = 1.2) +
scale_color_manual(values = c("Tumor" = "#e41a1c", "Normal" = "#377eb8")) +
labs(
title = "GC Effect on Coverage (After Median Normalization)",
subtitle = "LOESS curves aligned to tumor coverage scale",
x = "GC Content",
y = "Normalized Coverage",
color = "Sample"
) +
theme_minimal(base_size = 14) +
theme(
plot.title = element_text(face = "bold", hjust = 0.5),
plot.subtitle = element_text(hjust = 0.5),
legend.position = "top"
)
The updated plot shows that both samples follow a similar GC bias
trend:
coverage increases with GC content up to around 0.5–0.55 and then
drops.
After aligning their medians, we see that the tumor sample still
has higher normalized coverage, especially in GC-rich
regions.
This suggests that even after adjusting for sequencing depth,
there are likely real biological differences between
the samples — possibly due to true copy number variation.
It also reinforces why GC correction is so important in
downstream CNV analysis.
For model A, we analyzed the tumor sample without applying any GC correction. We focused on two specific genomic regions: 25M–30M and 75M–80M on chromosome 1. To visualize them together, we extracted the coverage values for each bin, normalized them by dividing by the median coverage within their respective region, and then combined the two regions into a single continuous plot.
We also added a dashed vertical line to clearly separate the two regions within the same plot. This way, we can easily compare the two regions side by side and look for potential shifts that may suggest copy number alterations, even before applying any GC-related bias correction.
# Define bin indices for selected regions
region1_bins <- 10001:12000 # for 25M–30M
region2_bins <- 30001:32000 # for 75M–80M
# Filter and combine regions from the cleaned combined_df
region1_df <- combined_df %>% filter(Bin %in% region1_bins)
region2_df <- combined_df %>% filter(Bin %in% region2_bins)
# Combine both and label the regions
analysis_df <- bind_rows(region1_df, region2_df) %>%
mutate(Region = ifelse(Bin %in% region1_bins, "25M–30M", "75M–80M"))
library(ggplot2)
library(dplyr)
# Prepare single continuous index across both regions
model_a_single_plot <- analysis_df %>%
filter(Sample == "Tumor") %>%
filter(Bin %in% c(region1_bins, region2_bins)) %>%
mutate(
Region = ifelse(Bin %in% region1_bins, "25M-30M", "75M-80M")
) %>%
group_by(Region) %>%
mutate(
Index = row_number(),
Region_Median = median(Coverage),
Norm_Coverage = Coverage / Region_Median
) %>%
ungroup() %>%
mutate(Combined_Index = ifelse(Region == "25M-30M", Index, Index + max(Index)))
# Calculate position to place vertical separation line
sep_position <- max(model_a_single_plot$Index)
# Plot
ggplot(model_a_single_plot, aes(x = Combined_Index, y = Norm_Coverage)) +
geom_point(color = "#e31a1c", alpha = 0.4, size = 0.8) +
geom_vline(xintercept = sep_position, linetype = "dashed", color = "gray50") +
geom_hline(yintercept = 1, linetype = "dashed", color = "black") +
annotate("text", x = sep_position / 2, y = 2.2, label = "25M–30M", size = 5, fontface = "bold") +
annotate("text", x = sep_position + max(model_a_single_plot$Index) / 2, y = 2.2, label = "75M–80M", size = 5, fontface = "bold") +
labs(title = "Model A: Tumor Sample – No GC Correction (Single Plot)",
x = "Bin index (both regions concatenated)",
y = "Coverage / Region Median") +
theme_minimal(base_size = 14) +
theme(
plot.title = element_text(hjust = 0.5, face = "bold")
)
The plot shows normalized coverage values across both regions of the tumor sample. The y-axis represents the coverage divided by the median coverage in each region, so a value of 1 indicates expected diploid-level coverage.
We observe that the 25M–30M region (left side of the plot) has a relatively tight and stable distribution around 1, whereas the 75M–80M region (right side) shows a broader spread, with many bins deviating above and below the baseline.
This increased variability might reflect biological events such as amplifications or deletions, or it could be due to technical artifacts.
Since no GC correction was applied yet, some of the noise may still be GC-related. The next model applies GC correction to address this.
Before jumping into the main plots of model B, we wanted to
understand and address a technical issue we observed:
In both genomic regions, the values of \(\hat{a}_i\) tend to be more scattered at
the edges — meaning the start and end of each region
look noisier and more extreme.
It’s because the GC correction function \(f(\text{GC})\), which we use to normalize
coverage, becomes very small when GC content is either
too low or too high.
And since \(\hat{a}_i =
\frac{\text{Coverage}}{f(\text{GC})}\), dividing by small values
can inflate minor fluctuations in coverage, even when
the actual copy number is stable.
To fix that, we decided to remove bins where \(f(\text{GC})\) is too low
# Prepare data for the two regions (25M–30M and 75M–80M)
model_b_df <- combined_df %>%
filter(Sample == "Tumor", Bin %in% c(region1_bins, region2_bins)) %>%
mutate(
Region = ifelse(Bin %in% region1_bins, "25M–30M", "75M–80M")
) %>%
group_by(Region) %>%
mutate(Index = row_number()) %>%
ungroup() %>%
mutate(Combined_Index = ifelse(Region == "25M–30M", Index, Index + max(Index)))
ggplot(model_b_df, aes(x = f_hat)) +
geom_histogram(bins = 100, fill = "steelblue", color = "white") +
geom_vline(xintercept = 200, color = "red", linetype = "dashed") +
labs(title = "Distribution of f(GC)", x = "Predicted f(GC)", y = "Count") +
theme_minimal()
Most bins have predicted \(f(\text{GC})\) values above 200,
but we can see a long left tail of lower values in the
distribution.
These low values correspond to bins at the extremes of GC
content, where the predicted coverage is less reliable.
We chose a threshold of 200 (shown as a red dashed
line) to filter out these unstable regions — this
cutoff helps reduce noise from extreme GC areas while still keeping the
majority of informative bins.
Before plotting, we computed \(\hat{a}_i = \frac{Y_i}{f(\text{GC}_i)}\) for the tumor sample only, using a GC-bias correction function estimated by a cubic spline. This allows us to normalize coverage values and detect potential copy number variation. We focused on two genomic regions (25M–30M and 75M–80M), plotted together with a dashed vertical line separating them.
We also noticed that bins with very low predicted coverage \(f(\text{GC})\)—especially at the GC extremes—produced noisy and inflated values of \(\hat{a}_i\). This happens because dividing by small predicted values amplifies random fluctuations in raw coverage.
To handle this, we filtered out bins where \(f(\text{GC}) < 200\), based on the histogram distribution, and re-plotted the data in a zoomed-in view.
library(ggplot2)
library(dplyr)
library(patchwork)
# Prepare data for the two regions (25M–30M and 75M–80M)
model_b_df <- combined_df %>%
filter(Sample == "Tumor", Bin %in% c(region1_bins, region2_bins)) %>%
mutate(
Region = ifelse(Bin %in% region1_bins, "25M–30M", "75M–80M")
) %>%
group_by(Region) %>%
mutate(Index = row_number()) %>%
ungroup() %>%
mutate(Combined_Index = ifelse(Region == "25M–30M", Index, Index + max(Index)))
# Define the separation line between regions in the plot
sep_position_b <- max(model_b_df$Index)
# Full plot including all bins
p1 <- ggplot(model_b_df, aes(x = Combined_Index, y = a_i)) +
geom_point(color = "#e31a1c", alpha = 0.4, size = 0.8) +
geom_vline(xintercept = sep_position_b, linetype = "dashed", color = "gray50") +
geom_hline(yintercept = 1, linetype = "dashed", color = "black") +
annotate("text", x = sep_position_b / 2, y = 2.2, label = "25M–30M", size = 5, fontface = "bold") +
annotate("text", x = sep_position_b + max(model_b_df$Index) / 2, y = 2.2, label = "75M–80M", size = 5, fontface = "bold") +
labs(title = "Model B: Tumor Sample – GC-Corrected (aᵢ)",
x = "Bin index (both regions concatenated)",
y = expression(a[i] ~ "=" ~ "Coverage / f(GC)")) +
theme_minimal(base_size = 14) +
theme(
plot.title = element_text(hjust = 0.5, face = "bold")
)
# Filter bins where predicted f(GC) is too low
threshold_f_hat <- 200 # Manually chosen threshold
model_b_filtered <- model_b_df %>%
filter(f_hat >= threshold_f_hat)
# Zoom-in plot after filtering edge bins
p2 <- ggplot(model_b_filtered, aes(x = Combined_Index, y = a_i)) +
geom_point(color = "#e31a1c", alpha = 0.4, size = 0.8) +
geom_vline(xintercept = sep_position_b, linetype = "dashed", color = "gray50") +
geom_hline(yintercept = 1, linetype = "dashed", color = "black") +
annotate("text", x = sep_position_b / 2, y = 2.2, label = "25M–30M", size = 5, fontface = "bold") +
annotate("text", x = sep_position_b + max(model_b_filtered$Index) / 2, y = 2.2, label = "75M–80M", size = 5, fontface = "bold") +
labs(title = "Model B: Tumor Sample – GC-Corrected (Zoom-In)",
subtitle = "Filtered bins with low predicted f(GC)",
x = "Bin index (filtered)",
y = expression(a[i] ~ "=" ~ "Coverage / f(GC)")) +
theme_minimal(base_size = 14) +
theme(
plot.title = element_text(hjust = 0.5, face = "bold"),
plot.subtitle = element_text(hjust = 0.5)
)
# Combine both plots (full and filtered)
p1 / p2
# Evaluate proportion of aᵢ values within ±10% of expected value (1)
within_10pct_before <- mean(abs(model_b_df$a_i - 1) < 0.1, na.rm = TRUE)
within_10pct_after <- mean(abs(model_b_filtered$a_i - 1) < 0.1, na.rm = TRUE)
cat("Proportion of bins with aᵢ within ±10% of 1:\n",
"- Before filtering: ", round(within_10pct_before, 3), "\n",
"- After filtering: ", round(within_10pct_after, 3), "\n")
## Proportion of bins with aᵢ within ±10% of 1:
## - Before filtering: 0.701
## - After filtering: 0.718
The first plot shows the full GC-corrected \(\hat{a}_i\) values across both regions. Most points hover around the expected baseline of 1, but there’s more scatter at the edges.
In the second (zoomed-in) plot, we removed bins with low predicted \(f(\text{GC})\), which cleaned up some of the noisy outliers and made the pattern more stable.
This small change slightly improved accuracy — the proportion of bins
with \(\hat{a}_i\) within ±10% of 1
increased from 0.701 to 0.718.
That suggests our GC correction worked well, and this extra filtering
helped reduce noise from problematic bins at GC extremes.
When comparing the two regions, we originally suspected a subtle dip
in the 25M–30M range. But after filtering, both regions show
very similar spread and variability, centered around
1.
This suggests that either there’s no strong copy number change in these
regions, or the signal is too subtle to detect confidently in this
model.
To compare tumor and normal samples without GC correction, we normalized coverage within each region by its median and computed the per-bin ratio: Tumor / Normal.
This helps us directly assess differences between the samples across two genomic regions (25M–30M and 75M–80M).
library(dplyr)
library(ggplot2)
library(tidyr) # for spread
# Filter for the relevant bins and define the region label
region_df <- combined_df %>%
filter(Bin %in% c(region1_bins, region2_bins)) %>%
mutate(Region = ifelse(Bin %in% region1_bins, "25M–30M", "75M–80M"))
# Normalize coverage by regional median within each sample and region
norm_df <- region_df %>%
group_by(Sample, Region) %>%
mutate(Region_Median = median(Coverage),
Norm_Coverage = Coverage / Region_Median) %>%
ungroup()
# Reshape to wide format using spread instead of pivot_wider
ratio_df <- norm_df %>%
select(Bin, Region, Sample, Norm_Coverage) %>%
spread(key = Sample, value = Norm_Coverage) %>%
mutate(Ratio = Tumor / Normal) %>%
arrange(Bin) %>%
group_by(Region) %>%
mutate(Index = row_number(),
Combined_Index = ifelse(Region == "25M–30M", Index, Index + max(Index))) %>%
ungroup()
# Separator position between regions
sep_position_c <- max(ratio_df$Index)
# Plotting the ratio
ggplot(ratio_df, aes(x = Combined_Index, y = Ratio)) +
geom_point(alpha = 0.6, color = "#6a3d9a") +
geom_hline(yintercept = 1, linetype = "dashed", color = "black") +
geom_vline(xintercept = sep_position_c, linetype = "dashed", color = "gray50") +
annotate("text", x = sep_position_c / 2, y = 2.1,
label = "25M–30M", size = 5, fontface = "bold") +
annotate("text", x = sep_position_c + max(ratio_df$Index) / 2, y = 2.1,
label = "75M–80M", size = 5, fontface = "bold") +
labs(title = "Model C: Tumor / Normal – No GC Correction (Ratio of Normalized Coverage)",
x = "Bin index (both regions concatenated)",
y = "Tumor / Normal (per-bin ratio)") +
coord_cartesian(ylim = c(0.5, 2.2)) +
theme_minimal(base_size = 14) +
theme(
plot.title = element_text(hjust = 0.5, face = "bold")
)
The plot shows the ratio of tumor to normal coverage for each bin, after we normalized both samples by their median coverage. Most values cluster around 1, meaning the tumor and normal coverages are roughly similar in many places — which is what we expect in regions without copy number changes.
Still, we see quite a bit of spread, especially in the 75M–80M region. Some bins drop below 0.75 or go above 1.5, which could reflect copy number variation (like deletions or amplifications in the tumor) — or just technical noise.
One important thing to remember is that we didn’t apply GC correction in this model, so some of the variation we’re seeing might still be caused by GC bias rather than real biological differences.
In model D, we directly compared the GC-corrected values of the tumor and normal samples to estimate the relative copy number per bin. This was done by computing the ratio between their normalized coverages, with an added stabilizing constant \(c\) to prevent division by very small numbers.
We focused again on the same two regions, combining them into a single plot and using color to distinguish between them.
This approach helps reduce technical biases and provides a clearer picture of actual copy number alterations in the tumor relative to the normal baseline.
library(ggplot2)
library(dplyr)
# Set stabilizing constant
c_val <- 0.1
# Join tumor and normal by Bin
tumor_ai <- combined_df %>%
filter(Sample == "Tumor", Bin %in% c(region1_bins, region2_bins)) %>%
select(Bin, Coverage, GC_Content, f_hat, a_i) %>%
rename(
Coverage_T = Coverage,
f_T = f_hat,
a_T = a_i
)
normal_ai <- combined_df %>%
filter(Sample == "Normal", Bin %in% c(region1_bins, region2_bins)) %>%
select(Bin, Coverage, f_hat, a_i) %>%
rename(
Coverage_N = Coverage,
f_N = f_hat,
a_N = a_i
)
# Merge and compute stabilized a_i ratio
model_d_df <- inner_join(tumor_ai, normal_ai, by = "Bin") %>%
mutate(
Region = ifelse(Bin %in% region1_bins, "25M–30M", "75M–80M"),
ai_ratio = (Coverage_T / f_T + c_val) / (Coverage_N / f_N + c_val)
) %>%
group_by(Region) %>%
mutate(Index = row_number()) %>%
ungroup() %>%
mutate(Combined_Index = ifelse(Region == "25M–30M", Index, Index + max(Index)))
# Vertical separator
sep_position_d <- max(model_d_df$Index)
# Plot
ggplot(model_d_df, aes(x = Combined_Index, y = ai_ratio)) +
geom_point(color = "#984ea3", alpha = 0.4, size = 0.8) +
geom_vline(xintercept = sep_position_d, linetype = "dashed", color = "gray50") +
geom_hline(yintercept = 1, linetype = "dashed", color = "black") +
annotate("text", x = sep_position_d / 2, y = 2.3, label = "25M–30M", size = 5, fontface = "bold") +
annotate("text", x = sep_position_d + max(model_d_df$Index) / 2, y = 2.3, label = "75M–80M", size = 5, fontface = "bold") +
labs(title = "Model D: Tumor vs Normal – GC Corrected Ratio (aᵢ)",
x = "Bin index (both regions concatenated)",
y = expression(hat(a)[i] ~ " Tumor / Normal"),
subtitle = paste0("Stabilizing constant c = ", c_val)) +
theme_minimal(base_size = 14) +
theme(
plot.title = element_text(hjust = 0.5, face = "bold"),
plot.subtitle = element_text(hjust = 0.5)
)
This plot shows the GC-corrected copy number ratio between the tumor and normal samples across the two regions. Each point represents a bin, and the values are computed as the ratio between the normalized coverages (coverage divided by GC-bias function), with a small stabilizing constant \(c = 0.1\) added to both numerator and denominator
In the 25M–30M region, we observe a consistent drop below 1, suggesting a possible deletion or loss of material in the tumor relative to the normal baseline. In contrast, the 75M–80M region is more stable and hovers around the expected value of 1, with relatively low variability. This model provides the clearest and most biologically meaningful view of potential copy number alterations in the tumor sample.
Unlike Model B, because of the stabilization \(c = 0.1\) , we do not need to filter out bins based on low predicted \(f(\text{GC})\) like we did in Model B. The constant effectively prevents noisy amplification, and we can see in the plot that the spread remains well-controlled across the full GC range.
In this part of the lab, we aimed to quantify the accuracy of
copy number estimation in a specific genomic region —
26.5M to 27.0M on chromosome 1 — where we suspect the
number of copies is less than 2.
We evaluated three models:
Our goal was to compute the median \(a_i\) in this region. If the model successfully captures the copy number loss, the median should be noticeably below 1.
# Construct model A: Raw tumor coverage
model_a_df <- combined_df %>%
filter(Sample == "Tumor", Bin %in% c(region1_bins, region2_bins)) %>%
mutate(a_i = Coverage / median(Coverage, na.rm = TRUE)) %>%
select(Bin, a_i)
# Construct model B: Tumor only with GC correction
model_b_df <- combined_df %>%
filter(Sample == "Tumor", Bin %in% c(region1_bins, region2_bins)) %>%
select(Bin, Coverage, f_hat) %>%
mutate(a_i = Coverage / f_hat)
# Construct model C: Tumor vs normal, no GC correction
model_c_df <- combined_df %>%
filter(Sample %in% c("Tumor", "Normal"),
Bin %in% c(region1_bins, region2_bins)) %>%
select(Bin, Sample, Coverage) %>%
pivot_wider(names_from = Sample, values_from = Coverage) %>%
mutate(a_i = Tumor / Normal)
# Construct model D: Tumor vs normal, with GC correction
model_d_df <- combined_df %>%
filter(Sample %in% c("Tumor", "Normal"),
Bin %in% c(region1_bins, region2_bins)) %>%
select(Bin, Sample, Coverage, f_hat) %>%
pivot_wider(names_from = Sample, values_from = c(Coverage, f_hat)) %>%
mutate(ai_ratio = (Coverage_Tumor / f_hat_Tumor + 0.1) /
(Coverage_Normal / f_hat_Normal + 0.1))
# Define region of interest: 26.5M–27.0M
region_bins <- 10601:10800
# Extract relevant bins for each model
region_a <- model_a_df %>% filter(Bin %in% region_bins)
region_b <- model_b_df %>% filter(Bin %in% region_bins)
region_c <- model_c_df %>% filter(Bin %in% region_bins)
region_d <- model_d_df %>% filter(Bin %in% region_bins)
# Compute median aᵢ values for each model
median_a <- round(median(region_a$a_i, na.rm = TRUE), 3)
median_b <- round(median(region_b$a_i, na.rm = TRUE), 3)
median_c <- round(median(region_c$a_i, na.rm = TRUE), 3)
median_d <- round(median(region_d$ai_ratio, na.rm = TRUE), 3)
# Print summary to console
cat("Median aᵢ in 26.5–27.0M region:\n",
"- Model A (Raw Tumor Coverage):", median_a, "\n",
"- Model B (Tumor only, GC-corrected):", median_b, "\n",
"- Model C (Tumor vs Normal, no correction):", median_c, "\n",
"- Model D (Tumor/Normal ratio, GC-corrected):", median_d, "\n")
## Median aᵢ in 26.5–27.0M region:
## - Model A (Raw Tumor Coverage): 1.169
## - Model B (Tumor only, GC-corrected): 1.03
## - Model C (Tumor vs Normal, no correction): 0.9
## - Model D (Tumor/Normal ratio, GC-corrected): 1.014
# Plot aᵢ values from Model D in the selected region
ggplot(model_d_df %>% filter(Bin %in% region_bins), aes(x = Bin, y = ai_ratio)) +
geom_point(color = "purple", alpha = 0.6, size = 1.2) +
geom_hline(yintercept = 1, linetype = "dashed", color = "black") +
labs(
title = expression("Model D: Copy Number Ratio in Region 26.5M–27.0M"),
x = "Bin Index",
y = expression(hat(a)[i])
) +
theme_minimal(base_size = 14) +
theme(
plot.title = element_text(hjust = 0.5, face = "bold"),
axis.title = element_text(face = "bold")
)
Although the question refers to an assumed deletion near position 26.8M on chromosome 1 (i.e., where \(A < 1\)), our analysis of the region 26.5M–27.0M showed that the median estimated copy number \(\hat{a}_i\) was actually slightly above 1 This suggests that in our specific sample, there’s no strong indication of copy number loss in this region.
Nonetheless, we used this region to compare the performance of Models
B, C, and D by computing the median \(\hat{a}_i\) as a numerical indicator of
deviation from diploidy.
We chose the median because it is a robust
statistic that’s less affected by extreme values or random
noise, which often appear in sequencing data like this. Compared to the
mean, the median provides a more stable and representative
measure of central tendency in noisy regions, making it more
suitable for comparing model outputs in this context.
Here are the median \(a_i\) values we observed:
All the medians are slightly above 1, indicating that none of
the models showed strong evidence for a copy number loss in
this region.
We can also see this in the plot above, where most points are slightly
above the baseline and there’s no clear drop around 26.8M.
This suggests that, in our specific sample, the assumed deletion (A
< 1) is either not present or not detectable using the current
methods.
Still, Model D produced the lowest median, reflecting a
small but consistent shift that may point to improved sensitivity.
In addition to using the median as a summary statistic, we also
propose an alternative quantitative metric:
the proportion of bins where the estimated copy number \(\hat{a}_i\) falls within a narrow window
around 1.
Because we didn’t see strong evidence for a deletion around 26.8M (all
models had medians slightly above 1),
we chose to calculate the proportion of bins where \(\hat{a}_i\) falls between 0.9 and 1.1 —
instead of looking at values below 0.9.
This gives us a simple way to check how well each model keeps diploid
regions stable, without being too noisy or overcorrected.
A higher proportion means the model gives more accurate and consistent
results in regions with no copy number change.
# Prepare region-specific data frames for consistency
region_b_prop <- region_b %>%
mutate(Model = "Model B", ai = a_i) %>%
select(Bin, ai, Model)
region_c_prop <- region_c %>%
mutate(Model = "Model C", ai = a_i) %>%
select(Bin, ai, Model)
region_d_prop <- region_d %>%
mutate(Model = "Model D", ai = ai_ratio) %>%
select(Bin, ai, Model)
# Combine all into one dataframe
region_models_df <- bind_rows(region_b_prop, region_c_prop, region_d_prop)
# Compute proportion of ai values between 0.9 and 1.1
proportion_within_range <- region_models_df %>%
filter(!is.na(ai)) %>%
group_by(Model) %>%
summarize(
Total = n(),
WithinRange = sum(ai >= 0.9 & ai <= 1.1),
Proportion = round(WithinRange / Total, 3)
)
# Print result
print(proportion_within_range)
## # A tibble: 3 × 4
## Model Total WithinRange Proportion
## <chr> <int> <int> <dbl>
## 1 Model B 196 144 0.735
## 2 Model C 195 94 0.482
## 3 Model D 195 146 0.749
We can see that Model D has the highest accuracy
(74.9%), closely followed by Model B (73.5%).
Model C shows the lowest proportion (48.2%).
This reinforces that Model D provides the most stable
and reliable copy number estimates in the selected region (26.5M–27.0M),
capturing diploid values with minimal deviation.
Each of the three models (B, C, and D) provides a different way to estimate copy number variation, and we can clearly see the differences in both the histograms and violin plots.
Model B uses only the tumor sample, applying GC correction to adjust for bias. It doesn’t require a matched normal, which makes it simpler to use. The estimates are fairly centered around 1, and the distribution is quite symmetric. However, this model might still miss shared technical effects that exist in both samples, like mappability issues or artifacts that GC correction alone can’t fully fix.
Model C compares tumor and normal directly, without any GC correction. The idea is that shared technical noise will cancel out. In practice though, the lack of GC correction leads to more variability, especially in regions with extreme GC content. This was clear in both regions we looked at — the estimates were much more spread out and less stable compared to the other models.
Model D combines both strategies: it applies GC correction to both tumor and normal, and then takes the ratio between them. This approach gave us the most accurate and consistent results overall. The estimates were tightly clustered around 1, and the distribution was symmetric and narrow in both regions. Model D does depend on having a good GC correction fit for both samples, but in our case, it clearly outperformed the others in terms of stability.
In summary, while all three models aim to estimate copy number,
Model D provided the most reliable and stable
estimates.
Model B was simple and fairly accurate but didn’t
handle all sources of noise.
Model C showed the most variation and is probably less
reliable unless GC correction is added.
Based on the plots and median values, Model D is the preferred
method for consistent copy number estimation.
library(ggplot2)
library(dplyr)
library(tidyr)
library(patchwork)
# Step 1: Normalize tumor and normal (for Model C)
norm_df <- combined_df %>%
filter(Bin %in% c(region1_bins, region2_bins)) %>%
mutate(Region = ifelse(Bin %in% region1_bins, "25M–30M", "75M–80M")) %>%
group_by(Sample, Region) %>%
mutate(Norm_Coverage = Coverage / median(Coverage, na.rm = TRUE)) %>%
ungroup()
model_c_df <- norm_df %>%
select(Bin, Region, Sample, Norm_Coverage) %>%
pivot_wider(names_from = Sample, values_from = Norm_Coverage) %>%
mutate(ai = Tumor / Normal, Model = "Model C") %>%
filter(!is.na(ai)) %>%
select(Bin, Region, ai, Model)
# Prepare Model B and D using original (non-selected) data
model_b_filtered <- combined_df %>%
filter(Sample == "Tumor", Bin %in% c(region1_bins, region2_bins)) %>%
mutate(
Model = "Model B",
Region = ifelse(Bin %in% region1_bins, "25M–30M", "75M–80M"),
ai = Coverage / f_hat
) %>%
select(Bin, Region, ai, Model)
model_d_filtered <- combined_df %>%
filter(Bin %in% c(region1_bins, region2_bins)) %>%
pivot_wider(id_cols = Bin, names_from = Sample, values_from = c(Coverage, f_hat)) %>%
mutate(
ai = (Coverage_Tumor / f_hat_Tumor + 0.1) / (Coverage_Normal / f_hat_Normal + 0.1),
Model = "Model D",
Region = ifelse(Bin %in% region1_bins, "25M–30M", "75M–80M")
) %>%
select(Bin, Region, ai, Model)
# Combine all models
all_models_df <- bind_rows(model_b_filtered, model_c_df, model_d_filtered)
# Compute medians for annotation
median_labels <- all_models_df %>%
group_by(Model, Region) %>%
summarize(med = round(median(ai, na.rm = TRUE), 3), .groups = "drop") %>%
mutate(
label = paste0(Model, ": ", med),
y = 155 - 20 * as.numeric(factor(Model)),
x = 1.32
)
# Create histograms with median text
# Region 25M–30M
p1 <- ggplot(all_models_df %>% filter(Region == "25M–30M"),
aes(x = ai, fill = Model)) +
geom_histogram(position = "identity", alpha = 0.5, bins = 60) +
geom_vline(xintercept = 1, linetype = "dashed") +
geom_text(data = median_labels %>% filter(Region == "25M–30M"),
aes(x = x, y = y, label = label, color = Model),
inherit.aes = FALSE, hjust = 0, size = 4.2, show.legend = FALSE) +
labs(title = "Region 25M–30M",
x = expression(hat(a)[i]), y = "Count") +
theme_minimal(base_size = 13) +
# Hide legend on the left plot
theme(legend.position = "none")
# Plot for Region 75M–80M
p2 <- ggplot(all_models_df %>% filter(Region == "75M–80M"),
aes(x = ai, fill = Model)) +
geom_histogram(position = "identity", alpha = 0.5, bins = 60) +
geom_vline(xintercept = 1, linetype = "dashed") +
geom_text(data = median_labels %>% filter(Region == "75M–80M"),
aes(x = x, y = y, label = label, color = Model),
inherit.aes = FALSE, hjust = 0, size = 4.2, show.legend = FALSE) +
labs(title = "Region 75M–80M",
x = expression(hat(a)[i]), y = "Count") +
theme_minimal(base_size = 13) +
theme(legend.position = "right")
# Combine the two plots side-by-side with shared legend at the bottom
p1 + p2 + plot_layout(guides = "collect") & theme(legend.position = 'bottom')
The histogram of \(\hat{a}_i\)
values highlights key differences between the models across the two
regions.
Model D (blue) consistently shows the most symmetric
and narrow distribution, centered near 1 — which suggests stable and
reliable estimates.
In the 25M–30M region, its median was closest to 1 and the spread was
minimal.
In the 75M–80M region, Model C (green) had a slightly
better median, but the distribution was much wider and more
variable.
Model B (red) was more centered than C, but less stable
than D.
Overall, these results support the conclusion that Model
D provides the most consistent and accurate copy number
estimation across regions.
library(ggplot2)
# Violin plot showing all models over both genomic regions combined
ggplot(all_models_df, aes(x = Model, y = ai, fill = Model)) +
geom_violin(alpha = 0.6, color = "black") +
geom_hline(yintercept = 1, linetype = "dashed", color = "black") +
scale_fill_manual(values = c("Model B" = "#fdae61",
"Model C" = "#abdda4",
"Model D" = "#2b83ba")) +
labs(
title = expression("Violin Plot of Estimated " * hat(a)[i] *
" per Model (Combined Regions)"),
x = "Model",
y = expression(hat(a)[i])
) +
theme_minimal(base_size = 14) +
theme(
plot.title = element_text(hjust = 0.5, face = "bold"),
axis.title = element_text(face = "bold"),
legend.position = "none"
)
The violin plot shows how the values of \(\hat{a}_i\) are distributed for each model,
across both genomic regions.
We can see that Model D has the most concentrated and
symmetric shape around 1, which means it’s the most stable and
consistent.
Model C looks a lot more spread out, especially toward
the lower end, which suggests it might be noisier or underestimate copy
numbers.
Model B is somewhere in between — not as tight as D,
but more centered than C.
So even though all models are roughly centered around 1, Model D clearly does it with the least noise, making it the most reliable overall.
In summary:
Through a step-by-step comparison of Models A through D, we saw how different correction strategies affect our ability to detect true CNV patterns. The combination of GC correction and comparison to a normal sample — as done in Model D, gave us the most stable results, especially in GC-rich regions.
Simpler models like B and C were easier to apply but had clear downsides — either from not correcting GC bias or from not accounting for shared technical effects. Overall, this lab helped us understand the strengths and trade-offs of each modeling choice and how important preprocessing and bias correction are in genomic data analysis.
One thing We’ve learned:
Even small technical biases like GC content can have a huge impact on sequencing coverage — and if we don’t correct for them properly, we might completely miss or misinterpret copy number changes. That’s why combining multiple corrections (like in Model D) is so powerful: it helps isolate the biological signal from all the background noise.