Genomic Data Smoothing¶
LOWESS for methylation profiles, ChIP-seq signals, and other genomic data.
Overview¶
Genomic data often contains noise from sequencing depth variation, PCR artifacts, or biological heterogeneity. LOWESS smoothing helps reveal underlying patterns.
Methylation Profile Smoothing¶
The Challenge¶
DNA methylation data (from bisulfite sequencing or arrays) shows position-dependent patterns that can be obscured by measurement noise.
Solution¶
library(rfastlowess)
set.seed(42)
positions <- seq(0, 10000, by = 10)
observed <- 50 + sin(positions / 100) * 20 + rnorm(length(positions), sd = 5)
library(rfastlowess)
# Simulate methylation data
set.seed(42)
n <- 1000
positions <- sort(runif(n, 0, 1e6))
# True pattern
true_meth <- 0.5 + 0.3 * sin(positions / 1e5)
# Observed with noise
observed <- true_meth + rnorm(n, sd = 0.15)
observed <- pmax(0, pmin(1, observed))
# Smooth
model <- Lowess(
fraction = 0.1,
iterations = 3,
confidence_intervals = 0.95
)
result <- model$fit(positions, observed)
# Plot
plot(positions, observed, pch = ".", col = "gray",
xlab = "Genomic Position (bp)", ylab = "Methylation Level",
main = "Methylation Profile Smoothing")
lines(result$x, result$y, col = "blue", lwd = 2)
lines(result$x, result$confidence_lower, col = "blue", lty = 2)
lines(result$x, result$confidence_upper, col = "blue", lty = 2)
import fastlowess as fl
import numpy as np
import matplotlib.pyplot as plt
# Simulate methylation data along a chromosome
np.random.seed(42)
n_positions = 1000
positions = np.sort(np.random.uniform(0, 1e6, n_positions))
# True methylation pattern (varies along chromosome)
true_methylation = 0.5 + 0.3 * np.sin(positions / 1e5)
# Observed with noise
observed = true_methylation + np.random.normal(0, 0.15, n_positions)
observed = np.clip(observed, 0, 1) # Methylation is 0-1
# Smooth with LOWESS
model = fl.Lowess(
fraction=0.1, # Small fraction for local detail
iterations=3, # Robustness for outliers
confidence_intervals=0.95
)
result = model.fit(positions, observed)
# Plot
plt.figure(figsize=(12, 5))
plt.scatter(positions, observed, s=2, alpha=0.3, label="Observed")
plt.plot(positions, result.y, "b-", linewidth=2, label="LOWESS smoothed")
plt.fill_between(
positions,
result.confidence_lower,
result.confidence_upper,
alpha=0.2, label="95% CI"
)
plt.xlabel("Genomic Position (bp)")
plt.ylabel("Methylation Level")
plt.legend()
plt.title("Methylation Profile Smoothing")
plt.show()
use fastLowess::prelude::*;
use std::f64::consts::TAU;
fn main() -> Result<(), LowessError> {
let n = 100usize;
let x: Vec<f64> = (0..n).map(|i| i as f64 * TAU / (n - 1) as f64).collect();
let y: Vec<f64> = x.iter().map(|&xi| xi.sin() + 0.1).collect();
let positions = x.clone();
let observed = y.clone();
let model = Lowess::new()
.fraction(0.1)
.iterations(3)
.confidence_intervals(0.95)
.build()?;
let result = model.fit(&positions, &observed)?;
// result.y contains smoothed methylation profile
// result.confidence_lower/upper contain 95% CI bounds
Ok(())
}
using FastLOWESS
using Random
rng = MersenneTwister(42)
positions = collect(0.0:10.0:10000.0)
observed = 50.0 .+ sin.(positions ./ 100.0) .* 20.0 .+ randn(rng, length(positions)) .* 5.0
using FastLOWESS
# positions and observed are your methylation data
model = Lowess(;
fraction=0.1,
iterations=3,
confidence_intervals=0.95
)
result = fit(model, positions, observed)
# Smoothed profile in result.y
# CI bounds in result.confidence_lower/upper
const fl = require('fastlowess');
const n = 100;
const x = Float64Array.from({ length: n }, (_, i) => i * 2 * Math.PI / (n - 1));
const y = Float64Array.from(x, (xi, i) => Math.sin(xi) + (((i*7+3)%17)/17-0.5)*0.6);
const positions = Float64Array.from({ length: 1000 }, (_, i) => i * 10.0);
const observed = Float64Array.from(positions, p => 50 + Math.sin(p/100)*20 + Math.random()*5);
// positions and observed are your methylation data (Float64Array)
const model = new fl.Lowess({
fraction: 0.1,
iterations: 3,
confidence_intervals: 0.95
});
const result = model.fit(positions, observed);
// Smoothed profile in result.y
// CI bounds in result.confidence_lower/upper
const { Lowess } = require('fastlowess-wasm');
const n = 100;
const positions = Float64Array.from({ length: n }, (_, i) => i * 100.0);
const observed = Float64Array.from(positions, p => 50 + Math.sin(p / 100) * 20 + ((p * 7 % 17) / 17 - 0.5) * 5);
// positions and observed are your methylation data (Float64Array)
const model = new Lowess({
fraction: 0.1,
iterations: 3,
confidence_intervals: 0.95
});
const result = model.fit(positions, observed);
// Smoothed profile in result.y
// CI bounds in result.confidence_lower/upper
#include <fastlowess.hpp>
#include <cmath>
#include <iostream>
#include <vector>
int main() {
const int n = 100;
std::vector<double> positions(n), observed(n);
for (int i = 0; i < n; ++i) {
positions[i] = i * 1000.0;
observed[i] = 50.0 + std::sin(positions[i] / 1000.0) * 20.0 + 5.0;
}
// positions and observed are std::vector<double>
fastlowess::Lowess model({ .fraction = 0.1, .iterations = 3, .confidence_intervals = 0.95 });
auto result = model.fit(positions, observed).value();
// Smoothed profile in result.y_vector()
// CI bounds in result.confidence_lower()/result.confidence_upper()
return 0;
}
ChIP-seq Signal Smoothing¶
Application¶
ChIP-seq experiments produce sparse, noisy coverage data. LOWESS can help identify binding regions.
library(rfastlowess)
set.seed(42)
positions <- seq(0, 10000, by = 10)
observed <- 50 + sin(positions / 100) * 20 + rnorm(length(positions), sd = 5)
set.seed(123)
positions <- seq(0, 10000, by = 10)
n <- length(positions)
# Simulate peaks
background <- 10
peak1 <- 50 * exp(-((positions - 2000)^2) / (2 * 200^2))
peak2 <- 80 * exp(-((positions - 5000)^2) / (2 * 300^2))
peak3 <- 40 * exp(-((positions - 8000)^2) / (2 * 150^2))
true_signal <- background + peak1 + peak2 + peak3
observed <- rpois(n, true_signal)
model <- Lowess(
fraction = 0.05,
iterations = 5
)
result <- model$fit(positions, observed)
# Find peaks
threshold <- quantile(result$y, 0.75)
peak_positions <- positions[result$y > threshold]
import fastlowess as fl
import numpy as np
np.random.seed(42)
positions = np.arange(0, 10000, 10, dtype=float)
coverage = np.random.poisson(50, len(positions)).astype(float)
# Simulate ChIP-seq coverage with peaks
np.random.seed(123)
positions = np.arange(0, 10000, 10, dtype=float)
n = len(positions)
# Background + peaks
background = 10
peak1 = 50 * np.exp(-((positions - 2000) ** 2) / (2 * 200 ** 2))
peak2 = 80 * np.exp(-((positions - 5000) ** 2) / (2 * 300 ** 2))
peak3 = 40 * np.exp(-((positions - 8000) ** 2) / (2 * 150 ** 2))
true_signal = background + peak1 + peak2 + peak3
observed = np.random.poisson(true_signal) # Poisson noise
# Smooth with robustness for sporadic high counts
model = fl.Lowess(
fraction=0.05, # Very local smoothing
iterations=5, # Strong robustness
return_residuals=True
)
result = model.fit(positions, observed.astype(float))
# Identify peaks (smoothed signal significantly above background)
threshold = np.percentile(result.y, 75)
peaks = positions[result.y > threshold]
print(f"Peak regions: {peaks}")
use fastLowess::prelude::*;
use std::f64::consts::TAU;
fn main() -> Result<(), LowessError> {
let n = 100usize;
let x: Vec<f64> = (0..n).map(|i| i as f64 * TAU / (n - 1) as f64).collect();
let y: Vec<f64> = x.iter().map(|&xi| xi.sin() + 0.1).collect();
let positions: Vec<f64> = (0..1000).map(|i| i as f64 *10.0).collect(); // 0 to 9990 step 10
let observed: Vec<f64> = positions.iter().map(|&p| (p / 1000.0).sin().abs()* 100.0 + 10.0).collect();
let model = Lowess::new()
.fraction(0.05)
.iterations(5)
.return_residuals()
.build()?;
let result = model.fit(&positions, &observed)?;
// Find peaks above threshold
let threshold = result.y.iter().copied()
.fold(f64::NEG_INFINITY, f64::max) * 0.75;
let peak_positions: Vec<f64> = positions.iter().zip(result.y.iter())
.filter(|(_, &y)| y > threshold)
.map(|(&p, _)| p)
.collect();
Ok(())
}
using FastLOWESS
using Random, Statistics
rng = MersenneTwister(42)
positions = collect(0.0:10.0:10000.0)
observed = 50.0 .+ sin.(positions ./ 100.0) .* 20.0 .+ randn(rng, length(positions)) .* 5.0
using FastLOWESS
# positions and observed are your ChIP-seq data
model = Lowess(; fraction=0.05, iterations=5)
result = fit(model, positions, observed)
# Find peaks above 75th percentile
threshold = quantile(result.y, 0.75)
peak_indices = findall(y -> y > threshold, result.y)
peak_positions = positions[peak_indices]
const fl = require('fastlowess');
const n = 100;
const x = Float64Array.from({ length: n }, (_, i) => i * 2 * Math.PI / (n - 1));
const y = Float64Array.from(x, (xi, i) => Math.sin(xi) + (((i*7+3)%17)/17-0.5)*0.6);
const positions = Float64Array.from({ length: 1000 }, (_, i) => i * 10.0);
const observed = Float64Array.from(positions, p => 50 + Math.sin(p/100)*20 + Math.random()*5);
const model = new fl.Lowess({
fraction: 0.05,
iterations: 5
});
const result = model.fit(positions, observed);
// Identify peaks above threshold
const smoothed = result.y;
const threshold = 50.0; // Example threshold
const peaks = positions.filter((p, i) => smoothed[i] > threshold);
const { Lowess } = require('fastlowess-wasm');
const n = 100;
const positions = Float64Array.from({ length: n }, (_, i) => i * 100.0);
const observed = Float64Array.from(positions, p => 50 + Math.sin(p / 100) * 20 + ((p * 7 % 17) / 17 - 0.5) * 5);
const model = new Lowess({
fraction: 0.05,
iterations: 5
});
const result = model.fit(positions, observed);
// Find peaks
const smoothed = result.y;
const peaks = positions.filter((p, i) => smoothed[i] > 25.0);
#include <fastlowess.hpp>
#include <cmath>
#include <iostream>
#include <vector>
int main() {
const int n = 100;
std::vector<double> positions(n), observed(n);
for (int i = 0; i < n; ++i) {
positions[i] = i * 1000.0;
observed[i] = 50.0 + std::sin(positions[i] / 1000.0) * 20.0 + 5.0;
}
fastlowess::Lowess model({ .fraction = 0.05, .iterations = 5 });
auto result = model.fit(positions, observed).value();
// Find peaks above threshold
std::vector<double> peaks;
const auto& y_vals = result.y_vector();
const auto& x_vals = result.x_vector();
for (size_t i = 0; i < y_vals.size(); ++i) {
if (y_vals[i] > 25.0) {
peaks.push_back(x_vals[i]);
}
}
return 0;
}
Large Genome Coverage (Streaming)¶
For whole-genome data that doesn't fit in memory:
library(rfastlowess)
set.seed(42)
positions <- seq(0, 10000, by = 10)
observed <- 50 + sin(positions / 100) * 20 + rnorm(length(positions), sd = 5)
coverage <- observed # alias: coverage = observed counts
model <- StreamingLowess(
fraction = 0.05,
chunk_size = 100000,
overlap = 10000,
merge_strategy = "weighted_average"
)
result <- model$process_chunk(positions, coverage)
final <- model$finalize()
import fastlowess as fl
import numpy as np
np.random.seed(42)
positions = np.arange(0, 10000, 10, dtype=float)
coverage = np.random.poisson(50, len(positions)).astype(float)
# Process chromosome-by-chromosome or in chunks
model = fl.StreamingLowess(
fraction=0.05,
chunk_size=100000, # 100kb chunks
overlap=10000, # 10kb overlap
merge_strategy="weighted_average"
)
model.process_chunk(positions, coverage)
result = model.finalize()
use fastLowess::prelude::*;
fn main() -> Result<(), LowessError> {
let x_chunk: Vec<f64> = (0..1001).map(|i| i as f64 * 10.0).collect();
let y_chunk: Vec<f64> = x_chunk.iter().map(|&p| 50.0 + (p / 100.0).sin() * 20.0 + 5.0).collect();
let mut processor = StreamingLowess::new()
.fraction(0.05)
.iterations(3)
.chunk_size(50)
.overlap(10)
.merge_strategy("weighted_average")
.build()?;
processor.process_chunk(&x_chunk, &y_chunk)?;
let result = processor.finalize()?;
Ok(())
}
using FastLOWESS
using Random
rng = MersenneTwister(42)
positions = collect(0.0:10.0:10000.0)
observed = 50.0 .+ sin.(positions ./ 100.0) .* 20.0 .+ randn(rng, length(positions)) .* 5.0
coverage = observed
using FastLOWESS
# coverage and positions are chromosome-scale vectors
model = StreamingLowess(;
fraction=0.05,
chunk_size=100000,
overlap=10000,
merge_strategy="weighted_average"
)
process_chunk(model, positions, coverage)
result = finalize(model)
const { StreamingLowess } = require('fastlowess');
const positions = Float64Array.from({ length: 1000 }, (_, i) => i * 10.0);
const observed = Float64Array.from(positions, p => 50 + Math.sin(p/100)*20 + Math.random()*5);
// Array of genomic chunks to process
const genomicData = [
{ positions: positions.slice(0, 500), coverage: observed.slice(0, 500) },
{ positions: positions.slice(500), coverage: observed.slice(500) }
];
const processor = new StreamingLowess(
{ fraction: 0.05, iterations: 3 },
{ chunk_size: 100000, overlap: 10000 }
);
// Process genomic chunks from stream or file
for (const chunk of genomicData) {
processor.process_chunk(chunk.positions, chunk.coverage);
}
const result = processor.finalize();
const { StreamingLowess } = require('fastlowess-wasm');
const xChunk = Float64Array.from({ length: 1001 }, (_, i) => i * 10.0);
const yChunk = Float64Array.from(xChunk, p => 50 + Math.sin(p / 100) * 20 + 5.0);
const processor = new StreamingLowess(
{ fraction: 0.05, iterations: 3 },
{ chunk_size: 100, overlap: 10 }
);
processor.process_chunk(xChunk, yChunk);
const result = processor.finalize();
#include <fastlowess.hpp>
#include <cmath>
#include <iostream>
#include <vector>
int main() {
const int n = 100;
std::vector<double> positions(n), coverage(n);
for (int i = 0; i < n; ++i) {
positions[i] = i * 1000.0;
coverage[i] = 50.0 + std::sin(positions[i] / 1000.0) * 20.0 + 5.0;
}
// coverage and positions are chromosome-scale vectors
fastlowess::StreamingOptions s_opts;
s_opts.fraction = 0.05;
s_opts.iterations = 3;
s_opts.chunk_size = 100000;
s_opts.overlap = 10000;
fastlowess::StreamingLowess stream(s_opts);
(void)stream.process_chunk(positions, coverage);
auto result = stream.finalize().value();
return 0;
}
Best Practices for Genomic Data¶
| Consideration | Recommendation |
|---|---|
| Fraction | 0.05–0.15 (preserve local features) |
| Iterations | 3–5 (handle sequencing outliers) |
| Large data | Use streaming mode |
| Sparse regions | Use boundary_policy="extend" |
| Multiple chromosomes | Process separately or ensure sorted |
See Also¶
- Concepts — How LOWESS works
- Parameters — All options
- Robustness — Outlier downweighting in depth
- Merge Strategies — Streaming chunk reconciliation
- Boundary Handling — Edge handling for sparse regions
- Real-Time Processing — For sequencing runs