Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions assets/uriahtalks-horizon-explorer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Great Docs-compatible renderer for the uriahtalks horizon explorer.
class UriahtalksHorizonExplorer extends HTMLElement {
connectedCallback() {
if (this.dataset.ready === "true") return;
this.dataset.ready = "true";

const competingAsCensored =
this.hasAttribute("competing-as-censored");
const min = Number(this.getAttribute("min") || 5);
const max = Number(this.getAttribute("max") || 50);
const step = Number(this.getAttribute("step") || 5);
const initial = Number(this.getAttribute("horizon") || 30);
const title = this.getAttribute("heading") || "Explore the horizon";
const accent = this.getAttribute("accent-color") || "#7a9a01";

this.style.setProperty("--uriah-horizon-accent", accent);
this.innerHTML = `
<div class="uriah-horizon-heading">${title}</div>
<label class="uriah-horizon-control">
<span>Fixed time horizon: <output>${initial}</output></span>
<input type="range" min="${min}" max="${max}" step="${step}"
value="${initial}" aria-label="Fixed time horizon">
</label>
<div class="uriah-horizon-chart" role="img"
aria-label="Follow-up timelines classified at the selected horizon"></div>
<div class="uriah-horizon-key"></div>
`;

const observations = [
[1, .482, "primary"], [2, .194, "primary"],
[3, .998, "primary"], [4, .372, "primary"],
[5, .696, "none"], [6, .284, "competing"],
[7, .784, "primary"], [8, .920, "competing"],
[9, .630, "none"], [10, .086, "primary"],
].map(([id, fraction, outcome]) => ({
id, outcome, time: fraction * max,
}));

const states = {
primary: ["🤢", "Primary event"],
competing: ["💀", "Competing event"],
censored: ["🤬", "Censored"],
nonEvent: ["🤨", "Non-event through horizon"],
};
const input = this.querySelector("input");
const output = this.querySelector("output");
const chart = this.querySelector(".uriah-horizon-chart");
const key = this.querySelector(".uriah-horizon-key");
const ns = "http://www.w3.org/2000/svg";

const classify = (observation, horizon) => {
if (observation.time > horizon) return states.nonEvent;
if (observation.outcome === "primary") return states.primary;
if (observation.outcome === "competing") {
return competingAsCensored ? states.censored : states.competing;
}
return states.censored;
};

const render = () => {
const horizon = Number(input.value);
output.value = horizon;
chart.replaceChildren();
const width = 900, height = 390;
const margin = { top: 22, right: 28, bottom: 48, left: 58 };
const innerWidth = width - margin.left - margin.right;
const rowHeight =
(height - margin.top - margin.bottom) / observations.length;
const x = value => margin.left + (value / max) * innerWidth;
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("class", "uriah-horizon-svg");

const tickStep = Math.max(step, max / 5);
for (let tick = 0; tick <= max; tick += tickStep) {
const line = document.createElementNS(ns, "line");
line.setAttribute("x1", x(tick));
line.setAttribute("x2", x(tick));
line.setAttribute("y1", height - margin.bottom);
line.setAttribute("y2", height - margin.bottom + 6);
line.setAttribute("class", "uriah-axis");
svg.appendChild(line);
const text = document.createElementNS(ns, "text");
text.setAttribute("x", x(tick));
text.setAttribute("y", height - margin.bottom + 24);
text.setAttribute("class", "uriah-tick");
text.textContent = Number(tick.toFixed(2));
svg.appendChild(text);
}

const axisLabel = document.createElementNS(ns, "text");
axisLabel.setAttribute("x", margin.left + innerWidth / 2);
axisLabel.setAttribute("y", height - 5);
axisLabel.setAttribute("class", "uriah-axis-label");
axisLabel.textContent = "Follow-up time";
svg.appendChild(axisLabel);

const horizonLine = document.createElementNS(ns, "line");
horizonLine.setAttribute("x1", x(horizon));
horizonLine.setAttribute("x2", x(horizon));
horizonLine.setAttribute("y1", margin.top - 8);
horizonLine.setAttribute("y2", height - margin.bottom);
horizonLine.setAttribute("class", "uriah-horizon-line");
svg.appendChild(horizonLine);

observations.forEach((observation, index) => {
const y = margin.top + rowHeight * (index + .5);
const displayTime = Math.min(observation.time, horizon);
const state = classify(observation, horizon);
const label = document.createElementNS(ns, "text");
label.setAttribute("x", margin.left - 14);
label.setAttribute("y", y);
label.setAttribute("class", "uriah-row-label");
label.textContent = observation.id;
svg.appendChild(label);
const followup = document.createElementNS(ns, "line");
followup.setAttribute("x1", x(0));
followup.setAttribute("x2", x(displayTime));
followup.setAttribute("y1", y);
followup.setAttribute("y2", y);
followup.setAttribute("class", "uriah-followup-line");
svg.appendChild(followup);
const marker = document.createElementNS(ns, "text");
marker.setAttribute("x", x(displayTime));
marker.setAttribute("y", y);
marker.setAttribute("class", "uriah-emoji-marker");
marker.textContent = state[0];
const tooltip = document.createElementNS(ns, "title");
tooltip.textContent =
`Observation ${observation.id}: ${state[1]}; observed time ${observation.time.toFixed(2)}`;
marker.appendChild(tooltip);
svg.appendChild(marker);
});

chart.appendChild(svg);
const active = competingAsCensored
? [states.primary, states.censored, states.nonEvent]
: [states.primary, states.competing, states.censored, states.nonEvent];
key.replaceChildren(...active.map(state => {
const item = document.createElement("span");
item.innerHTML = `<span aria-hidden="true">${state[0]}</span> ${state[1]}`;
return item;
}));
};

input.addEventListener("input", render);
render();
}
}

