d3 = require("d3")
jStat = require("jstat")
unified_layout = {
const container = d3.create("div")
.style("width", "100%")
.style("background-color", "#f9f9f9")
.style("border", "1px solid #dee2e6")
.style("border-radius", "15px")
.style("padding", "20px")
.style("margin-bottom", "10px");
function createSlider(config) {
const sliderContainer = d3.create("div")
.style("padding", "8px")
.style("background-color", "#f9f9f9")
.style("margin-bottom", "8px")
sliderContainer.append("label")
.style("display", "block")
.style("margin-bottom", "4px")
.style("font-weight", "600")
.style("font-size", "12px")
.style("color", "#495057")
.text(config.label)
const slider = sliderContainer.append("input")
.attr("type", "range")
.attr("min", config.min)
.attr("max", config.max)
.attr("step", config.step)
.attr("value", config.value)
.style("width", "100%")
.style("height", "6px")
.style("margin-bottom", "4px")
const valueDisplay = sliderContainer.append("div")
.style("text-align", "center")
.style("font-size", "11px")
.style("color", "#6c757d")
.text(config.value)
return { container: sliderContainer, slider, valueDisplay }
}
const controlsSection = container.append("div")
.style("display", "grid")
.style("grid-template-columns", "repeat(4, 1fr)")
.style("gap", "10px")
const slider1 = createSlider({
label: "Number of tests",
min: 100,
max: 10000,
step: 100,
value: 5000
})
controlsSection.append(() => slider1.container.node())
const slider2 = createSlider({
label: "Cohen's d under H1",
min: 0,
max: 0.5,
step: 0.025,
value: 0.15
})
controlsSection.append(() => slider2.container.node())
const slider3 = createSlider({
label: "Prop. of tests that are null",
min: 0,
max: 1,
step: 0.025,
value: 0.9
})
controlsSection.append(() => slider3.container.node())
const slider4 = createSlider({
label: "Control FDR at",
min: 0,
max: 0.3,
step: 0.01,
value: 0.1
})
controlsSection.append(() => slider4.container.node())
const plotsSection = container.append("div")
.style("display", "grid")
.style("grid-template-columns", "repeat(3, 1fr)")
.style("gap", "15px")
.style("background-color", "#f9f9f9")
// plot containers
const plot1Container = plotsSection.append("div").style("display", "flex").style("justify-content", "center")
const plot2Container = plotsSection.append("div").style("display", "flex").style("justify-content", "center")
const plot3Container = plotsSection.append("div").style("display", "flex").style("justify-content", "center")
const plot4Container = plotsSection.append("div").style("display", "flex").style("justify-content", "center")
const plot5Container = plotsSection.append("div").style("display", "flex").style("justify-content", "center")
const plot6Container = plotsSection.append("div").style("display", "flex").style("justify-content", "center")
const confusionSection = container.append("div")
.style("display", "flex")
.style("justify-content", "center")
.style("align-items", "flex-start")
.style("gap", "40px")
.style("padding", "20px")
const confusionContainer = confusionSection.append("div")
const metricsContainer = confusionSection.append("div")
function benjaminiHochberg(pValues) {
const m = pValues.length;
if (m === 0) return [];
let indexedPValues = pValues.map((p, originalIndex) => ({ p, originalIndex }));
indexedPValues.sort((a, b) => a.p - b.p);
let lastQ = 1;
for (let i = m - 1; i >= 0; i--) {
const rank = i + 1;
const p = indexedPValues[i].p;
const q = Math.min(lastQ, (m / rank) * p);
indexedPValues[i].q = q;
lastQ = indexedPValues[i].q;
}
const results = new Array(m);
for (const val of indexedPValues) {
results[val.originalIndex] = { p: val.p, q: val.q };
}
return results;
}
function drawHistogram(data, title) {
const width = 280;
const height = 200;
const margin = {top: 40, right: 20, bottom: 60, left: 60};
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.style("background-color", "#f9f9f9")
const x = d3.scaleLinear()
.domain([0, 1])
.range([margin.left, width - margin.right]);
const bins = d3.histogram()
.domain(x.domain())
.thresholds(25)(data);
const y = d3.scaleLinear()
.domain([0, d3.max(bins, d => d.length)])
.range([height - margin.bottom, margin.top]);
svg.selectAll("rect")
.data(bins)
.join("rect")
.attr("x", d => x(d.x0))
.attr("y", d => y(d.length))
.attr("width", d => x(d.x1) - x(d.x0) - 1)
.attr("height", d => y(0) - y(d.length))
.attr("fill", "#4758ab");
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).ticks(5))
.selectAll("text")
.style("font-size", "12px");
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y).ticks(4))
.selectAll("text")
.style("font-size", "12px");
svg.append("text")
.attr("x", width / 2)
.attr("y", height - 10)
.attr("text-anchor", "middle")
.style("font-size", "14px")
.style("font-weight", "600")
.text("p-value");
svg.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -height / 2)
.attr("y", 20)
.attr("text-anchor", "middle")
.style("font-size", "14px")
.style("font-weight", "600")
.text("Frequency");
svg.append("text")
.attr("x", width / 2)
.attr("y", 25)
.attr("text-anchor", "middle")
.style("font-size", "16px")
.style("font-weight", "600")
.text(title);
return svg.node();
}
function drawRankPlot(data, title, alpha = null) {
const width = 280;
const height = 200;
const margin = {top: 40, right: 20, bottom: 60, left: 60};
const m = data.length;
const sorted_data = [...data].sort((a, b) => a - b);
const plot_data = sorted_data.map((p_value, k) => ({
observed: p_value,
expected: (k + 1) / m
}));
const sample_plot_data = m > 500 ? plot_data.filter((_, i) => i % Math.floor(m / 500) === 0) : plot_data;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.style("background-color", "#f9f9f9")
const x = d3.scaleLinear()
.domain([0, 1])
.range([margin.left, width - margin.right]);
const y = d3.scaleLinear()
.domain([0, 1])
.range([height - margin.bottom, margin.top]);
svg.append("line")
.attr("x1", x(0))
.attr("y1", y(0))
.attr("x2", x(1))
.attr("y2", y(1))
.attr("stroke", "grey")
.attr("stroke-dasharray", "4,4");
const line = d3.line()
.x(d => x(d.expected))
.y(d => y(d.observed));
svg.append("path")
.datum(plot_data)
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 2)
.attr("d", line);
svg.selectAll("circle")
.data(sample_plot_data)
.join("circle")
.attr("cx", d => x(d.expected))
.attr("cy", d => y(d.observed))
.attr("r", 1.5)
.attr("fill", "steelblue");
if (alpha) {
svg.append("line")
.attr("x1", x(0))
.attr("y1", y(0))
.attr("x2", x(1))
.attr("y2", y(alpha))
.attr("stroke", "red")
.attr("stroke-width", 1.5);
}
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).ticks(4))
.selectAll("text")
.style("font-size", "12px");
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y).ticks(4))
.selectAll("text")
.style("font-size", "12px");
svg.append("text")
.attr("x", width / 2)
.attr("y", height - 10)
.attr("text-anchor", "middle")
.style("font-size", "14px")
.style("font-weight", "600")
.text("Expected p-value");
svg.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -height / 2)
.attr("y", 20)
.attr("text-anchor", "middle")
.style("font-size", "14px")
.style("font-weight", "600")
.text("Observed P-Value");
svg.append("text")
.attr("x", width / 2)
.attr("y", 25)
.attr("text-anchor", "middle")
.style("font-size", "16px")
.style("font-weight", "600")
.text(title);
return svg.node();
}
function createConfusionMatrix(metrics) {
const table = d3.create("table")
.style("border-collapse", "collapse")
.style("margin", "0 auto")
.style("font-size", "14px");
const header = table.append("thead").append("tr");
header.append("th").style("border", "1px solid #ddd").style("padding", "8px").text("");
header.append("th").style("border", "1px solid #ddd").style("padding", "8px").style("background-color", "#f8f9fa").text("Rejected");
header.append("th").style("border", "1px solid #ddd").style("padding", "8px").style("background-color", "#f8f9fa").text("Not Rejected");
const tbody = table.append("tbody");
const row1 = tbody.append("tr");
row1.append("td").style("border", "1px solid #ddd").style("padding", "8px").style("background-color", "#f8f9fa").style("font-weight", "bold").text("H1 True");
row1.append("td").style("border", "1px solid #ddd").style("padding", "8px").style("background-color", "#d4edda").text(`${metrics.TP} (TP)`);
row1.append("td").style("border", "1px solid #ddd").style("padding", "8px").style("background-color", "#f8d7da").text(`${metrics.FN} (FN)`);
const row2 = tbody.append("tr");
row2.append("td").style("border", "1px solid #ddd").style("padding", "8px").style("background-color", "#f8f9fa").style("font-weight", "bold").text("H0 True");
row2.append("td").style("border", "1px solid #ddd").style("padding", "8px").style("background-color", "#f8d7da").text(`${metrics.FP} (FP)`);
row2.append("td").style("border", "1px solid #ddd").style("padding", "8px").style("background-color", "#d4edda").text(`${metrics.TN} (TN)`);
return table.node();
}
function createMetricsTable(metrics) {
const table = d3.create("table")
.style("border-collapse", "collapse")
.style("margin", "0 auto")
.style("font-size", "14px");
const tbody = table.append("tbody");
const rejectedRow = tbody.append("tr");
rejectedRow.append("td")
.style("border", "1px solid #ddd")
.style("padding", "8px")
.style("background-color", "#f8f9fa")
.style("font-weight", "bold")
.text("Rejected:");
rejectedRow.append("td")
.style("border", "1px solid #ddd")
.style("padding", "8px")
.style("background-color", "#f8f9fa")
.text(metrics.r);
const sensitivityRow = tbody.append("tr");
sensitivityRow.append("td")
.style("border", "1px solid #ddd")
.style("padding", "8px")
.style("background-color", "#f8f9fa")
.style("font-weight", "bold")
.text("Power:");
sensitivityRow.append("td")
.style("border", "1px solid #ddd")
.style("padding", "8px")
.style("background-color", "#f8f9fa")
.text(metrics.sensitivity.toFixed(3));
const fdrRow = tbody.append("tr");
fdrRow.append("td")
.style("border", "1px solid #ddd")
.style("padding", "8px")
.style("background-color", "#f8f9fa")
.style("font-weight", "bold")
.text("FDP:");
fdrRow.append("td")
.style("border", "1px solid #ddd")
.style("padding", "8px")
.style("background-color", "#f8f9fa")
.text(metrics.fdr.toFixed(3));
return table.node();
}
function updateVisualization() {
const nr_tests = parseInt(slider1.slider.property("value"));
const cohen_d_alt = parseFloat(slider2.slider.property("value"));
const prop_null = parseFloat(slider3.slider.property("value"));
const alpha = parseFloat(slider4.slider.property("value"));
slider1.valueDisplay.text(nr_tests);
slider2.valueDisplay.text(cohen_d_alt);
slider3.valueDisplay.text(prop_null);
slider4.valueDisplay.text(alpha);
// actual data calculations
const n = 250; // size study
const nr_h0_tests = Math.floor(nr_tests * prop_null);
const nr_h1_tests = Math.floor(nr_tests * (1 - prop_null));
const ncp = cohen_d_alt * Math.sqrt(n);
const p_values_h1 = [];
for (let i = 0; i < nr_h1_tests; i++) {
const z = d3.randomNormal(ncp, 1)();
const p = 2 * (1 - jStat.normal.cdf(Math.abs(z), 0, 1));
p_values_h1.push(p);
}
const p_values_h0 = Array.from({length: nr_h0_tests}, Math.random)
const p_values_merged = p_values_h0.concat(p_values_h1);
// calculate metrics
const bh_results = benjaminiHochberg(p_values_merged);
const m = bh_results.length
const r = bh_results.filter(r => r.q < alpha).length;
const h0_results = bh_results.slice(0, nr_h0_tests);
const h1_results = bh_results.slice(nr_h0_tests, nr_tests);
const TP = h1_results.filter(r => r.q < alpha).length;
const FP = h0_results.filter(r => r.q < alpha).length;
const FN = h1_results.filter(r => r.q >= alpha).length;
const TN = h0_results.filter(r => r.q >= alpha).length;
// const total_rejections = TP + FP;
const fdr = r === 0 ? 0 : FP / r;
const sensitivity = (TP + FN) === 0 ? 0 : TP / (TP + FN);
const metrics = {m, r, TP, FP, FN, TN, fdr, sensitivity};
// clear and update plots
plot1Container.selectAll("*").remove();
plot2Container.selectAll("*").remove();
plot3Container.selectAll("*").remove();
plot4Container.selectAll("*").remove();
plot5Container.selectAll("*").remove();
plot6Container.selectAll("*").remove();
plot1Container.append(() => drawHistogram(p_values_h0, "H0 p-values"));
plot2Container.append(() => drawHistogram(p_values_h1, "H1 p-values"));
plot3Container.append(() => drawHistogram(p_values_merged, "H0 + H1 p-Values"));
plot4Container.append(() => drawRankPlot(p_values_h0, "H0 rank plot"));
plot5Container.append(() => drawRankPlot(p_values_h1, "H1 rank plot"));
plot6Container.append(() => drawRankPlot(p_values_merged, "H0 + H1 rank plot", alpha));
// update confusion matrix and metrics
confusionContainer.selectAll("*").remove();
metricsContainer.selectAll("*").remove();
confusionContainer.append(() => createConfusionMatrix(metrics));
metricsContainer.append(() => createMetricsTable(metrics));
}
// event listeners
slider1.slider.on("input", updateVisualization);
slider2.slider.on("input", updateVisualization);
slider3.slider.on("input", updateVisualization);
slider4.slider.on("input", updateVisualization);
// initial render
updateVisualization();
return container.node();
}