## ----include = FALSE---------------------------------------------------------- knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4 ) ## ----setup-------------------------------------------------------------------- library(rLifting) if (!requireNamespace("ggplot2", quietly = TRUE)) { knitr::opts_chunk$set(eval = FALSE) message("'ggplot2' is required to render plots. Vignette code will not run.") } else { library(ggplot2) } set.seed(20260521) ## ----motivate----------------------------------------------------------------- n = 1024 t = seq(0, 1, length.out = n) pure = sqrt(t * (1 - t)) * sin((2 * pi * 1.05) / (t + 0.05)) noisy = pure + rnorm(n, sd = 0.15) scheme = lifting_scheme("cdf53") # Three configurations default_clean = denoise_signal_offline( noisy, scheme, levels = 5, shrinkage = "semisoft", alpha = 0.3, beta = 1.2 ) oversmooth_clean = denoise_signal_offline( noisy, scheme, levels = 5, shrinkage = "semisoft", alpha = 5, beta = 2.5 ) undersmooth_clean = denoise_signal_offline( noisy, scheme, levels = 5, shrinkage = "hard", alpha = 0.1, beta = 0.5 ) mse = function(est) mean((pure - est)^2) data.frame( config = c( "default (semisoft, α=0.3, β=1.2)", "over (semisoft, α=5, β=2.5)", "under (hard, α=0.1, β=0.5)" ), MSE = c( mse(default_clean), mse(oversmooth_clean), mse(undersmooth_clean) ) ) ## ----motivate-benchmark------------------------------------------------------- data("benchmark_rlifting", package = "rLifting") sub = subset( benchmark_rlifting, Mode == "offline" & Wavelet == "cdf53" & Boundary == "symmetric" ) aggregate( MSE_median ~ Signal, data = sub, FUN = function(x) c( min = min(x), median = median(x), max = max(x), ratio = round(max(x)/min(x), 2) ) ) ## ----motivate-plot, echo=FALSE, fig.cap="Figure 1: The same noisy Doppler signal denoised with three different parameter combinations. The default semisoft configuration tracks the underlying signal closely; the over-smoothed run loses fine detail; the under-smoothed hard configuration retains visible residual noise."---- df = data.frame( index = rep(seq_len(n), 4), value = c(pure, default_clean, oversmooth_clean, undersmooth_clean), config = factor( rep( c("Original", "Default", "Over-smoothed", "Under-smoothed"), each = n ), levels = c( "Original", "Default", "Over-smoothed", "Under-smoothed" ) ) ) ggplot(df, aes(x = index, y = value, colour = config)) + geom_line(linewidth = 0.5) + facet_wrap(~ config, ncol = 2) + scale_colour_manual( values = c( "Original" = "black", "Default" = "#0072B2", "Over-smoothed" = "#D55E00", "Under-smoothed" = "#009E73" ) ) + theme_minimal() + theme(legend.position = "none") + labs( title = "Same signal, three parameter choices", x = "Sample index", y = "Amplitude" ) ## ----mad-estimate------------------------------------------------------------- lw = lwt(noisy, scheme, levels = 5) d1 = lw$coeffs$d1 sigma_mad = mad(d1, constant = 1.4826) # 1/0.6745 ≈ 1.4826 sigma_sd = sd(d1) # for comparison cat(sprintf("True noise sd : 0.15\n")) cat(sprintf("MAD-based σ̂ : %.4f (used by rLifting)\n", sigma_mad)) cat( sprintf( "Plain sd of d1 : %.4f (sensitive to large signal coeffs)\n", sigma_sd ) ) ## ----mad-robust--------------------------------------------------------------- # Inject five spurious large coefficients (simulating a sharp event) d1_contam = d1 d1_contam[sample.int(length(d1), 5)] = c(2, -2, 1.8, -1.7, 1.6) cat(sprintf("With 5 large coeffs added:\n")) cat( sprintf( " MAD-based σ̂ : %.4f (essentially unchanged)\n", mad(d1_contam, constant = 1.4826) ) ) cat( sprintf( " Plain sd : %.4f (inflated by outliers)\n", sd(d1_contam) ) ) ## ----mad-hist, echo=FALSE, fig.cap="Figure 2: Distribution of the finest-level detail coefficients $d_1$ for the noisy Doppler signal. Dashed lines mark $\\pm\\hat{\\sigma}$, the MAD-based noise estimate used as input to the universal threshold rule."---- ggplot(data.frame(d1 = d1), aes(x = d1)) + geom_histogram(bins = 60, fill = "grey60", colour = "white") + geom_vline( xintercept = c(-sigma_mad, sigma_mad), linetype = "dashed", colour = "#D55E00", linewidth = 0.6 ) + annotate( "text", x = sigma_mad, y = Inf, label = "±σ̂", colour = "#D55E00", vjust = 1.5, hjust = -0.2 ) + theme_minimal() + labs( title = "Distribution of d_1 coefficients with MAD-based σ̂", x = "d_1", y = "Count" ) ## ----univ-vs-sure------------------------------------------------------------- make_noisy = function(type, n, sd) { pure = rLifting:::.generate_signal(type, n = n) list(pure = pure, noisy = pure + rnorm(n, sd = sd)) } n_3 = 1024 signals = list( doppler = make_noisy("doppler", n_3, sd = 0.15), heavisine = make_noisy("heavisine", n_3, sd = 0.35), blocks = make_noisy("blocks", n_3, sd = 0.50) ) compare_rules = function(sig) { un = denoise_signal_offline( sig$noisy, scheme, levels = 5, threshold_method = "universal", shrinkage = "semisoft" ) su = denoise_signal_offline( sig$noisy, scheme, levels = 5, threshold_method = "sure", shrinkage = "semisoft" ) c( universal = mean((sig$pure - un)^2), sure = mean((sig$pure - su)^2) ) } mse_table = t(sapply(signals, compare_rules)) round(mse_table, 4) ## ----univ-vs-sure-benchmark--------------------------------------------------- b = subset( benchmark_rlifting, Mode == "offline" & Wavelet == "cdf53" & Boundary == "symmetric" & ThresholdMethod %in% c("universal", "sure") & !grepl("tuned", Method) ) b$rule = b$ThresholdMethod mat = aggregate(MSE_median ~ Signal + Shrinkage + rule, data = b, FUN = mean) rs = reshape( mat, idvar = c("Signal", "Shrinkage"), timevar = "rule", direction = "wide" ) rs$winner = ifelse( rs$MSE_median.universal < rs$MSE_median.sure, "universal", "sure" ) rs[ order(rs$Signal, rs$Shrinkage), c("Signal", "Shrinkage", "MSE_median.universal", "MSE_median.sure", "winner") ] ## ----univ-vs-sure-lambdas----------------------------------------------------- # Per-level lambda comparison on the Doppler signal lw_dop = lwt(signals$doppler$noisy, scheme, levels = 5) sigma_global = mad(lw_dop$coeffs$d1, constant = 1.4826) # Universal: recursive decay with default alpha=0.3, beta=1.2 lam_univ = compute_adaptive_threshold(lw_dop, alpha = 0.3, beta = 1.2) lam_univ_vec = unlist(lam_univ) # SURE: per-level. Replicate by computing optimal lambda per level. sure_lambda_level = function(d) { sigma_j = mad(d, constant = 1.4826) if (sigma_j < 1e-15) return(0) abs_d = sort(abs(d)) m = length(d) cap = sigma_j * sqrt(2 * log(m)) best = m * sigma_j^2 bl = 0 cs = c(0, cumsum(abs_d^2)) for (k in seq_len(m)) { lam = abs_d[k] s = m * sigma_j^2 + cs[k + 1] + lam^2 * (m - k) - 2 * sigma_j^2 * k if (s < best) { best = s; bl = lam } } min(bl, cap) } lam_sure_vec = sapply(lw_dop$coeffs[paste0("d", 1:5)], sure_lambda_level) data.frame( level = 1:5, universal = round(lam_univ_vec, 4), sure = round(unname(lam_sure_vec), 4) ) ## ----sure-inert--------------------------------------------------------------- a1 = denoise_signal_offline( signals$doppler$noisy, scheme, levels = 5, threshold_method = "sure", shrinkage = "semisoft", alpha = 0.3, beta = 1.2 ) a2 = denoise_signal_offline( signals$doppler$noisy, scheme, levels = 5, threshold_method = "sure", shrinkage = "semisoft", alpha = 999, beta = 0.01 ) identical(a1, a2) ## ----alpha-sweep-------------------------------------------------------------- n_alpha = 1024 sigma_demo = 0.15 levels_demo = 6 lam1 = 1.2 * sigma_demo * sqrt(2 * log(n_alpha)) recurse = function(alpha, k_max, lam1) { lam = numeric(k_max); lam[1] = lam1 for (k in 2:k_max) lam[k] = lam[k-1] * (k-1) / (k + alpha - 1) lam } alphas = c(0, 0.1, 0.3, 1, 5, 50) df_alpha = do.call( rbind, lapply( alphas, function(a) { data.frame( level = 1:levels_demo, lambda = recurse(a, levels_demo, lam1), alpha = factor(a) ) } ) ) df_alpha ## ----alpha-plot, echo=FALSE, fig.cap="Figure 3: Effect of the parameter $\\alpha$ on the per-level threshold $\\lambda_k$, holding $\\lambda_1$ fixed and varying $\\alpha$ across six representative values. The y-axis is on a log scale. At $\\alpha = 0$ the threshold is constant across all levels; as $\\alpha$ grows the decay becomes increasingly aggressive."---- ggplot(df_alpha, aes(x = level, y = lambda, colour = alpha, group = alpha)) + geom_line(linewidth = 0.7) + geom_point(size = 2) + scale_y_log10() + theme_minimal() + labs(title = "α controls how aggressively λ decays across levels", subtitle = "Same λ_1; varying α only. Log scale on y.", x = "Decomposition level k", y = expression(lambda[k])) + scale_colour_brewer(palette = "RdBu", direction = -1) ## ----tune-run----------------------------------------------------------------- opt = tune_alpha_beta(signals$heavisine$noisy, scheme, levels = 5) opt ## ----tune-mse----------------------------------------------------------------- def_clean = denoise_signal_offline( signals$heavisine$noisy, scheme, levels = 5, shrinkage = "semisoft", alpha = 0.3, beta = 1.2 ) tun_clean = denoise_signal_offline( signals$heavisine$noisy, scheme, levels = 5, shrinkage = "semisoft", alpha = opt$alpha, beta = opt$beta ) c( default_MSE = mean((signals$heavisine$pure - def_clean)^2), tuned_MSE = mean((signals$heavisine$pure - tun_clean)^2) ) ## ----sure-landscape, fig.height=5, fig.cap="Figure 4: SURE landscape on the (α, β) grid for the noisy Doppler signal. Darker regions correspond to lower SURE. The white cross marks the optimum returned by tune_alpha_beta(). The landscape is broad and slightly multimodal, which is why phase 1 of the tuner is a grid search before Nelder-Mead refinement."---- lw_tune = lwt(signals$doppler$noisy, scheme, levels = 5) details_tune = lw_tune$coeffs[paste0("d", 1:5)] sigma_tune = mad(details_tune[[1]], constant = 1.4826) n_finest = length(details_tune[[1]]) sure_at = function(a, b) { lam = numeric(5); lam[1] = b * sigma_tune * sqrt(2 * log(n_finest)) for (k in 2:5) lam[k] = lam[k-1] * (k-1) / (k + a - 1) total = 0 for (j in seq_along(details_tune)) { d = details_tune[[j]]; m = length(d) sj = mad(d, constant = 1.4826); if (sj < 1e-15) sj = sigma_tune total = total + m * sj^2 + sum(pmin(d^2, lam[j]^2)) - 2 * sj^2 * sum(abs(d) <= lam[j]) } total } a_grid = seq(0, 5, length.out = 26) b_grid = seq(0.5, 3.0, length.out = 26) grid_df = expand.grid(alpha = a_grid, beta = b_grid) grid_df$sure = mapply(sure_at, grid_df$alpha, grid_df$beta) ggplot( grid_df, aes(x = alpha, y = beta, fill = sure) ) + geom_raster(interpolate = TRUE) + geom_point( data = data.frame(alpha = opt$alpha, beta = opt$beta), aes(x = alpha, y = beta), inherit.aes = FALSE, colour = "white", size = 3, shape = 4, stroke = 1.2 ) + scale_fill_viridis_c(option = "magma") + theme_minimal() + labs( title = "SURE landscape (white × marks the optimum)", x = expression(alpha), y = expression(beta), fill = "SURE" ) ## ----shrinkage-curves, fig.height=3.5, echo=FALSE, fig.cap="Figure 5: The four shrinkage functions with $\\lambda = 1$. Soft introduces a constant bias of $\\lambda$ on large coefficients; hard is bias-free above $\\lambda$ but discontinuous; semisoft transitions continuously and asymptotes to the identity; SCAD is bias-free above $a\\lambda$ (default $a = 3.7$)."---- x_th = seq(-3, 3, length.out = 301) lam = 1.0 df_th = data.frame( x = rep(x_th, 4), y = c( threshold(x_th, lam, "hard"), threshold(x_th, lam, "soft"), threshold(x_th, lam, "semisoft"), threshold(x_th, lam, "scad", a = 3.7) ), Method = factor( rep( c("Hard", "Soft", "Semisoft", "SCAD"), each = length(x_th) ), levels = c("Hard", "Soft", "Semisoft", "SCAD") ) ) ggplot(df_th, aes(x = x, y = y, colour = Method)) + geom_line(linewidth = 0.8) + geom_abline( slope = 1, intercept = 0, linetype = "dotted", colour = "grey60" ) + geom_vline( xintercept = c(-lam, lam), linetype = "dashed", colour = "grey40" ) + theme_minimal() + labs( title = "Four shrinkage functions (λ = 1)", x = "Input coefficient", y = "Output coefficient" ) + scale_colour_manual( values = c( "Hard" = "#E69F00", "Soft" = "#56B4E9", "Semisoft" = "#009E73", "SCAD" = "#CC79A7" ) ) ## ----shrinkage-mse------------------------------------------------------------ shrink_mse = function(sig, levels = 5) { m = sapply(c("hard", "soft", "semisoft", "scad"), function(s) { out = denoise_signal_offline( sig$noisy, scheme, levels = levels, shrinkage = s, threshold_method = "universal", alpha = 0.3, beta = 1.2 ) mean((sig$pure - out)^2) }) round(m, 4) } shrink_table = t(sapply(signals, shrink_mse)) shrink_table ## ----shrinkage-by-signal, echo=FALSE, fig.height=3.5, fig.cap="Figure 6: MSE by shrinkage rule across three test signals (Doppler, HeaviSine, Blocks), holding the universal threshold rule and the default $\\alpha$, $\\beta$ fixed. Y-axes use independent scales."---- df_sh = as.data.frame(shrink_table) df_sh$signal = rownames(df_sh) df_long = reshape( df_sh, varying = c("hard", "soft", "semisoft", "scad"), v.names = "MSE", times = c("hard", "soft", "semisoft", "scad"), timevar = "shrinkage", direction = "long" ) df_long$shrinkage = factor( df_long$shrinkage, levels = c("hard", "soft", "semisoft", "scad") ) ggplot(df_long, aes(x = shrinkage, y = MSE, fill = shrinkage)) + geom_col() + facet_wrap(~ signal, scales = "free_y", ncol = 3) + scale_fill_manual( values = c( "hard" = "#E69F00", "soft" = "#56B4E9", "semisoft" = "#009E73", "scad" = "#CC79A7" ) ) + theme_minimal() + theme(legend.position = "none") + labs(title = "Shrinkage MSE by signal", x = "Shrinkage", y = "MSE") ## ----shrinkage-benchmark------------------------------------------------------ bs = subset( benchmark_rlifting, Mode == "offline" & Wavelet == "cdf53" & Boundary == "symmetric" & ThresholdMethod == "universal" & !grepl("tuned", Method) ) bs_agg = aggregate(MSE_median ~ Signal + Shrinkage, data = bs, FUN = mean) bs_wide = reshape( bs_agg, idvar = "Signal", timevar = "Shrinkage", direction = "wide" ) names(bs_wide) = sub("MSE_median\\.", "", names(bs_wide)) bs_wide$winner = c("hard", "soft", "semisoft", "scad")[ apply(bs_wide[, c("hard", "soft", "semisoft", "scad")], 1, which.min) ] bs_wide[, c("Signal", "hard", "soft", "semisoft", "scad", "winner")]