Build an awesome marimo notebook that brings a research paper or dataset to life — for competitions, tutorials, demos, or blog companions. Use when asked to "make an awesome marimo notebook," "build a notebook for this paper," "create a competition submission," or pair-program an interactive research explainer on molab.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add ericmjl/skills --skill awesome-marimo-notebook --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Awesome Marimo Notebook?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericmjl-awesome-marimo-notebook)More formats (shields.io, HTML) on the badges page.
---
description: Build an awesome marimo notebook that brings a research paper or dataset to life — for competitions, tutorials, demos, or blog companions. Use when asked to "make an awesome marimo notebook," "build a notebook for this paper," "create a competition submission," or pair-program an interactive research explainer on molab.
license: MIT
metadata:
author: ericmjl
version: '1.0'
name: awesome-marimo-notebook
---
# Awesome marimo Notebook
A workflow for building marimo notebooks that people remember — interactive
research explainers that bring a paper, method, or dataset to life. Useful for
competition entries, conference tutorials, blog companions, or standalone demos.
**Related skills:**
- `marimo-pair` — pair-program inside a running marimo kernel (the build tool)
- `marimo-notebook-patterns` — exhaustive gotcha reference for marimo `.py` editing
---
## Principles of an engaging notebook
The most memorable marimo notebooks share five qualities. Aim for all five:
1. **An original tactile interaction** that makes the core idea feel obvious
*before any math*. A drag-the-parameter game, a visual metaphor the reader
manipulates. The "aha" should land in the fingers before it lands in the
equations.
2. **Real code, not description** — ideally the paper authors' own repo, or a
faithful reimplementation. Readers should be able to trace every result to
the cell that produced it.
3. **A clearly-labeled original contribution** with its own quantitative
trade-off. Something you built that goes beyond the paper: an untested
variant, a new visualization, a side-by-side comparison. Label it explicitly
("My extension," "Our contribution") so readers can find it.
4. **At least one custom-built widget** (anywidget with hand-written ESM).
Stock `mo.ui` sliders are fine for controls, but a bespoke widget — one that
couldn't exist without your specific insight — is what makes the notebook
unforgettable.
5. **A coherent design system** — hero banner, hand-authored SVG art,
consistent color palette reused across every figure, `hide_code=True` on
most cells. Reads like a designed page, not a code dump.
### Narrative proportions
Allocate screen real estate deliberately:
```
~5% Editorial hero (banner, SVG art, paper attribution)
~5% Problem framing (why the status quo is unsatisfying -> ends with a question)
~20% INTUITION ONLY — interactive game + visual bridge, NO equations
~5% Math formalization (introduce equations AFTER the reader has the picture)
~30% Real experiment (decomposed into numbered steps, state flowing between them)
~10% The paper's algorithm, demonstrated live
~15% ORIGINAL EXTENSION (honestly flagged, with quantitative trade-off)
~5% Efficiency / limits analysis (honest, nuanced)
~5% Close + acknowledgments (AI-tool disclosure)
```
The narrative spine:
> **Hook -> Metaphor -> Math -> Real experiment -> Algorithm -> Original extension -> Trade-off -> Close.**
> Each act opens with a *question*, closes by *naming the formal concept*.
---
## Phase 1: Research
### Pick the right paper
Ideal paper characteristics:
- **Single-thesis core idea** with a clean "one knob" (e.g., replace norm with
tanh; crush weights to 3 values; drop the noise-level input)
- **High audience pull** (upvotes, name recognition, famous authors)
- **GPU-leverageable** (can run real training/inference, not just theory)
- **Extension opportunities** (the paper doesn't sweep a parameter, doesn't
test on a certain architecture, doesn't address an obvious follow-up)
- **Fast to a compelling demo** (can train in seconds-minutes on GPU)
### Quality dimensions
A great notebook does well across these dimensions (no weights — just aim
high on each):
| Dimension | What it means |
|---|---|
| Paper engagement | Go beyond the abstract; explore methods/results; deliver a clear takeaway |
| Creativity & impact | Fresh angle; novel visualizations; cross-reference related work |
| Interactivity | Reactive widgets that DRIVE exploration, not just decorate |
| Design & shareability | Polished, self-contained, makes people want to try marimo |
| Code quality | Clean, reproducible, no errors |
| GPU utilization (if applicable) | Meaningful GPU use — measurement, batched ops, appropriate device placement |
**Errors are the #1 engagement killer.** A notebook that errors mid-flow loses
the reader. Test end-to-end before sharing.
---
## Phase 2: Design
Map the paper onto the narrative spine above. Decide early:
- What is the **intuition interaction**? (The tactile widget that makes the
thesis feel obvious before any math.)
- What is the **centerpiece experiment**? (Something only real compute can do:
a training race, activation instrumentation, efficiency measurement.)
- What is the **original extension**? (An honest, labeled follow-up the paper
didn't test. Pick one that reuses the trained model for speed.)
- What is the **design motif**? (One visual element echoed everywhere — e.g.,
the S-curve for DyT, the tri-color heatmap for BitNet.)
---
## Phase 3: Build
Use the **`marimo-pair`** skill to pair-program inside the running kernel. For
the exhaustive gotcha list (reactive graph, code_mode quirks, triple-quote
collisions, etc.), load **`marimo-notebook-patterns`**. Below are only the
most critical conventions.
### Cell conventions (non-negotiable)
**1. `mo` is auto-injected — import in ONE cell only.** Every other cell uses
`mo` bare. Two cells both doing `import marimo as mo` causes
`MultiplyDefinedNameError`.
**2. Every top-level name must be UNIQUE across ALL cells.** Wrap heavy
computation in a function (`run_experiment()`) so locals are isolated — only
the function name and its output need to be unique. Loop variables like `i`,
`fig`, `ax` collide across cells: namespace by purpose (`frontier_fig`,
`heat_ax`).
**3. NO underscore-prefixed names for anything downstream cells need.**
marimo treats underscore-prefixed variables as PRIVATE — they do not appear
in the cell's defs at all, so they cannot be referenced in ANY form (`_model`
or `model`) from another cell. Use clean public names (`vis_model`) so the
name is available in every cell.
**4. Cells don't auto-execute on creation** — call `ctx.run_cell(cid)` after
`ctx.create_cell(...)`.
**5. Edit through `code_mode`, never the filesystem.** Direct `.py` edits are
silently lost when the kernel saves. Use `ctx.edit_cell(target, code=...)`
with the full new cell body.
**6. Use `skip_staleness_check=True` for batch edits.**
### App-level configuration
```python
app = marimo.App(width="medium", auto_download=["html"])
```
Apply `hide_code=True` on most cells (all plotting/training cells; keep code
visible only in pedagogical cells like the implementation walkthrough).
---
## Phase 4: Widget craft
The custom widget is what makes a notebook unforgettable. Budget time for it
early — don't leave it for the end.
### Custom anywidget pattern
An anywidget is a **bi-directional reactive bridge**: `traitlets` tagged
`sync=True` flow BOTH ways between the browser and the kernel. This is what
makes a bespoke widget powerful — not just "a custom visual," but a channel
for reader actions to become Python variables, and for Python variables to
reshape the visual live.
**The two API calls that make the bridge:**
- **JS → Python:** `model.set('trait', v)` then **`model.save_changes()`**
pushes a browser-side change into the kernel trait; downstream cells that
read the widget's value re-run reactively.
- **Python → JS:** the JS reads initial state via `model.get('trait')` in
`render`; it can also react to later mutations via
`model.on('change:trait', cb)` — but only needed when the trait mutates on
a *persistent* instance (see Pattern B).
Decide first which direction the widget serves.
#### Pattern A — JS → Python (the widget is an INPUT)
The reader acts in the browser (brush, drag, select); the action becomes a
Python value downstream cells consume. Wrap in `mo.ui.anywidget()` and read
`.value` in a **different** cell.
Classic case — **brush a scatterplot → save the selection as a region of
interest:**
```python
class BrushScatter(anywidget.AnyWidget):
_esm = """
function render({ model, el }) {
const pts = model.get('points');
// ...draw an SVG scatter of pts with a brush rectangle...
svg.addEventListener('pointerup', () => {
model.set('value', pointsInsideBrushRect()); // selection -> Python
model.save_changes(); // CRITICAL: without this, Python never sees it
});
}
export default { render };
"""
points = traitlets.List().tag(sync=True)
value = traitlets.List([]).tag(sync=True) # filled from JS = the selection
brush = mo.ui.anywidget(BrushScatter(points=[[x, y] for x, y in data]))
brush # last expression = cell output
```
```python
# DIFFERENT cell — re-runs every time the reader brushes.
roi = brush.value # the selected points
mo.md(f"**Region of interest:** {len(roi)} points, mean y = {mean_y(roi):.2f}")
```
`brush.value` returns the widget's synced `value` trait. For a multi-trait
widget, read the specific trait you care about so the cell only re-runs when
that one changes.
#### Pattern B — Python → JS (the widget is a DISPLAY)
A Python control (dropdown/slider) or computed value changes what the widget
shows. The reliable marimo pattern is **recompute-and-reinstantiate**: the
widget's data is a function of Python state; when that state changes, the cell
re-runs and mounts a fresh widget whose `render` reads the new traits at
startup. No `change:` handler is needed because the widget is remounted.
```python
# cell 1 — control
graph_choice = mo.ui.dropdown(["toy", "real-A", "real-B"], value="toy")
graph_choice
```
```python
# cell 2 — reads graph_choice.value (DIFFERENT cell!), rebuilds the widget
data = load_graph(graph_choice.value)
BFSAnimation(nodes_json=json.dumps(data["nodes"]),
steps_exists_json=json.dumps(data["steps"]),
target_label=str(data["target"])) # bare instance — nothing reads it
```
This is exactly **Network-Analysis-Made-Simple `02-paths.py`**: a dropdown
swaps the BFS animation between the toy teaching graph and real sociopatterns
ego subgraphs; each graph's nodes/edges/BFS-steps are computed in Python
(spring layout + step precomputation) and the JS just renders
`model.get(...)` at mount. Use this pattern whenever the data swaps wholesale
(new dataset, new layout). For smooth *in-place* updates within a single cell
(live training curves), use `mo.output.replace()` (see "Live-updating charts")
rather than mutating traits on a persistent instance.
#### Key gotchas
- **`model.save_changes()` is REQUIRED for JS→Python sync** — without it the
trait never updates and downstream cells don't re-run. The #1 anywidget bug.
- **Reading `.value` must happen in a DIFFERENT cell** from the one that
created the widget — marimo raises `RuntimeError: Accessing the value of a
UIElement in the cell that created it is not allowed`. Split into a
create-cell and a consume-cell.
- **Wrap in `mo.ui.anywidget()` when a downstream cell reads `.value`** (INPUT
widgets, JS→Python). Pure Python→JS display widgets (like `BFSAnimation`)
render fine as a bare instance; wrapping a display widget is harmless and is
how `CellTour` below is used, but only INPUT widgets NEED the wrap.
- **`_esm` and `_css` are anywidget's required attribute names** (the framework
looks them up by those exact names) — the deliberate exception to the
no-underscore rule.
- **The widget's variable name must be non-underscore** if a downstream cell
reads it: marimo treats underscore-prefixed variables as private (not
shared at all), so `_brush` is not available in any form in other cells.
### Bi-directional state sync (the real power of anywidgets)
A widget that only renders Python data into the DOM (Python→JS, once) is the
floor. The ceiling — and the reason to reach for a custom anywidget over a
static plot — is **bi-directional state**: JS interactions become Python data,
and Python data reactively reshapes the JS UI. The button-counter above shows
the mechanism in miniature; real notebooks use both directions to make the
widget a two-way instrument, not a display.
**Direction 1 — JS state → Python (interactions become data).** The killer use
case is a *selection that you save as a Python value* and then use downstream.
Classic example: brush a scatterplot and store the selected points as a region
of interest (ROI) that filters/masks the rest of the notebook.
```python
class BrushScatter(anywidget.AnyWidget):
_esm = """
function render({ model, el }) {
// ...draw points, attach a brush handler...
brush.on('end', ({ selection }) => {
if (selection) {
model.set('roi', pointsInside(selection)); // push JS state up
model.save_changes(); // CRITICAL — without this Python never sees it
}
});
}
export default { render };
"""
roi = traitlets.List([]).tag(sync=True) # JS writes this; Python reads it
brush = mo.ui.anywidget(BrushScatter())
brush # cell output
```
Downstream, `brush.roi` is an ordinary Python list — read it in another cell to
filter a DataFrame, refit a model, etc. marimo re-runs every downstream cell
that reads `brush.roi` each time the brush changes, so the ROI drives the rest
of the notebook live. **The test that you've wired this correctly:** interact in
the browser and watch a downstream cell re-run and change.
**Direction 2 — Python state → JS (reactive UI rebuild).** A Python control
(dropdown, slider) changes a value, and the widget re-renders with new data.
Two equivalent flavors:
- **Rebuild on change (simplest, preferred when the data is bulky):** put the
widget in a cell that depends on the upstream control. When the control
changes, marimo re-runs the cell and you construct a fresh widget with new
`sync=True` traits — the JS re-renders from the new trait payloads. This is
the pattern in Network-Analysis-Made-Simple notebook `02-paths.py`: a
`mo.ui.dropdown` picks a graph; the widget cell rebuilds `BFSAnimation(
nodes_json=..., steps_path_json=..., target_label=...)` from the selection,
and the BFS animation re-renders on the chosen graph. Pass bulky data as
JSON-string traits (`traitlets.Unicode(json.dumps(data)).tag(sync=True)`)
and `JSON.parse` them in the ESM.
- **React in place (preferred for cheap scalar updates, no re-render):**
register `model.on('change:source', handler)` in the render function and
mutate the existing DOM. Use this when only a small value changes and you
want to preserve JS-side state (current zoom, play position) that a rebuild
would discard.
**Mechanic checklist (both directions):**
- Every trait that crosses the boundary needs `.tag(sync=True)` — unsynced
traits never propagate either way.
- JS→Python: `model.set(...)` then **always** `model.save_changes()` (the
gotcha above; forgetting it is the #1 silent-failure in anywidget code).
- Python→JS rebuild: the widget must be the cell's last expression AND the cell
must reference the upstream control so marimo's dataflow re-runs it.
- Send structured/bulky data as JSON-string traits (`Unicode`) rather than
relying on object sync — ESM does `JSON.parse(model.get('x_json'))`.
### Round-trip sync anti-pattern (JS → Python → JS feedback loop)
The two directions above are shown in **isolation** (JS→Python, then
Python→JS). Chaining them into a round-trip — JS sets trait A, Python
`@observe` derives trait B from A, JS listens to B and re-renders — is an
**anti-pattern** that silently fails.
**Symptom signature.** The Python observer fires correctly (the derived trait
has the right value in Python — verified by setting the source trait manually
and reading the derived trait back), but the JS `model.on('change:<derived>',
handler)` listener does **not** fire — or fires with stale data — **when the
original change came from JS**. Initial render works; subsequent updates,
especially rapid `j`/`k` keyboard navigation across graph nodes, do not.
**Root cause.** The "ping-pong" round-trip where JS initiated the change,
Python processes it and emits a new trait value, and that value must sync
**back** to JS. Change-batching/queueing in the anywidget/ipywidget model can
drop or coalesce the second trait update, especially under rapid interaction.
The Python side looks correct in every probe; the failure is on the
Python→JS return leg of a round-trip that JS started.
**Fix — avoid the Python round-trip for derived DISPLAY state.** Pass the
data JS needs to render the detail as a **one-shot payload trait** populated
once at widget construction, and render the detail pane **entirely in JS** by
looking up `payload[selected]` inside the `change:selected` handler:
```python
class GraphWidget(anywidget.AnyWidget):
_esm = """
function render({ model, el }) {
const payload = model.get('posts_payload'); // one-shot, set once
const detail = el.querySelector('.detail');
const render = () => {
const sel = model.get('selected');
detail.innerHTML = payload[sel]?.html || '<em>—</em>';
};
model.on('change:selected', render); // JS-side lookup, no round-trip
render();
}
export default { render };
"""
selected = traitlets.Int(0).tag(sync=True) # canonical bi-directional trait
posts_payload = traitlets.Dict().tag(sync=True) # one-shot data, JS owns rendering
# NO @observe('selected'), NO derived 'selected_html' trait
```
The canonical bi-directional trait stays **minimal** (just the selection);
all detail rendering reads from the JS-side payload keyed by the selection.
No Python `@observe`, no second derived trait, no round-trip.
**Generalizes to ANY anywidget** where (a) JS initiates a change to trait A,
(b) Python computes a derived value for trait B from A, (c) JS needs to
re-render based on B. Prefer the one-shot payload + JS-side rendering over
the Python observer + derived trait. Reserve the Python `@observe` for cases
where the derived value must be consumed by **downstream Python cells**
(i.e. it is INPUT data, not display state) — in that case the round-trip is
load-bearing and you do need it; otherwise keep the loop in JS.
**Concrete instance:** marimo discourse-graph widget over Bluesky quote-trees
(`graph-widget-keyboard-nav-popover` skill, 2026-07-18) — `j`/`k` nav sets
`selected` in JS, a Python `@observe` pushed `selected_html` back, the detail
pane didn't update on navigation despite `selected_html` being correct in
Python; dropping the observer + rendering detail in JS from `posts_payload`
fixed it immediately.
**Distinct from the #1 anywidget bug** (forgetting `model.save_changes()` on
the JS→Python direction — THAT silently fails to push JS state to Python at
all; THIS fails on the Python→JS return leg of a round-trip the JS side
started).
### Same-direction synchronous double-fire (event handler + change listener both call the side-effect)
A sibling of the round-trip anti-pattern above, but **same-direction (JS→JS)** and
fails by **double-execution** rather than by a lost return leg. Easier to hit than
the round-trip version and produces vivid jittery symptoms.
**Trigger condition.** Your widget has BOTH:
1. An originating event handler (click, hover, drag) that calls
`model.set('trait', v)` **and also** performs a side-effect directly — e.g.
`network.focus(nodeId, {animation})`, `network.fit()`, a DOM toggle, a
`fetch`.
2. A `model.on('change:trait', ...)` listener that performs the **same**
side-effect.
**Root cause.** In anywidget, a JS-side `model.set('trait', v)` fires the
`change:trait` listener **synchronously and immediately** — not on a separate
tick, not batched. So a single user action runs the side-effect **twice in a
row**: once inside the event handler, once inside the change listener. For
imperative visual operations that start an animation (`network.focus`,
`network.fit`, camera transitions), the two calls start **competing animations**
that fight each other for control of the viewport.
**Symptom signature (vis-network, marimo discourse-graph widget over Bluesky
quote-trees, 2026-07-18).** The graph_widget "rapidly zooms in then back out
when clicking a node, EVEN IF already zoomed in"; the bipartite graph "pans
rapidly across the viewport when hovering edges." The animation looks like a
fight between two focus targets.
**Fix — perform the side-effect in EXACTLY ONE place.** Either the event
handler OR the change listener, not both. **Prefer the change listener** as the
single source of truth: every selection change — whether from a click, from
`j`/`k` keyboard navigation, from a programmatic `model.set`, or from a Python
push — routes through exactly one focus call. Remove the side-effect from the
originating event handler; let `change:selected` own it.
```js
// BAD — two focus calls on every click (handler + listener)
network.on("click", (params) => {
const sel = params.nodes[0];
if (sel) {
model.set("selected", pn); // fires change:selected synchronously
model.save_changes();
network.focus(sel, {scale: 1.1, animation: {duration: 250}}); // FOCUS #1
}
});
model.on("change:selected", () => {
const sel = nodeById[model.get("selected")];
if (sel) network.focus(sel, {scale: 1.1, animation: {duration: 250}}); // FOCUS #2
});
// GOOD — focus lives ONLY in the change listener
network.on("click", (params) => {
const sel = params.nodes[0];
if (sel) {
model.set("selected", pn); // fires change:selected -> focus runs once
model.save_changes();
}
});
model.on("change:selected", () => {
const sel = nodeById[model.get("selected")];
if (sel) network.focus(sel, {scale: 1.1, animation: {duration: 250}}); // the ONE focus
});
```
**Diagnosis heuristic.** If a vis-network (or any animated-canvas) widget has
jittery / competing pan-or-zoom animations on user action, grep the widget
source for `network.focus` / `network.fit` and confirm each appears in
**exactly one call site per user action**. Two call sites that both fire on a
click is the bug.
**Distinct from the round-trip anti-pattern above.** That is JS→Python→JS
where the **return leg is lost or coalesced** under rapid interaction and the
listener silently fails to fire. THIS is same-direction JS→JS, the listener
**fires reliably every time**, and the bug is that **two listeners run** rather
than one being dropped. Both are anywidget feedback-loop gotchas; they fail in
opposite directions (lost-fire vs. double-fire). Concrete instance:
marimo discourse-graph widget (graph_widget + bipartite cells, 2026-07-18).
### wigglystuff CellTour (guided walkthrough)
```python
import wigglystuff
tour = wigglystuff.CellTour(
steps=[{"cell_name": "hero", "title": "...", "description": "..."}],
auto_start=False, show_progress=True,
)
mo.ui.anywidget(tour)
```
The `steps` list accepts either `{"cell": <int-index>, ...}` or
`{"cell_name": "<name>", ...}` — use the latter if you name your cells.
Verify all references resolve to existing cells — stale refs silently break
the tour.
#### Code tour description voice
The tour is an **onboarding tool** — the first thing a reader sees when they
open a notebook. Write descriptions that orient, not summarize. The test:
*if someone just opened this notebook for the first time, does this sentence
tell them what section they're in, what their role is, and what to pay
attention to?*
Rules:
1. **Label the section type.** Tell the reader what kind of cell this is:
"Background section," "Hands-on section," "Reflection section,"
"Try-it-yourself section," "Wrap-up." This helps them calibrate attention.
2. **Point at what's on screen.** "Look at the two answers above" — not
"Specialization trades simplicity for focus." The reader has the output
right in front of them; direct their eyes.
3. **Tell them what to do.** "Fill in blanks to connect the searcher and
synthesizer" — not "Implement the specialist pipeline." Use plain language
a first-time reader understands.
4. **Never spoil the punchline.** Discussion and reflection cells should set
up a question, not hand the conclusion. "Did the extra cost buy better
evidence?" — not "Specialization improves quality at higher cost."
5. **One to two sentences max.** The tour widget has limited space. If you
can't say it in two sentences, the cell's markdown should carry the
detail.
6. **Reference the arc.** Connect back to earlier context: "the
`search_corpus` tool from Part 3," "the same question from Exercise 4."
This anchors the notebook in its narrative.
Example (good):
```python
{
"cell_name": "ex1_header",
"title": "Exercise 1: wire the specialists",
"description": "Hands-on section. You fill in blanks to connect two "
"specialized agents — one that searches, one that synthesizes — "
"into a single pipeline. The pieces are from Part 5.",
}
```
Example (bad — don't do this):
```python
{
"cell_name": "ex1_header",
"title": "Exercise 1: wire the specialists",
"description": "Implement run_specialist_pipeline — wire SearcherAgent "
"and SynthesizerAgent into a ResearchOrchestrator.",
}
```
The bad version drops raw class names the reader hasn't seen yet, doesn't
label the section type, and reads like a docstring instead of a guide.
### Live-updating charts
```python
chart = LiveChart()
mo.output.replace(chart)
for step in range(100):
# ... train ...
chart.data = new_data
mo.output.replace(chart) # native mid-cell output API
await asyncio.sleep(0.01)
```
`mo.output.replace()` is the native mid-cell output API — more reliable than
cross-cell widget mutation for in-cell live updates.
### Plotly charts
Prefer `plotly` over matplotlib — renders natively and interactively in
marimo. Use a consistent 5-color palette and `paper_bgcolor="white"` across
all figures for a cohesive, designed look.
---
## Phase 5: GPU strategy
### Device placement
```python
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
runtime_gpu = torch.cuda.get_device_name(0) if DEVICE.type == "cuda" else "CPU"
```
Always guard `torch.cuda.*` calls with `DEVICE.type == "cuda"` — readers may
open the notebook on CPU-only machines, and an unguarded `get_device_name(0)`
crashes mid-narrative.
### Using GPU compute effectively
Go beyond "I trained on GPU." The most compelling angle: load a real
pretrained model and *empirically measure* the paper's core claim on its
internals — activation distributions, throughput, FLOPs, memory. Present as:
"The paper predicted this. Here it is, measured."
Other strong GPU moves:
- **Live A/B race** — two/three variants training side-by-side, loss curves
updating in real time.
- **Reactive re-inference** — pretrain-once-cache-checkpoints, then let the
reader flip one knob and re-run inference instantly without retraining.
- **Efficiency as a result** — if the paper removes/reduces computation,
measure the real wall-clock + memory delta and present it as a headline.
### Measuring GPU usage
```python
torch.cuda.reset_peak_memory_stats()
# ... work ...
peak_gb = torch.cuda.max_memory_allocated() / 1e9
throughput = n_samples / elapsed_seconds
```
Present these as stat-card dashboards — concrete numbers make the GPU usage
tangible.
### Memory-efficient training
When fine-tuning at scale, the autograd graph is the memory killer:
1. **Fused autograd Function** — combine round/clamp/scale into one
`torch.autograd.Function` with in-place forward, reducing intermediates:
```python
class FusedSTE(torch.autograd.Function):
@staticmethod
def forward(ctx, w):
gamma = w.abs().mean() + 1e-8
q = w / gamma
q.round_().clamp_(-1, 1).mul_(gamma)
return q
@staticmethod
def backward(ctx, grad):
return grad # STE: pass-through
```
2. **Gradient checkpointing** — `model.gradient_checkpointing_enable()`.
3. **Gradient clipping** — `clip_grad_norm_(params, 1.0)` prevents divergence
in STE/QAT training (numerically sensitive).
4. **Clear resident models** before heavy cells:
`gc.collect(); torch.cuda.empty_cache()`.
### CUDA OOM recovery
CUDA OOM **poisons the kernel** — all subsequent CUDA ops fail until restart.
There is no in-code recovery. Prevention:
- Monitor peak VRAM after the first training step.
- Use `bf16` weights + `torch.autocast` for training to halve VRAM.
- Train once, cache checkpoints; reactive cells re-*infer*, never re-*train*.
---
## Phase 6: Review & refine
After the notebook is built, dispatch 2-3 read-only review subagents in
parallel, each with a distinct focus:
1. **Structure & flow** — logical top-to-bottom argument? Explicit
transitions? Redundancy between sections?
2. **Coherence & claims** — contradictions across cells? Numbers consistent?
Claims that overstate the evidence? LaTeX rendering bugs?
3. **Quality & engagement** — how does it score on each quality dimension?
Extensions clearly flagged? Interactive widgets drive exploration? Code
reproducible?
Each agent reads the notebook dump (cell-by-cell `.txt`) + this skill.
They report findings; you consolidate and apply fixes.
### Common fixes after review
- **Reorder sections** to match the narrative arc (`ctx.move_cell`).
- **Delete redundancy** (two cells showing the same curve).
- **Label extensions** explicitly — readers can only appreciate what they can
find.
- **Reconcile numbers** — if cell A says "30k" and cell B says "19k" for the
same metric, pick one and use it everywhere.
- **Fix LaTeX** — use raw strings (`mo.md(r"""...""")`) so `\t` doesn't
become a tab.
- **Add CPU guards** — `if DEVICE.type == "cuda"` before all `torch.cuda.*`.
- **Verify CellTour** — all `cell_name` references resolve.
- **Merge related visuals into one plot** — when two cells show DIFFERENT but
related aspects of the SAME underlying diagram (e.g. a point-marker selection
AND a window band on the same ruler plot, both driven by the same controls),
combine them into ONE cell/plot so the viewer sees the relationship. Splitting
related aspects across cells fragments understanding; one combined plot makes
the interaction cohesive. (User correction, 07-15.)
- **Verify SVG label/axis clipping** — in hand-authored SVG (row labels, axis
ticks), `text-anchor="end"` labels extend LEFTWARD from the anchor and clip
if the left margin is narrower than the label width (`response_locked` shows
as `ponse_locked`); bottom axis labels need bottom padding. Always check the
RENDERED output for clipped text, not just the SVG source. (07-15.)
---
## Phase 7: Polish
### Pre-publication checklist
- [ ] **0 errors** — run all cells end-to-end.
- [ ] **App config** — `width="medium"`, `auto_download=["html"]`.
- [ ] **hide_code=True** on most cells (keep pedagogical code visible).
- [ ] **No underscore-prefixed names** anywhere downstream (scan with regex).
- [ ] **CellTour** — all references valid, correct page order.
- [ ] **Numbers consistent** — same metric cited identically across cells.
- [ ] **LaTeX renders** — use raw strings; check `\text`, `\times`.
- [ ] **CPU guards** on all `torch.cuda.*` calls.
- [ ] **VRAM note** — if a cell needs >40 GB, note it + add graceful skip.
- [ ] **AI disclosure** — note in the close cell if AI tools were used.
### Biggest engagement-killers
1. **Unlabeled extensions** — readers can't appreciate what they can't find.
2. **Errors or missing deps** — breaks the reading flow instantly.
3. **Decorative widgets** — if a widget doesn't drive exploration, it's noise.
4. **No sense of compute** — if you used a GPU, make it tangible (numbers,
measurements, not just "trained on GPU").
---
## Dependencies
This skill assumes:
- **`marimo-pair`** skill — pair-programming inside a running kernel (the build tool)
- **`marimo-notebook-patterns`** skill — exhaustive marimo gotcha reference
- Python packages: `anywidget` + `traitlets` (widgets), `wigglystuff`
(CellTour — install via `ctx.packages.add("wigglystuff")`), `plotly`
(charts), `torch` (GPU experiments), `transformers` (language model
experiments)
## celltour-granularity
- CellTour GRANULARITY: target **~7-12 stops at "main beats" granularity** — NOT
15-25 (that range was tried and rejected as "going too overboard"). The
evolution on Network-Analysis-Made-Simple (07-12): 6 steps → too few ("the
tour should follow the story of the notebook"); 23 steps → too many ("now
that's going too overboard. we want main points, coherent narrative flipping
through stops"); 9-12 stops → "ok, great, this granularity is much better."
Each stop is a MAIN NARRATIVE BEAT (hero, dataset intro, core concept, key
exercise, generalization, distribution/viz, key insight, optional extension,
recap), never a micro-step.
- CellTour AUTHORING RULES (from Network-Analysis-Made-Simple 07-12): (1) Cell 0
is ALWAYS the hero (first stop); (2) Cell 1 is ALWAYS the CellTour itself
(SKIP it — never reference it as a tour stop); (3) Target markdown section
headers, key code cells, exercises, and interactive widgets; (4) Each
description = ONE sentence about what the reader should learn at that stop;
(5) Steps follow the notebook's reading order, not jumping around.
- CellTour VERIFICATION: after writing steps, dump each cell index + first
meaningful line (a script counting `@app.cell` decorators from 0) to confirm
every cell index matches its title/description. When asked to populate
CellTours across a series of notebooks, apply these rules consistently to ALL
notebooks in the series.
## Original description (preserved on trim, 2026-07-19)
Build an awesome marimo notebook that brings a research paper or dataset to life — for competitions, tutorials, demos, or blog companions. Use when asked to "make an awesome marimo notebook," "build a notebook for this paper," "create a competition submission," or pair-program an interactive research explainer on molab. Covers: what makes a notebook engaging (intuition game, real experiment, novel extension, design system), paper selection, narrative arc and proportions, custom anywidget patterns (bi-directional JS<->Python state: JS brushing/selection -> Python region-of-interest; Python variables reactively driving JS; the JS->Python->JS ROUND-TRIP anti-pattern where a Python @observe on a JS-set trait pushes a derived trait back to JS whose change listener silently fails under rapid interaction — render derived display state in JS from a one-shot payload instead), wigglystuff CellTour, effective GPU usage, memory-efficient training (fused STE, gradient checkpointing), parallel review subagents, and a pre-publication polish checklist. CRITICAL: mo is auto-injected (import in ONE cell); every top-level name must be unique across ALL cells; custom anywidget needs model.save_changes(); use mo.output.replace() for live charts; guard all torch.cuda calls for CPU-only machines.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!