customElements.define("uriahtalks-horizon-explorer", UriahtalksHorizonExplorer);

25 changes: 24 additions & 1 deletion great-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ include_in_header:
<script>
document.addEventListener('DOMContentLoaded', () => {
const nav = document.querySelector('nav.navbar ul.navbar-nav.me-auto');
if (!nav || nav.querySelector('[data-rtichoke-blog-link]')) return;
if (!nav || nav.querySelector('[data-rtichoke-guides-link]')) return;

const guidesItem = document.createElement('li');
guidesItem.className = 'nav-item';
guidesItem.setAttribute('data-rtichoke-guides-link', '');

const guidesLink = document.createElement('a');
guidesLink.className = 'nav-link';
const offset = document.querySelector('meta[name="quarto:offset"]');
guidesLink.href = (offset ? offset.content : '') + 'index.html';
guidesLink.textContent = 'Guides';
guidesItem.appendChild(guidesLink);

const item = document.createElement('li');
item.className = 'nav-item';
Expand All @@ -29,9 +40,20 @@ include_in_header:
const reference = [...nav.children].find((el) =>
el.textContent.trim().toLowerCase().includes('reference')
);
nav.insertBefore(guidesItem, reference || null);
nav.insertBefore(item, reference || null);
});
</script>
- text: |
<script>
(function () {
var script = document.createElement('script');
var offset = document.querySelector('meta[name="quarto:offset"]');
script.src = (offset ? offset.content : '') + 'assets/uriahtalks-horizon-explorer.js';
script.defer = true;
document.head.appendChild(script);
})();
</script>

site:
css: site.css
Expand Down Expand Up @@ -85,3 +107,4 @@ reference:
- create_decision_curve
- create_decision_curve_times
- plot_decision_curve

91 changes: 91 additions & 0 deletions site.css
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,94 @@ footer.footer {
--rtichoke-shadow: none;
}
}
uriahtalks-horizon-explorer {
color: inherit;
background: transparent;
container-type: inline-size;
display: block;
margin: 1rem 0 1.5rem;
}

uriahtalks-horizon-explorer .uriah-horizon-heading {
font-size: 1.1em;
font-weight: 650;
margin-bottom: .65rem;
}

uriahtalks-horizon-explorer .uriah-horizon-control {
display: grid;
gap: .3rem;
max-width: 34rem;
}

uriahtalks-horizon-explorer input[type="range"] {
accent-color: var(--uriah-horizon-accent);
width: 100%;
}

uriahtalks-horizon-explorer .uriah-horizon-chart {
background: transparent;
overflow-x: auto;
width: 100%;
}

uriahtalks-horizon-explorer .uriah-horizon-svg {
background: transparent;
display: block;
min-width: 620px;
width: 100%;
}

uriahtalks-horizon-explorer .uriah-axis {
stroke: currentColor;
stroke-width: 1;
}

uriahtalks-horizon-explorer .uriah-tick,
uriahtalks-horizon-explorer .uriah-axis-label,
uriahtalks-horizon-explorer .uriah-row-label {
fill: currentColor;
font: 14px Commissioner, system-ui, sans-serif;
}

uriahtalks-horizon-explorer .uriah-tick,
uriahtalks-horizon-explorer .uriah-axis-label {
text-anchor: middle;
}

uriahtalks-horizon-explorer .uriah-row-label {
dominant-baseline: middle;
text-anchor: end;
}

uriahtalks-horizon-explorer .uriah-followup-line {
stroke: color-mix(in srgb, currentColor 38%, transparent);
stroke-width: 2;
}

uriahtalks-horizon-explorer .uriah-horizon-line {
stroke: var(--uriah-horizon-accent);
stroke-dasharray: 7 6;
stroke-width: 4;
}

uriahtalks-horizon-explorer .uriah-emoji-marker {
cursor: help;
dominant-baseline: central;
font: 30px "Segoe UI Emoji", "Apple Color Emoji", sans-serif;
text-anchor: middle;
}

uriahtalks-horizon-explorer .uriah-horizon-key {
display: flex;
flex-wrap: wrap;
font-size: .9em;
gap: .45rem 1.1rem;
}

@media (max-width: 650px) {
uriahtalks-horizon-explorer .uriah-horizon-svg {
width: 720px;
}
}

54 changes: 54 additions & 0 deletions user_guide/02-fixed-time-horizons.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
title: "Fixed Time Horizons"
guide-section: "Before You Validate"
---

A time-to-event prediction must specify when the outcome is evaluated. Use
`fixed_time_horizons` to declare those times in the same unit as `times` and
the prediction model:

```python
fixed_time_horizons = [5.0, 10.0] # years
```

Choose clinically meaningful horizons before inspecting performance. Changing
the horizon changes the outcome being validated.

## Update administrative censoring

At a fixed horizon:

- an event observed after the horizon is a 🤨 non-event *at that horizon*;
- event-free follow-up ending before the horizon is 🤬 censored;
- a primary event observed by the horizon remains 🤢; and
- a competing event observed by the horizon remains 💀.

## Explore the horizon

Move the slider to see how the selected horizon changes each observation. The
vertical line marks the horizon; information after it is not used.

<uriahtalks-horizon-explorer
min="10" max="50" step="10" horizon="50"
accent-color="#7a9a01"
heading="Explore the horizon">
</uriahtalks-horizon-explorer>

The symbols are:

- 🤬 censored before the horizon;
- 🤢 primary event;
- 🤨 non-event through the horizon; and
- 💀 competing event.

## Pass horizons to `rtichoke`

```python
performance_data = rk.prepare_performance_data_times(
probs=probs,
reals=reals,
times=times,
fixed_time_horizons=[5.0, 10.0],
)
```

Loading