Java tutorial
/*- * #%L * This file is part of QuPath. * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) * %% * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public * License along with this program. If not, see * <http://www.gnu.org/licenses/gpl-3.0.html>. * #L% */ package qupath.lib.gui.panels.survival; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.Map.Entry; import org.apache.commons.math3.distribution.NormalDistribution; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javafx.application.Platform; import javafx.beans.property.DoubleProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableNumberValue; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.chart.LineChart; import javafx.scene.chart.NumberAxis; import javafx.scene.chart.XYChart; import javafx.scene.chart.XYChart.Data; import javafx.scene.control.ContextMenu; import javafx.scene.control.Menu; import javafx.scene.control.MenuItem; import javafx.scene.control.RadioMenuItem; import javafx.scene.control.TableCell; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.TitledPane; import javafx.scene.control.ToggleGroup; import javafx.scene.control.Tooltip; import javafx.scene.control.TableColumn.CellDataFeatures; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.GridPane; import javafx.scene.layout.Pane; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.stage.Stage; import javafx.stage.Window; import javafx.util.Callback; import qupath.lib.analysis.stats.Histogram; import qupath.lib.analysis.stats.StatisticsHelper; import qupath.lib.analysis.stats.survival.KaplanMeierData; import qupath.lib.analysis.stats.survival.LogRankTest; import qupath.lib.analysis.stats.survival.LogRankTest.LogRankResult; import qupath.lib.common.GeneralTools; import qupath.lib.gui.helpers.ChartToolsFX; import qupath.lib.gui.helpers.ColorToolsFX; import qupath.lib.gui.helpers.dialogs.ParameterPanelFX; import qupath.lib.gui.plots.HistogramPanelFX; import qupath.lib.gui.plots.HistogramPanelFX.ThresholdedChartWrapper; import qupath.lib.measurements.MeasurementList; import qupath.lib.objects.TMACoreObject; import qupath.lib.objects.helpers.PathObjectTools; import qupath.lib.objects.hierarchy.PathObjectHierarchy; import qupath.lib.objects.hierarchy.events.PathObjectHierarchyEvent; import qupath.lib.objects.hierarchy.events.PathObjectHierarchyListener; import qupath.lib.plugins.parameters.IntParameter; import qupath.lib.plugins.parameters.ParameterChangeListener; import qupath.lib.plugins.parameters.ParameterList; /** * Create an manage a display component for survival data. * * @author Pete Bankhead * */ public class KaplanMeierDisplay implements ParameterChangeListener, PathObjectHierarchyListener { final private static Logger logger = LoggerFactory.getLogger(KaplanMeierDisplay.class); // Flag to enable/disable calculating all P-values, and allowing threshold to be set to the lowest private boolean calculateAllPValues = true; private PathObjectHierarchy hierarchy; private HistogramPanelFX histogramPanel; private ThresholdedChartWrapper histogramWrapper; private LineChart<Number, Number> chartPValues; private ThresholdedChartWrapper pValuesWrapper; private KaplanMeierChartWrapper plotter; private ParameterList params; private ParameterPanelFX panelParams; private BorderPane paneMain = new BorderPane(); private TableView<Integer> table = new TableView<>(); private KaplanMeierTableModel tableModel = new KaplanMeierTableModel(table); private String scoreColumn; private KaplanMeierDisplay.ScoreData scoreData; // P-value computations are relatively expensive... so cache the results for possible reuse private double lastPValueCensorThreshold = Double.NaN; private double[] pValues = null; private double[] pValuesSmoothed = null; private double[] pValueThresholds = null; private boolean[] pValueThresholdsObserved = null; // Wrapper class for storing score data - helps to reduce (relatively) expensive p-value computations private static class ScoreData { @Override public int hashCode() { final int prime = 31; int result = 1; // long temp; // temp = Double.doubleToLongBits(censorTime); // result = prime * result + (int) (temp ^ (temp >>> 32)); result = prime * result + Arrays.hashCode(censored); result = prime * result + Arrays.hashCode(scores); result = prime * result + Arrays.hashCode(survival); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; KaplanMeierDisplay.ScoreData other = (KaplanMeierDisplay.ScoreData) obj; // if (Double.doubleToLongBits(censorTime) != Double.doubleToLongBits(other.censorTime)) // return false; if (!Arrays.equals(censored, other.censored)) return false; if (!Arrays.equals(scores, other.scores)) return false; if (!Arrays.equals(survival, other.survival)) return false; return true; } double[] scores; double[] survival; boolean[] censored; // double censorTime; ScoreData(final double[] scores, final double[] survival, final boolean[] censored) { this.scores = scores; this.survival = survival; this.censored = censored; } } // Threshold properties, used for displaying on private DoubleProperty[] threshProperties = new DoubleProperty[] { new SimpleDoubleProperty(Double.NaN), new SimpleDoubleProperty(Double.NaN), new SimpleDoubleProperty(Double.NaN) }; private String survivalColumn; private String censoredColumn; public KaplanMeierDisplay(final PathObjectHierarchy hierarchy, final String scoreColumn, final String survivalColumn, final String censoredColumn) { this.hierarchy = hierarchy; if (this.hierarchy != null) this.hierarchy.addPathObjectListener(this); this.scoreColumn = scoreColumn; this.survivalColumn = survivalColumn; this.censoredColumn = censoredColumn; initialize(); } private void initialize() { generatePlot(); } public Parent getView() { return paneMain; } private Stage createStage(final Window parent, final String title) { Stage frame = new Stage(); if (parent != null) frame.initOwner(parent); frame.setTitle("Kaplan Meier: " + title); frame.setOnCloseRequest(e -> { if (hierarchy != null) hierarchy.removePathObjectListener(KaplanMeierDisplay.this); panelParams.removeParameterChangeListener(KaplanMeierDisplay.this); frame.hide(); }); Scene scene = new Scene(getView(), 600, 400); frame.setScene(scene); frame.setMinWidth(600); frame.setMinHeight(400); return frame; } /** * Show in own window, with optional parent window specified. * * @param parent * @param title * @return */ public Stage show(final Window parent, final String title) { Stage frame = createStage(parent, title); frame.show(); return frame; } @Override public void hierarchyChanged(PathObjectHierarchyEvent event) { if (!Platform.isFxApplicationThread()) Platform.runLater(() -> hierarchyChanged(event)); else generatePlot(); } /** * Set a new hierarchy, updating the plot accordingly. * * @param hierarchy */ public void setHierarchy(final PathObjectHierarchy hierarchy, final String survivalKey, final String censoredKey) { if (this.hierarchy != null) this.hierarchy.removePathObjectListener(this); this.survivalColumn = survivalKey; this.censoredColumn = censoredKey; this.hierarchy = hierarchy; if (this.hierarchy != null) this.hierarchy.addPathObjectListener(this); generatePlot(); } public String getScoreColumn() { return scoreColumn; } public void setScoreColumn(final String scoreColumn) { this.scoreColumn = scoreColumn; refresh(); } public void refresh() { generatePlot(); } @SuppressWarnings("unchecked") private void generatePlot() { KaplanMeierDisplay.ScoreData newScoreData = scoreData; // If we have a hierarchy, update the scores with the most recent data if (hierarchy != null) { List<TMACoreObject> cores = PathObjectTools.getTMACoreObjects(hierarchy, false); double[] survival = new double[cores.size()]; boolean[] censored = new boolean[cores.size()]; double[] scores = new double[cores.size()]; // // Optionally sort by scores... helps a bit when debugging e.g. p-values, Hazard ratios etc. // cores.sort((c1, c2) -> Double.compare(c1.getMeasurementList().getMeasurementValue(scoreColumn), c2.getMeasurementList().getMeasurementValue(scoreColumn))); // scoreColumn = "Positive %"; // scoreColumn = "RoughScore"; for (int i = 0; i < cores.size(); i++) { TMACoreObject core = cores.get(i); MeasurementList ml = core.getMeasurementList(); survival[i] = core.getMeasurementList().getMeasurementValue(survivalColumn); double censoredValue = core.getMeasurementList().getMeasurementValue(censoredColumn); boolean hasCensoredValue = !Double.isNaN(censoredValue) && (censoredValue == 0 || censoredValue == 1); censored[i] = censoredValue != 0; if (!hasCensoredValue) { // If we don't have a censored value, ensure we mask out everything else scores[i] = Double.NaN; survival[i] = Double.NaN; } else if (ml.containsNamedMeasurement(scoreColumn)) // Get the score if we can scores[i] = ml.getMeasurementValue(scoreColumn); else { // // Try to compute score if we need to // Map<String, Number> map = ROIMeaningfulMeasurements.getPathClassSummaryMeasurements(core.getChildObjects(), true); // Number value = map.get(scoreColumn); // if (value == null) scores[i] = Double.NaN; // else // scores[i] = value.doubleValue(); } } // Mask out any scores that don't have associated survival data for (int i = 0; i < survival.length; i++) { if (Double.isNaN(survival[i])) scores[i] = Double.NaN; } newScoreData = new ScoreData(scores, survival, censored); } if (newScoreData == null || newScoreData.scores.length == 0) return; // KaplanMeier kmHigh = new KaplanMeier("Above threshold"); // KaplanMeier kmLow = new KaplanMeier("Below threshold"); double[] quartiles = StatisticsHelper.getQuartiles(newScoreData.scores); double q1 = quartiles[0]; double median = quartiles[1]; double q3 = quartiles[2]; double[] thresholds; if (params != null) { Object thresholdMethod = params.getChoiceParameterValue("scoreThresholdMethod"); if (thresholdMethod.equals("Median")) { // panelParams.setNumericParameterValue("scoreThreshold", median); // ((DoubleParameter)params.getParameters().get("scoreThreshold")).setValue(median); // TODO: UPDATE DIALOG! thresholds = new double[] { median }; } else if (thresholdMethod.equals("Tertiles")) { // ((DoubleParameter)params.getParameters().get("scoreThreshold")).setValue(median); // TODO: UPDATE DIALOG! thresholds = StatisticsHelper.getTertiles(newScoreData.scores); } else if (thresholdMethod.equals("Quartiles")) { // ((DoubleParameter)params.getParameters().get("scoreThreshold")).setValue(median); // TODO: UPDATE DIALOG! thresholds = new double[] { q1, median, q3 }; } else if (thresholdMethod.equals("Manual (1)")) { thresholds = new double[] { params.getDoubleParameterValue("threshold1") }; } else if (thresholdMethod.equals("Manual (2)")) { thresholds = new double[] { params.getDoubleParameterValue("threshold1"), params.getDoubleParameterValue("threshold2") }; } else //if (thresholdMethod.equals("Manual (3)")) { thresholds = new double[] { params.getDoubleParameterValue("threshold1"), params.getDoubleParameterValue("threshold2"), params.getDoubleParameterValue("threshold3") }; } else thresholds = new double[] { median }; double minVal = Double.POSITIVE_INFINITY; double maxVal = Double.NEGATIVE_INFINITY; int numNonNaN = 0; for (double d : newScoreData.scores) { if (Double.isNaN(d)) continue; if (d < minVal) minVal = d; if (d > maxVal) maxVal = d; numNonNaN++; } boolean scoresValid = maxVal > minVal; // If not this, we don't have valid scores that we can work with double maxTimePoint = 0; for (double d : newScoreData.survival) { if (Double.isNaN(d)) continue; if (d > maxTimePoint) maxTimePoint = d; } if (panelParams != null && maxTimePoint > ((IntParameter) params.getParameters().get("censorTimePoints")).getUpperBound()) { panelParams.setNumericParameterValueRange("censorTimePoints", 0, Math.ceil(maxTimePoint)); } // Optionally censor at specified time double censorThreshold = params == null ? maxTimePoint : params.getIntParameterValue("censorTimePoints"); // Compute log-rank p-values for *all* possible thresholds // Simultaneously determine the threshold that yields the lowest p-value, // resolving ties in favour of a more even split between high/low numbers of events boolean pValuesChanged = false; if (calculateAllPValues) { if (!(pValues != null && pValueThresholds != null && newScoreData.equals(scoreData) && censorThreshold == lastPValueCensorThreshold)) { Map<Double, Double> mapLogRank = new TreeMap<>(); Set<Double> setObserved = new HashSet<>(); for (int i = 0; i < newScoreData.scores.length; i++) { Double d = newScoreData.scores[i]; boolean observed = !newScoreData.censored[i] && newScoreData.survival[i] < censorThreshold; if (observed) setObserved.add(d); if (mapLogRank.containsKey(d)) continue; List<KaplanMeierData> kmsTemp = splitByThresholds(newScoreData, new double[] { d }, censorThreshold, false); // if (kmsTemp.get(1).nObserved() == 0 || kmsTemp.get(1).nObserved() == 0) // continue; LogRankResult test = LogRankTest.computeLogRankTest(kmsTemp.get(0), kmsTemp.get(1)); double pValue = test.getPValue(); // double pValue = test.hazardRatio < 1 ? test.hazardRatio : 1.0/test.hazardRatio; // Checking usefulness of Hazard ratios... if (!Double.isFinite(pValue)) continue; // if (!Double.isFinite(test.getHazardRatio())) { //// continue; // pValue = Double.NaN; // } mapLogRank.put(d, pValue); } pValueThresholds = new double[mapLogRank.size()]; pValues = new double[mapLogRank.size()]; pValueThresholdsObserved = new boolean[mapLogRank.size()]; int count = 0; for (Entry<Double, Double> entry : mapLogRank.entrySet()) { pValueThresholds[count] = entry.getKey(); pValues[count] = entry.getValue(); if (setObserved.contains(entry.getKey())) pValueThresholdsObserved[count] = true; count++; } // Find the longest 'significant' stretch int maxSigCount = 0; int maxSigInd = -1; int sigCurrent = 0; int[] sigCount = new int[pValues.length]; for (int i = 0; i < pValues.length; i++) { if (pValues[i] < 0.05) { sigCurrent++; sigCount[i] = sigCurrent; if (sigCurrent > maxSigCount) { maxSigCount = sigCurrent; maxSigInd = i; } } else sigCurrent = 0; } if (maxSigCount == 0) { logger.info("No p-values < 0.05"); } else { double minThresh = maxSigInd - maxSigCount < 0 ? pValueThresholds[0] - 0.0000001 : pValueThresholds[maxSigInd - maxSigCount]; double maxThresh = pValueThresholds[maxSigInd]; int nBetween = 0; int nBetweenObserved = 0; for (int i = 0; i < newScoreData.scores.length; i++) { if (newScoreData.scores[i] > minThresh && newScoreData.scores[i] <= maxThresh) { nBetween++; if (newScoreData.survival[i] < censorThreshold && !newScoreData.censored[i]) nBetweenObserved++; } } logger.info("Longest stretch of p-values < 0.05: {} - {} ({} entries, {} observed)", minThresh, maxThresh, nBetween, nBetweenObserved); } pValuesSmoothed = new double[pValues.length]; Arrays.fill(pValuesSmoothed, Double.NaN); int n = (pValues.length / 20) * 2 + 1; logger.info("Smoothing log-rank test p-values by " + n); for (int i = n / 2; i < pValues.length - n / 2; i++) { double sum = 0; for (int k = i - n / 2; k < i - n / 2 + n; k++) { sum += pValues[k]; } pValuesSmoothed[i] = sum / n; } // for (int i = 0; i < pValues.length; i++) { // double sum = 0; // for (int k = Math.max(0, i-n/2); k < Math.min(pValues.length, i-n/2+n); k++) { // sum += pValues[k]; // } // pValuesSmoothed[i] = sum/n; // } // pValues = pValuesSmoothed; lastPValueCensorThreshold = censorThreshold; pValuesChanged = true; } } else { lastPValueCensorThreshold = Double.NaN; pValueThresholds = null; pValues = null; } // if (params != null && !Double.isNaN(bestThreshold) && (params.getChoiceParameterValue("scoreThresholdMethod").equals("Lowest p-value"))) if (params != null && (params.getChoiceParameterValue("scoreThresholdMethod").equals("Lowest p-value"))) { int bestIdx = -1; double bestPValue = Double.POSITIVE_INFINITY; for (int i = pValueThresholds.length / 10; i < pValueThresholds.length * 9 / 10; i++) { if (pValues[i] < bestPValue) { bestIdx = i; bestPValue = pValues[i]; } } thresholds = bestIdx >= 0 ? new double[] { pValueThresholds[bestIdx] } : new double[0]; } else if (params != null && (params.getChoiceParameterValue("scoreThresholdMethod").equals("Lowest smoothed p-value"))) { int bestIdx = -1; double bestPValue = Double.POSITIVE_INFINITY; for (int i = pValueThresholds.length / 10; i < pValueThresholds.length * 9 / 10; i++) { if (pValuesSmoothed[i] < bestPValue) { bestIdx = i; bestPValue = pValuesSmoothed[i]; } } thresholds = bestIdx >= 0 ? new double[] { pValueThresholds[bestIdx] } : new double[0]; } // Split into different curves using the provided thresholds List<KaplanMeierData> kms = splitByThresholds(newScoreData, thresholds, censorThreshold, params != null && "Quartiles".equals(params.getChoiceParameterValue("scoreThresholdMethod"))); // for (KaplanMeier km : kms) // km.censorAtTime(censorThreshold); //// kmHigh.censorAtTime(censorThreshold); //// kmLow.censorAtTime(censorThreshold); // logger.info("High: " + kmHigh.toString()); // logger.info("Low: " + kmLow.toString()); // logger.info("Log rank comparison: {}", LogRankTest.computeLogRankTest(kmLow, kmHigh)); if (plotter == null) { plotter = new KaplanMeierChartWrapper(survivalColumn + " time"); // plotter.setBorder(BorderFactory.createTitledBorder("Survival plot")); // plotter.getCanvas().setWidth(300); // plotter.getCanvas().setHeight(300); } KaplanMeierData[] kmArray = new KaplanMeierData[kms.size()]; plotter.setKaplanMeierCurves(survivalColumn + " time", kms.toArray(kmArray)); tableModel.setSurvivalCurves(thresholds, params != null && params.getChoiceParameterValue("scoreThresholdMethod").equals("Lowest p-value"), kmArray); // Bar width determined using 'Freedman and Diaconis' rule' (but overridden if this gives < 16 bins...) double barWidth = (2 * q3 - q1) * Math.pow(numNonNaN, -1.0 / 3.0); int nBins = 100; if (!Double.isNaN(barWidth)) barWidth = (int) Math.max(16, Math.ceil((maxVal - minVal) / barWidth)); Histogram histogram = scoresValid ? new Histogram(newScoreData.scores, nBins) : null; if (histogramPanel == null) { GridPane paneHistogram = new GridPane(); histogramPanel = new HistogramPanelFX(); histogramPanel.getChart().setAnimated(false); histogramWrapper = new ThresholdedChartWrapper(histogramPanel.getChart()); for (ObservableNumberValue val : threshProperties) histogramWrapper.addThreshold(val, ColorToolsFX.getCachedColor(240, 0, 0, 128)); histogramWrapper.getPane().setPrefHeight(150); paneHistogram.add(histogramWrapper.getPane(), 0, 0); Tooltip.install(histogramPanel.getChart(), new Tooltip("Distribution of scores")); GridPane.setHgrow(histogramWrapper.getPane(), Priority.ALWAYS); GridPane.setVgrow(histogramWrapper.getPane(), Priority.ALWAYS); NumberAxis xAxis = new NumberAxis(); xAxis.setLabel("Score threshold"); NumberAxis yAxis = new NumberAxis(); yAxis.setLowerBound(0); yAxis.setUpperBound(1); yAxis.setTickUnit(0.1); yAxis.setAutoRanging(false); yAxis.setLabel("P-value"); chartPValues = new LineChart<>(xAxis, yAxis); chartPValues.setAnimated(false); chartPValues.setLegendVisible(false); // Make chart so it can be navigated ChartToolsFX.makeChartInteractive(chartPValues, xAxis, yAxis); pValuesChanged = true; Tooltip.install(chartPValues, new Tooltip( "Distribution of p-values (log-rank test) comparing low vs. high for all possible score thresholds")); // chartPValues.getYAxis().setAutoRanging(false); pValuesWrapper = new ThresholdedChartWrapper(chartPValues); for (ObservableNumberValue val : threshProperties) pValuesWrapper.addThreshold(val, ColorToolsFX.getCachedColor(240, 0, 0, 128)); pValuesWrapper.getPane().setPrefHeight(150); paneHistogram.add(pValuesWrapper.getPane(), 0, 1); GridPane.setHgrow(pValuesWrapper.getPane(), Priority.ALWAYS); GridPane.setVgrow(pValuesWrapper.getPane(), Priority.ALWAYS); ContextMenu popup = new ContextMenu(); ChartToolsFX.addChartExportMenu(chartPValues, popup); RadioMenuItem miZoomY1 = new RadioMenuItem("0-1"); miZoomY1.setOnAction(e -> { yAxis.setAutoRanging(false); yAxis.setUpperBound(1); yAxis.setTickUnit(0.2); }); RadioMenuItem miZoomY05 = new RadioMenuItem("0-0.5"); miZoomY05.setOnAction(e -> { yAxis.setAutoRanging(false); yAxis.setUpperBound(0.5); yAxis.setTickUnit(0.1); }); RadioMenuItem miZoomY02 = new RadioMenuItem("0-0.2"); miZoomY02.setOnAction(e -> { yAxis.setAutoRanging(false); yAxis.setUpperBound(0.2); yAxis.setTickUnit(0.05); }); RadioMenuItem miZoomY01 = new RadioMenuItem("0-0.1"); miZoomY01.setOnAction(e -> { yAxis.setAutoRanging(false); yAxis.setUpperBound(0.1); yAxis.setTickUnit(0.05); }); RadioMenuItem miZoomY005 = new RadioMenuItem("0-0.05"); miZoomY005.setOnAction(e -> { yAxis.setAutoRanging(false); yAxis.setUpperBound(0.05); yAxis.setTickUnit(0.01); }); RadioMenuItem miZoomY001 = new RadioMenuItem("0-0.01"); miZoomY001.setOnAction(e -> { yAxis.setAutoRanging(false); yAxis.setUpperBound(0.01); yAxis.setTickUnit(0.005); }); ToggleGroup tgZoom = new ToggleGroup(); miZoomY1.setToggleGroup(tgZoom); miZoomY05.setToggleGroup(tgZoom); miZoomY02.setToggleGroup(tgZoom); miZoomY01.setToggleGroup(tgZoom); miZoomY005.setToggleGroup(tgZoom); miZoomY001.setToggleGroup(tgZoom); Menu menuZoomY = new Menu("Set y-axis range"); menuZoomY.getItems().addAll(miZoomY1, miZoomY05, miZoomY02, miZoomY01, miZoomY005, miZoomY001); MenuItem miCopyData = new MenuItem("Copy chart data"); miCopyData.setOnAction(e -> { String dataString = ChartToolsFX.getChartDataAsString(chartPValues); ClipboardContent content = new ClipboardContent(); content.putString(dataString); Clipboard.getSystemClipboard().setContent(content); }); popup.getItems().addAll(miCopyData, menuZoomY); chartPValues.setOnContextMenuRequested(e -> { popup.show(chartPValues, e.getScreenX(), e.getScreenY()); }); for (int col = 0; col < tableModel.getColumnCount(); col++) { TableColumn<Integer, String> column = new TableColumn<>(tableModel.getColumnName(col)); int colNumber = col; column.setCellValueFactory( new Callback<CellDataFeatures<Integer, String>, ObservableValue<String>>() { @Override public ObservableValue<String> call(CellDataFeatures<Integer, String> p) { return new SimpleStringProperty( (String) tableModel.getValueAt(p.getValue(), colNumber)); } }); column.setCellFactory(new Callback<TableColumn<Integer, String>, TableCell<Integer, String>>() { @Override public TableCell<Integer, String> call(TableColumn<Integer, String> param) { TableCell<Integer, String> cell = new TableCell<Integer, String>() { @Override protected void updateItem(String item, boolean empty) { super.updateItem(item, empty); setText(item); setTooltip(new Tooltip(item)); } }; return cell; } }); table.getColumns().add(column); } table.setPrefHeight(250); table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); table.maxHeightProperty().bind(table.prefHeightProperty()); params = new ParameterList(); // maxTimePoint = 0; // for (TMACoreObject core : hierarchy.getTMAGrid().getTMACoreList()) { // double os = core.getMeasurementList().getMeasurementValue(TMACoreObject.KEY_OVERALL_SURVIVAL); // double rfs = core.getMeasurementList().getMeasurementValue(TMACoreObject.KEY_RECURRENCE_FREE_SURVIVAL); // if (os > maxTimePoint) // maxTimePoint = os; // if (rfs > maxTimePoint) // maxTimePoint = rfs; // } params.addIntParameter("censorTimePoints", "Max censored time", (int) (censorThreshold + 0.5), null, 0, (int) Math.ceil(maxTimePoint), "Latest time point beyond which data will be censored"); // params.addChoiceParameter("scoreThresholdMethod", "Threshold method", "Manual", Arrays.asList("Manual", "Median", "Log-rank test")); if (calculateAllPValues) // Don't include "Lowest smoothed p-value" - it's not an established method and open to misinterpretation... params.addChoiceParameter("scoreThresholdMethod", "Threshold method", "Median", Arrays.asList("Manual (1)", "Manual (2)", "Manual (3)", "Median", "Tertiles", "Quartiles", "Lowest p-value")); // params.addChoiceParameter("scoreThresholdMethod", "Threshold method", "Median", Arrays.asList("Manual (1)", "Manual (2)", "Manual (3)", "Median", "Tertiles", "Quartiles", "Lowest p-value", "Lowest smoothed p-value")); else params.addChoiceParameter("scoreThresholdMethod", "Threshold method", "Median", Arrays.asList("Manual (1)", "Manual (2)", "Manual (3)", "Median", "Tertiles", "Quartiles")); params.addDoubleParameter("threshold1", "Threshold 1", thresholds.length > 0 ? thresholds[0] : (minVal + maxVal) / 2, null, "Threshold to distinguish between patient groups"); params.addDoubleParameter("threshold2", "Threshold 2", thresholds.length > 1 ? thresholds[1] : (minVal + maxVal) / 2, null, "Threshold to distinguish between patient groups"); params.addDoubleParameter("threshold3", "Threshold 3", thresholds.length > 2 ? thresholds[2] : (minVal + maxVal) / 2, null, "Threshold to distinguish between patient groups"); params.addBooleanParameter("showAtRisk", "Show at risk", plotter.getShowAtRisk(), "Show number of patients at risk below the plot"); params.addBooleanParameter("showTicks", "Show censored ticks", plotter.getShowCensoredTicks(), "Show ticks to indicate censored data"); params.addBooleanParameter("showKey", "Show key", plotter.getShowKey(), "Show key indicating display of each curve"); // params.addBooleanParameter("useColor", "Use color", plotter.getUseColor(), "Show each curve in a different color"); // params.addBooleanParameter("useStrokes", "Use strokes", plotter.getUseStrokes(), "Show each curve with a differed line stroke"); // Hide threshold parameters if threshold can't be used if (!scoresValid) { // params.setHiddenParameters(true, "scoreThresholdMethod", "scoreThreshold"); histogramPanel.getChart().setVisible(false); } panelParams = new ParameterPanelFX(params); panelParams.addParameterChangeListener(this); updateThresholdsEnabled(); for (int i = 0; i < threshProperties.length; i++) { String p = "threshold" + (i + 1); threshProperties[i].addListener((v, o, n) -> { if (interactiveThresholds()) { // Need to do a decent double check with tolerance to text field value changing while typing if (!GeneralTools.almostTheSame(params.getDoubleParameterValue(p), n.doubleValue(), 0.0001)) panelParams.setNumericParameterValue(p, n); } }); } BorderPane paneBottom = new BorderPane(); TitledPane paneOptions = new TitledPane("Options", panelParams.getPane()); // paneOptions.setCollapsible(false); Pane paneCanvas = new StackPane(); paneCanvas.getChildren().add(plotter.getCanvas()); GridPane paneLeft = new GridPane(); paneLeft.add(paneOptions, 0, 0); paneLeft.add(table, 0, 1); GridPane.setHgrow(paneOptions, Priority.ALWAYS); GridPane.setHgrow(table, Priority.ALWAYS); paneBottom.setLeft(paneLeft); paneBottom.setCenter(paneHistogram); paneMain.setCenter(paneCanvas); paneMain.setBottom(paneBottom); paneMain.setPadding(new Insets(10, 10, 10, 10)); } else if (thresholds.length > 0) { // Ensure the sliders/text fields are set sensibly if (!GeneralTools.almostTheSame(thresholds[0], params.getDoubleParameterValue("threshold1"), 0.0001)) { panelParams.setNumericParameterValue("threshold1", thresholds[0]); } if (thresholds.length > 1 && !GeneralTools.almostTheSame(thresholds[1], params.getDoubleParameterValue("threshold2"), 0.0001)) { panelParams.setNumericParameterValue("threshold2", thresholds[1]); } if (thresholds.length > 2 && !GeneralTools.almostTheSame(thresholds[2], params.getDoubleParameterValue("threshold3"), 0.0001)) { panelParams.setNumericParameterValue("threshold3", thresholds[2]); } } if (histogram != null) { histogramPanel.getHistogramData() .setAll(HistogramPanelFX.createHistogramData(histogram, false, (Color) null)); histogramPanel.getChart().getXAxis().setLabel(scoreColumn); histogramPanel.getChart().getYAxis().setLabel("Count"); ChartToolsFX.addChartExportMenu(histogramPanel.getChart(), null); // histogramWrapper.setVerticalLines(thresholds, ColorToolsFX.getCachedColor(240, 0, 0, 128)); // Deal with threshold adjustment // histogramWrapper.getThresholds().addListener((Observable o) -> generatePlot()); } if (pValues != null) { // TODO: Raise earlier where p-value calculation is if (pValuesChanged) { ObservableList<XYChart.Data<Number, Number>> data = FXCollections.observableArrayList(); for (int i = 0; i < pValueThresholds.length; i++) { double pValue = pValues[i]; if (Double.isNaN(pValue)) continue; data.add(new XYChart.Data<>(pValueThresholds[i], pValue, pValueThresholdsObserved[i])); } ObservableList<XYChart.Data<Number, Number>> dataSmoothed = null; if (pValuesSmoothed != null) { dataSmoothed = FXCollections.observableArrayList(); for (int i = 0; i < pValueThresholds.length; i++) { double pValueSmoothed = pValuesSmoothed[i]; if (Double.isNaN(pValueSmoothed)) continue; dataSmoothed.add(new XYChart.Data<>(pValueThresholds[i], pValueSmoothed)); } } // Don't bother showing the smoothed data... it tends to get in the way... // if (dataSmoothed != null) // chartPValues.getData().setAll(new XYChart.Series<>("P-values", data), new XYChart.Series<>("Smoothed P-values", dataSmoothed)); // else chartPValues.getData().setAll(new XYChart.Series<>("P-values", data)); // Add line to show 0.05 significance threshold if (pValueThresholds.length > 1) { Data<Number, Number> sigData1 = new Data<>(pValueThresholds[0], 0.05); Data<Number, Number> sigData2 = new Data<>(pValueThresholds[pValueThresholds.length - 1], 0.05); XYChart.Series<Number, Number> dataSignificant = new XYChart.Series<>("Signficance 0.05", FXCollections.observableArrayList(sigData1, sigData2)); chartPValues.getData().add(dataSignificant); sigData1.getNode().setVisible(false); sigData2.getNode().setVisible(false); } // chartPValues.getData().get(0).getNode().setVisible(true); // pValuesWrapper.clearThresholds(); for (XYChart.Data<Number, Number> dataPoint : data) { if (!Boolean.TRUE.equals(dataPoint.getExtraValue())) dataPoint.getNode().setVisible(false); } // if (dataSmoothed != null) { // for (XYChart.Data<Number, Number> dataPoint : dataSmoothed) { // dataPoint.getNode().setVisible(false); // } // chartPValues.getData().get(1).getNode().setOpacity(0.5); // } // int count = 0; // for (int i = 0; i < pValueThresholds.length; i++) { // double pValue = pValues[i]; // if (Double.isNaN(pValue)) // continue; // boolean observed = pValueThresholdsObserved[i]; //// if (observed) //// pValuesWrapper.addThreshold(new ReadOnlyDoubleWrapper(pValueThresholds[i]), Color.rgb(0, 0, 0, 0.05)); // // if (!observed) { //// StackPane pane = (StackPane)data.get(count).getNode(); //// pane.setEffect(new DropShadow()); // data.get(count).getNode().setVisible(false); // } // count++; // } } for (int i = 0; i < threshProperties.length; i++) { if (i < thresholds.length) threshProperties[i].set(thresholds[i]); else threshProperties[i].set(Double.NaN); } boolean isInteractive = interactiveThresholds(); histogramWrapper.setIsInteractive(isInteractive); pValuesWrapper.setIsInteractive(isInteractive); chartPValues.setVisible(true); } // else // chartPValues.setVisible(false); // Store values for next time scoreData = newScoreData; } static List<KaplanMeierData> splitByThresholds(final KaplanMeierDisplay.ScoreData scoreData, final double[] thresholds, final double censorThreshold, final boolean usesQuartiles) { List<KaplanMeierData> kms = new ArrayList<>(); int nThresholds = thresholds.length; // Ensure thresholds are sorted double[] sortedThresholds = thresholds.clone(); Arrays.sort(sortedThresholds); for (int i = 0; i <= nThresholds; i++) { if (nThresholds == 0) kms.add(new KaplanMeierData("All")); else if (nThresholds == 1) { kms.add(new KaplanMeierData(i == 0 ? "Low" : "High")); } else if (nThresholds == 2 && sortedThresholds[0] < sortedThresholds[1]) { kms.add(new KaplanMeierData(i == 0 ? "Low" : (i == 1 ? "Moderate" : "High"))); } else if (usesQuartiles) { kms.add(new KaplanMeierData("Quartile " + (i + 1))); } else { if (i == 0) kms.add(new KaplanMeierData(String.format("x < %.2f", sortedThresholds[i]))); else if (i == nThresholds) kms.add(new KaplanMeierData(String.format("x >= %.2f", sortedThresholds[i - 1]))); else kms.add(new KaplanMeierData( String.format("%.2f <= x < %.2f", sortedThresholds[i - 1], sortedThresholds[i]))); } } for (int i = 0; i < scoreData.survival.length; i++) { double s = scoreData.scores[i]; if (Double.isNaN(s)) continue; double surv = scoreData.survival[i]; if (Double.isNaN(surv)) continue; int t = sortedThresholds.length; while (t > 0) { if (s >= sortedThresholds[t - 1]) break; t--; } // Can be used to omit the exact threshold from contributing // if (t > 0 && s == sortedThresholds[t-1]) { // continue; // } // Create an event, optionally censoring on a particular time if needed if (censorThreshold > 0 && surv > censorThreshold) kms.get(t).addEvent(censorThreshold, true); else kms.get(t).addEvent(surv, scoreData.censored[i]); } return kms; } /** * Helper method to calculate best splits (in terms of log-rank test) used to identify extreme positive & negative phenotypes. * * This is useful when looking at P53. * * @param scoreData * @param censorThreshold * @return */ static double[] calculateOptimalExtremePositiveNegativeThresholds(final KaplanMeierDisplay.ScoreData scoreData, final double censorThreshold) { double[] thresholds = scoreData.scores.clone(); Arrays.sort(thresholds); double t1Optimal = Double.NaN; double t2Optimal = Double.NaN; double bestP = Double.POSITIVE_INFINITY; double bestPSplit = Double.POSITIVE_INFINITY; int g1 = 0; int g2 = 0; int g3 = 0; // int skip = scoreData.scores.length/10; int skip = thresholds.length / 10; double median = thresholds[thresholds.length / 2]; for (int i = skip; i < thresholds.length - skip; i++) { double t1 = thresholds[i]; if (t1 > median) break; for (int j = i + 1; j < thresholds.length - skip; j++) { double t2 = thresholds[j]; if (t2 < median) continue; List<KaplanMeierData> kmsTemp = splitByThresholds(scoreData, new double[] { t1, t2 }, censorThreshold, false); // Add extreme positive event list to extreme negative kmsTemp.get(0).addEvents(kmsTemp.get(2).getEvents()); LogRankResult test = LogRankTest.computeLogRankTest(kmsTemp.get(0), kmsTemp.get(1)); double pValue = test.getPValue(); if (pValue < bestP) { double split = (double) kmsTemp.get(0).nEvents() / (kmsTemp.get(0).nEvents() + kmsTemp.get(1).nEvents()); if (Math.abs(split - 0.5) < bestPSplit) { bestP = pValue; bestPSplit = split; t1Optimal = t1; t2Optimal = t2; g3 = kmsTemp.get(2).getEvents().size(); g2 = kmsTemp.get(1).getEvents().size(); g1 = kmsTemp.get(0).getEvents().size() - g3; // Remember we added here... } } } } logger.info("Optimal split thresholds: {} and {} (p-value {}; group sizes {}, {} and {})", t1Optimal, t2Optimal, bestP, g1, g2, g3); return new double[] { bestP, t1Optimal, t2Optimal }; } /** * Returns true if the thresholds are interactive (i.e. not set based on stats) * * @return */ private boolean interactiveThresholds() { return params != null && Arrays.asList("Manual (1)", "Manual (2)", "Manual (3)") .contains(params.getChoiceParameterValue("scoreThresholdMethod")); } @Override public void parameterChanged(ParameterList parameterList, String key, boolean isAdjusting) { if ("showTicks".equals(key)) { plotter.setShowCensoredTicks(parameterList.getBooleanParameterValue("showTicks")); return; } // if ("useColor".equals(key)) { // plotter.setUseColor(parameterList.getBooleanParameterValue("useColor")); // return; // } // if ("useStrokes".equals(key)) { // plotter.setUseStrokes(parameterList.getBooleanParameterValue("useStrokes")); // return; // } if ("showKey".equals(key)) { plotter.setShowKey(parameterList.getBooleanParameterValue("showKey")); return; } if ("showAtRisk".equals(key)) { plotter.setShowAtRisk(parameterList.getBooleanParameterValue("showAtRisk")); return; } // Enable/disable manual slider as required if ("scoreThresholdMethod".equals(key)) { updateThresholdsEnabled(); } generatePlot(); } private void updateThresholdsEnabled() { Object value = panelParams.getParameters().getChoiceParameterValue("scoreThresholdMethod"); if ("Manual (1)".equals(value)) { panelParams.setParameterEnabled("threshold1", true); panelParams.setParameterEnabled("threshold2", false); panelParams.setParameterEnabled("threshold3", false); } else if ("Manual (2)".equals(value)) { panelParams.setParameterEnabled("threshold1", true); panelParams.setParameterEnabled("threshold2", true); panelParams.setParameterEnabled("threshold3", false); } else if ("Manual (3)".equals(value)) { panelParams.setParameterEnabled("threshold1", true); panelParams.setParameterEnabled("threshold2", true); panelParams.setParameterEnabled("threshold3", true); } else { panelParams.setParameterEnabled("threshold1", false); panelParams.setParameterEnabled("threshold2", false); panelParams.setParameterEnabled("threshold3", false); } } // TODO: Fix this horrible, Swing-inspired design (a remnant of Swing days, not yet fully transferred) static class KaplanMeierTableModel { // private static DecimalFormat df1 = new DecimalFormat("#.#"); private static DecimalFormat df2 = new DecimalFormat("#.##"); private static DecimalFormat df4 = new DecimalFormat("#.####"); private List<String> names = new ArrayList<>(); private List<String> values = new ArrayList<>(); private TableView<Integer> table; KaplanMeierTableModel(final TableView<Integer> table) { this.table = table; } void setSurvivalCurves(final double[] thresholds, final boolean correctPValues, final KaplanMeierData... kms) { names.clear(); values.clear(); if (kms.length == 0) return; boolean multipleThresholds = thresholds.length > 1; int count = 0; for (double t : thresholds) { count++; if (multipleThresholds) names.add("Score threshold " + count); else names.add("Score threshold"); values.add(df2.format(t)); } names.add("Max time"); double maxTime = Double.NEGATIVE_INFINITY; int nEvents = 0; int nObserved = 0; int nCensored = 0; for (KaplanMeierData km : kms) { maxTime = Math.max(maxTime, km.getMaxTime()); nEvents += km.nEvents(); nObserved += km.nObserved(); nCensored += km.nCensored(); } values.add(df2.format(maxTime)); names.add("Total events"); values.add(Integer.toString(nEvents)); names.add("Num observed"); values.add(Integer.toString(nObserved)); names.add("Num censored"); values.add(Integer.toString(nCensored)); for (KaplanMeierData km : kms) { names.add(km.getName()); values.add(km.nEvents() + " (" + km.nObserved() + " observed)"); // values.add(km.nEvents() + " (" + df1.format(km.nEvents()*100.0/nEvents) + "%)"); } // Add pairwise log-rank tests for (int i = 0; i < kms.length; i++) { for (int j = i + 1; j < kms.length; j++) { KaplanMeierData km1 = kms[i]; KaplanMeierData km2 = kms[j]; LogRankResult logRankResult = LogRankTest.computeLogRankTest(km1, km2); // if (!Double.isNaN(pValue)) { names.add(logRankResult.getName(km1.getName(), km2.getName())); values.add(logRankResult.getResultString()); // names.add("Log-rank (" + km1.getName() + " vs. " + km2.getName() + ")"); double pValue = logRankResult.getPValue(); // if (Double.isNaN(pValue)) // values.add("NaN"); // else if (pValue > 1e-3) // values.add(df4.format(pValue)); // else if (pValue > 1e-4) // values.add(GeneralTools.getFormatter(5).format(pValue)); // else if (pValue > 1e-5) // values.add(GeneralTools.getFormatter(6).format(pValue)); // else if (pValue > 1e-6) // values.add(GeneralTools.getFormatter(7).format(pValue)); // else // values.add(GeneralTools.getFormatter(8).format(pValue)); //// } if (correctPValues) { names.add("Log-rank (corrected P-value, e=0.1)"); // pValue = 0.012; double pValueAdjustedQuick = -1.63 * pValue * (1 + 2.35 * Math.log(pValue)); // For e = 10% // double pValueAdjustedQuick = -3.13*pValue*(1 + 1.65*Math.log(pValue)); // For e = 5% // pValue = -1.63*pValue*(1 + 2.35*Math.log(pValue)); // pValue = -3.13*pValue*(1 + 1.65*Math.log(pValue)); double epsilon = 0.1; // pValue = 0.037; // For checking with Altman's paper... double z = (1 - pValue / 2); NormalDistribution dist = new NormalDistribution(); z = dist.inverseCumulativeProbability(1 - pValue / 2); double phi = dist.density(z); // System.err.println("PHI: " + phi + " for " + z); double pValueAdjusted = phi * (z - 1 / z) * Math.log((1 - epsilon) * (1 - epsilon) / (epsilon * epsilon)) + 4 * phi / z; values.add(df4.format(pValueAdjusted)); logger.info("Original P-value: {}", pValue); logger.info("Quick adjusted P-value (epsilon = {}): {}", epsilon, pValueAdjustedQuick); logger.info("Full adjusted P-value (epsilon = {}): {}", epsilon, pValueAdjusted); } // // Add Hazard ratio, if available // if (!Double.isNaN(pValue) && Double.isFinite(logRankResult.hazardRatio)) { // names.add("Hazard ratio (" + km1.getName() + " vs. " + km2.getName() + ")"); // values.add(String.format("%.3f (%.3f-%.3f)", logRankResult.hazardRatio, logRankResult.hazardRatioLowerConfidence, logRankResult.hazardRatioUpperConfidence)); // } } } // If we have exactly 3 thresholds, try comparing extremes (useful for P53) if (kms.length == 3) { KaplanMeierData kmExtreme = new KaplanMeierData("Low+High"); kmExtreme.addEvents(kms[0].getEvents()); kmExtreme.addEvents(kms[2].getEvents()); LogRankResult logRankResult = LogRankTest.computeLogRankTest(kmExtreme, kms[1]); names.add(logRankResult.getName(kmExtreme.getName(), kms[1].getName())); values.add(logRankResult.getResultString()); } // Notify listeners List<Integer> list = new ArrayList<>(); for (int i = 0; i < getRowCount(); i++) list.add(i); table.getItems().setAll(list); } public int getRowCount() { return names.size(); } public int getColumnCount() { return 2; } public String getColumnName(int columnIndex) { if (columnIndex == 0) return "Name"; else return "Value"; } public String getValueAt(int rowIndex, int columnIndex) { if (columnIndex == 0) return names.get(rowIndex); else return values.get(rowIndex); } } }