cxr-vlm-code / scripts /_build_inference_notebook.py
convitom
f
37bb3d9
Raw
History Blame Contribute Delete
24 kB
"""
Build scripts/cxrvlm_colab_inference.ipynb from cell sources defined here.
Run:
python scripts/_build_inference_notebook.py
"""
import json
from pathlib import Path
NB_PATH = Path(__file__).parent / "cxrvlm_colab_inference.ipynb"
def md(cell_id, src):
return {
"cell_type": "markdown",
"id": cell_id,
"metadata": {},
"source": src.splitlines(keepends=True),
}
def code(cell_id, src):
return {
"cell_type": "code",
"id": cell_id,
"metadata": {},
"execution_count": None,
"outputs": [],
"source": src.splitlines(keepends=True),
}
CELLS = []
# ─────────────────────────────────────────────────────────────────────
CELLS.append(md("inf-0", """\
# CXR-VLM — Inference Notebook (Colab T4 / GCP)
Free-form inference on individual chest X-ray images. Use this when you want to:
- Run the trained model on a **new image** (not in the test split).
- Ask **custom VQA questions**.
- See the image + generated text side-by-side for qualitative checks.
- Iterate on prompts without re-running the heavy `evaluate.py` pipeline.
For aggregate test-set metrics use the companion notebook **`cxrvlm_colab_eval.ipynb`**.
### Sections
1. Selectors (which trained model to load + compute device)
2. Env + pip
3. Pull code from HF
4. Pull trained checkpoint + config snapshot from HF
5. Auto-detect GPU profile (bf16+FA2 on Ampere+/Ada, fp16+SDPA on Turing)
6. Build configs (4-bit Vicuna with the profile)
7. **Load model in-kernel** (one-time, ~5–8 min on T4 cold cache, ~3 min on L4)
8. **Inference helpers** (single image → findings / impression / report / VQA / full cascade)
9. **Examples** — single image, VQA, batch folder
"""))
CELLS.append(md("inf-select-md", """\
## 0. Select trained run + device
"""))
CELLS.append(code("inf-select", """\
# ── Platform ─────────────────────────────────────────────────────
PLATFORM = 'colab' # 'kaggle' | 'colab' | 'lightning' | 'gcp' | 'local'
# ── Source repos on HuggingFace ──────────────────────────────────
HF_USER = 'hieu3636'
HF_CODE_REPO = f'{HF_USER}/cxr-vlm-code'
HF_RUNS_REPO = f'{HF_USER}/cxr-vlm-runs'
# ── Which trained run to use ─────────────────────────────────────
RUN_ID = 'MIMIC-CXR_resized_run_1'
CKPT_PICK = 'best' # 'best' | 'last'
# ── Generation defaults (override per-call later if desired) ─────
MAX_NEW_TOKENS = 300
TEMPERATURE = 0.1
DO_SAMPLE = False # greedy by default — deterministic
NUM_BEAMS = 1
assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')
assert CKPT_PICK in ('best', 'last')
print(f'PLATFORM = {PLATFORM}')
print(f'RUN_ID = {RUN_ID} (ckpt: stage2/{CKPT_PICK})')
"""))
CELLS.append(md("inf-env-md", "## 1. Environment + pip install"))
CELLS.append(code("inf-env", """\
import os
os.environ['CUDA_VISIBLE_DEVICES'] = '0'
os.environ['TOKENIZERS_PARALLELISM'] = 'false'
os.environ['BITSANDBYTES_NOWELCOME'] = '1'
os.environ['TRANSFORMERS_VERBOSITY'] = 'warning'
os.environ['PYTHONUNBUFFERED'] = '1'
import sys, shutil, subprocess
from pathlib import Path
"""))
CELLS.append(code("inf-pip", """\
# Same dependency window as the training/eval notebooks.
!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate
!pip install -q -U bitsandbytes
!pip install -q \\
'transformers>=4.46,<4.50' \\
'peft>=0.13,<0.15' \\
'accelerate>=1.0' \\
'huggingface_hub>=0.27,<1.0' \\
omegaconf sentencepiece 'protobuf>=3.20' \\
pillow matplotlib
# Best-effort flash-attn install for Ampere+/Ada GPUs (L4, 3090, A10, A100, H100).
# Silent fail is OK — cxr_vlm.py auto-falls-back to SDPA if FA2 isn't importable.
import torch as _t
if _t.cuda.is_available() and _t.cuda.get_device_capability(0) >= (8, 0):
print('[pip] Ampere+/Ada detected -> trying flash-attn (5-10 min if building from source)')
!pip install -q flash-attn --no-build-isolation 2>&1 | tail -5
else:
print('[pip] Pre-Ampere GPU (or no CUDA) -> skipping flash-attn install')
"""))
CELLS.append(code("inf-versions", """\
import torch, transformers, peft, huggingface_hub, httpx
print('torch :', torch.__version__, '| cuda:', torch.cuda.is_available())
print('transformers :', transformers.__version__)
print('peft :', peft.__version__)
print('huggingface_hub:', huggingface_hub.__version__)
print('httpx :', httpx.__version__)
# httpx 0.28+ shim — same as the other notebooks.
def _patch_httpx():
if tuple(int(x) for x in httpx.__version__.split('.')[:2]) < (0, 28):
return
if getattr(httpx.Client, '_cxr_vlm_compat_patched', False):
return
def _make(orig):
def patched(self, *args, **kwargs):
if 'allow_redirects' in kwargs:
kwargs['follow_redirects'] = kwargs.pop('allow_redirects')
kwargs.pop('proxies', None)
return orig(self, *args, **kwargs)
return patched
for cls in (httpx.Client, httpx.AsyncClient):
for m in ('request', 'get', 'head', 'post', 'put',
'patch', 'delete', 'options'):
if hasattr(cls, m):
setattr(cls, m, _make(getattr(cls, m)))
httpx.Client._cxr_vlm_compat_patched = True
_patch_httpx()
assert torch.cuda.is_available(), 'CUDA not available.'
_p = torch.cuda.get_device_properties(0)
print(f'GPU : {_p.name} ({_p.total_memory/1e9:.1f} GB)')
"""))
CELLS.append(md("inf-paths-md", "## 2. Paths + pull code"))
CELLS.append(code("inf-paths", """\
# WORK dir + HF_TOKEN bootstrap (platform-specific).
if PLATFORM == 'kaggle':
from kaggle_secrets import UserSecretsClient
os.environ['HF_TOKEN'] = UserSecretsClient().get_secret('HF_TOKEN')
WORK = Path('/kaggle/working')
elif PLATFORM == 'colab':
from google.colab import userdata
os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN')
WORK = Path('/content')
elif PLATFORM == 'lightning':
WORK = Path('/teamspace/studios/this_studio')
elif PLATFORM == 'gcp':
# Vertex AI Workbench → /home/jupyter. Generic GCE VM → /workspace.
# Pick whichever exists; create the latter if neither is writable.
for _cand in (Path('/home/jupyter'), Path('/workspace')):
if _cand.exists() or os.access(_cand.parent, os.W_OK):
WORK = _cand
break
else:
WORK = Path.home() / 'cxr-vlm-work'
else: # 'local'
WORK = Path.home() / 'cxr-vlm-work'
WORK.mkdir(parents=True, exist_ok=True)
assert os.environ.get('HF_TOKEN'), \\
'HF_TOKEN missing — set it via the platform secrets UI before re-running.'
from huggingface_hub import snapshot_download
print(f'Pulling code from {HF_CODE_REPO} …')
CODE_SRC = Path(snapshot_download(
repo_id = HF_CODE_REPO,
repo_type = 'model',
token = os.environ['HF_TOKEN'],
local_dir = str(WORK / 'cxr-vlm-code'),
))
PROJECT = WORK / 'cxr_vlm'
if CODE_SRC.resolve() != PROJECT.resolve() and not PROJECT.exists():
shutil.copytree(CODE_SRC, PROJECT)
os.chdir(PROJECT)
sys.path.insert(0, str(PROJECT))
print('PROJECT :', PROJECT)
print('WORK :', WORK)
"""))
CELLS.append(md("inf-pull-run-md", """\
## 3. Pull trained checkpoint + config snapshot
Same as the eval notebook: pulls `{RUN_ID}/configs/` + `{RUN_ID}/stage2/{best|last}/` from `HF_RUNS_REPO`. No dataset payload needed for free-form inference.
"""))
CELLS.append(code("inf-pull-run", """\
from huggingface_hub import snapshot_download
RUN_PULL_ROOT = WORK / 'run_pull'
RUN_PULL_ROOT.mkdir(parents=True, exist_ok=True)
print(f'Pulling {RUN_ID}/{{configs,stage2/{CKPT_PICK}}} from {HF_RUNS_REPO} …')
snapshot_download(
repo_id = HF_RUNS_REPO,
repo_type = 'model',
token = os.environ['HF_TOKEN'],
allow_patterns = [
f'{RUN_ID}/configs/**',
f'{RUN_ID}/run_meta.json',
f'{RUN_ID}/stage2/{CKPT_PICK}/**',
],
local_dir = str(RUN_PULL_ROOT),
)
RUN_DIR_PULLED = RUN_PULL_ROOT / RUN_ID
CKPT_DIR_PULLED = RUN_DIR_PULLED / 'stage2' / CKPT_PICK
PROJ_PT = CKPT_DIR_PULLED / 'checkpoint_projection.pt'
LORA_DIR = CKPT_DIR_PULLED / 'checkpoint_lora'
CHEXPERT_PT = CKPT_DIR_PULLED / 'checkpoint_chexpert_classifier.pt'
assert PROJ_PT.is_file(), f'Projection weights not found at {PROJ_PT}.'
assert (LORA_DIR / 'adapter_config.json').is_file(), \\
f'LoRA adapter_config.json not found in {LORA_DIR}.'
print(f' projection : {PROJ_PT} ({PROJ_PT.stat().st_size/1e6:.1f} MB)')
print(f' lora : {LORA_DIR}/ ({sum(p.stat().st_size for p in LORA_DIR.rglob("*") if p.is_file())/1e6:.1f} MB)')
print(f' chexpert : {CHEXPERT_PT} (exists: {CHEXPERT_PT.is_file()})')
SAVED_CFG_DIR = RUN_DIR_PULLED / 'configs'
SAVED_TRAIN_CFG = SAVED_CFG_DIR / 'train_config.yaml'
SAVED_MODEL_CFG = SAVED_CFG_DIR / 'model_config.yaml'
"""))
CELLS.append(md("inf-gpu-md", """\
## 4. Auto-detect GPU profile
Picks precision + attention backend based on the actual GPU:
- **Turing (T4, V100)** → fp16 + SDPA
- **Ampere+ / Ada (3090, L4, A10, A100, H100)** → bf16 + FA2 (if flash-attn installed; falls back to SDPA otherwise)
"""))
CELLS.append(code("inf-gpu", """\
import torch
assert torch.cuda.is_available(), 'CUDA not available.'
_p = torch.cuda.get_device_properties(0)
_cap = (_p.major, _p.minor)
_vram_gb = _p.total_memory / 1e9
_bf16_ok = torch.cuda.is_bf16_supported()
_fa2_ok = _cap >= (8, 0)
_flash_attn_installed = False
if _fa2_ok:
try:
import flash_attn # noqa: F401
_flash_attn_installed = True
except Exception:
_flash_attn_installed = False
PROFILE = dict(
label = _p.name,
torch_dtype = 'bfloat16' if _bf16_ok else 'float16',
bnb_4bit_compute_dtype = 'bfloat16' if _bf16_ok else 'float16',
attn_implementation = 'flash_attention_2' if (_fa2_ok and _flash_attn_installed) else 'sdpa',
)
print(f'GPU : {_p.name} ({_vram_gb:.1f} GB)')
print(f'Compute cap : sm_{_cap[0]}{_cap[1]} bf16 ok: {_bf16_ok} FA2 wheel: {_flash_attn_installed}')
print(f'-> precision: {PROFILE["torch_dtype"]} attn: {PROFILE["attn_implementation"]}')
"""))
CELLS.append(md("inf-cfg-md", "## 5. Build configs"))
CELLS.append(code("inf-cfg", """\
from omegaconf import OmegaConf
if SAVED_MODEL_CFG.is_file():
model_cfg = OmegaConf.load(SAVED_MODEL_CFG)
print(f'model_cfg <- {SAVED_MODEL_CFG}')
else:
model_cfg = OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')
print(f'model_cfg <- repo default')
if SAVED_TRAIN_CFG.is_file():
train_cfg = OmegaConf.load(SAVED_TRAIN_CFG)
else:
train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')
# Compute from auto-detected PROFILE (previous cell).
model_cfg.llm.load_in_4bit = True
model_cfg.llm.load_in_8bit = False
model_cfg.llm.attn_implementation = PROFILE['attn_implementation']
model_cfg.llm.torch_dtype = PROFILE['torch_dtype']
model_cfg.llm.bnb_4bit_compute_dtype = PROFILE['bnb_4bit_compute_dtype']
model_cfg.llm.bnb_4bit_quant_type = 'nf4'
model_cfg.llm.bnb_4bit_use_double_quant = True
model_cfg.llm.gradient_checkpointing = False # inference only
# CheXpert classifier — enable iff its checkpoint is pulled.
if CHEXPERT_PT.is_file():
model_cfg.chexpert_classifier.enabled = True
print(f'CheXpert classifier checkpoint found -> ENABLED')
else:
model_cfg.chexpert_classifier.enabled = False
print('No CheXpert classifier in this run -> DISABLED '
'(structured_findings will be None unless you pass it explicitly)')
print('\\nmodel_cfg.llm:')
print(OmegaConf.to_yaml(model_cfg.llm))
"""))
CELLS.append(md("inf-load-md", """\
## 6. Load model into kernel
This is the slow phase on cold cache: encoder + 4-bit Vicuna shard download + quantization. Run **once per session** and reuse the `model` object for all subsequent inferences.
Approx timing: ~8 min on T4 cold, ~3 min on L4 cold, seconds when cache is warm.
"""))
CELLS.append(code("inf-load", """\
import time, torch
from model import CXRVisionLanguageModel
from model.rad_dino import BioViLTEncoder
from utils.checkpoint import load_checkpoint
print('[1/3] Building model (Vicuna-7B 4-bit + RAD-DINO + LoRA)…')
print(' On T4 cold cache this takes ~5–8 min for the shard download + quantization.')
_t0 = time.time()
model = CXRVisionLanguageModel(model_cfg)
print(f' built in {time.time()-_t0:.1f}s', flush=True)
print(f'[2/3] Loading checkpoint from {CKPT_DIR_PULLED} …')
_t0 = time.time()
# Pass the directory, not the .pt file: load_checkpoint splits suffix off
# the path stem, so passing checkpoint_projection.pt makes it look for
# checkpoint_projection_projection.pt (which does not exist) and silently
# skips both projection AND lora — model falls back to raw 4-bit Vicuna.
load_checkpoint(model, str(CKPT_DIR_PULLED))
print(f' loaded in {time.time()-_t0:.1f}s', flush=True)
print('[3/3] Moving to cuda + eval()')
_t0 = time.time()
model = model.to('cuda').eval()
print(f' ready in {time.time()-_t0:.1f}s', flush=True)
# Cache transform + chexpert helpers for reuse.
TRANSFORM = BioViLTEncoder.get_transform('val')
print('\\nModel ready. VRAM used:',
f'{torch.cuda.memory_allocated()/1e9:.2f} GB / {torch.cuda.get_device_properties(0).total_memory/1e9:.1f} GB')
"""))
CELLS.append(md("inf-helpers-md", """\
## 7. Inference helpers
Two convenience functions:
- `predict(image, task, question=None, structured_findings=None, ...)` — single image, single task.
- `predict_report(image, ...)` — cascade: generates findings first, then feeds the model's own findings as context for impression. Returns both sections plus the merged report.
`image` can be a filesystem path (str/Path) or a `PIL.Image.Image`.
### About `structured_findings` (the PNU prompt block)
Your training run baked **oracle CheXpert labels from the CSV/manifest** into the prompt as the `Positive / Negative / Uncertain Abnormalities` block. At inference time you don't have GT labels — you have three choices:
1. **`None` (default)** — no PNU block prepended. The model sees only the image + the task instruction. This is the realistic deployment mode.
2. **Pass a PNU string yourself** — for oracle ablation or testing how much PNU helps:
```
structured_findings='Positive Abnormalities: Cardiomegaly\\nNegative Abnormalities: No Finding, ...\\nUncertain Abnormalities: None'
```
3. **Train a CheXpert classifier** (Stage 0 in the training pipeline) and load it via `model_cfg.chexpert_classifier.checkpoint` — your current runs do not have one, so this path is disabled in section 4.
"""))
CELLS.append(code("inf-helpers", """\
import torch
from pathlib import Path
from typing import Optional, Union
from PIL import Image
from data.prompt_templates import (
build_findings_prompt, build_impression_prompt,
build_report_prompt, build_vqa_prompt,
)
ImageLike = Union[str, Path, Image.Image]
def _to_tensor(image: ImageLike) -> torch.Tensor:
'''Load + transform a CXR into a (1, C, H, W) tensor on cuda.'''
if isinstance(image, (str, Path)):
img = Image.open(image).convert('RGB')
else:
img = image.convert('RGB') if image.mode != 'RGB' else image
t = TRANSFORM(img) # (C, H, W)
return t.unsqueeze(0).to('cuda') # (1, C, H, W)
@torch.no_grad()
def predict(
image: ImageLike,
task: str,
question: Optional[str] = None,
structured_findings: Optional[str] = None,
max_new_tokens: int = None,
temperature: float = None,
do_sample: bool = None,
num_beams: int = None,
) -> str:
'''Run the model on one image.
task ∈ {'findings', 'impression', 'report', 'vqa'}.
For task='vqa', `question` is required.
'''
assert task in ('findings', 'impression', 'report', 'vqa'), f'bad task: {task}'
if task == 'vqa':
assert question, 'VQA requires `question`.'
sf = structured_findings
if task == 'findings':
prompt = build_findings_prompt(sf, randomize=False)
elif task == 'impression':
prompt = build_impression_prompt(sf, randomize=False)
elif task == 'report':
prompt = build_report_prompt(sf, randomize=False)
else:
prompt = build_vqa_prompt(question, sf)
out = model.generate(
images = _to_tensor(image),
prompts = [prompt],
max_new_tokens = max_new_tokens if max_new_tokens is not None else MAX_NEW_TOKENS,
temperature = temperature if temperature is not None else TEMPERATURE,
do_sample = do_sample if do_sample is not None else DO_SAMPLE,
num_beams = num_beams if num_beams is not None else NUM_BEAMS,
)
return out[0]
@torch.no_grad()
def predict_report(
image: ImageLike,
structured_findings: Optional[str] = None,
max_new_tokens: int = None,
):
'''Cascade: generate findings first, then feed them as context for impression.
Matches the `split_cascade` training recipe but with model-generated (not
GT) findings — i.e. a real end-to-end cascade (no GT leakage).
Returns dict {findings, impression, report}.
'''
# 1) Findings (uses PNU context only — None by default)
findings = predict(image, task='findings',
structured_findings=structured_findings,
max_new_tokens=max_new_tokens)
# 2) Impression: feed the generated findings as the structured_findings
# context — same shape as the cascade trainer saw at training time
# but with the model's own findings instead of GT.
impression = predict(image, task='impression',
structured_findings=findings,
max_new_tokens=max_new_tokens)
return {
'findings': findings,
'impression': impression,
'report': f'Findings: {findings}\\n\\nImpression: {impression}',
}
"""))
CELLS.append(md("inf-show-md", "## 8. Display helper (optional, pretty-prints image + outputs)"))
CELLS.append(code("inf-show", """\
import matplotlib.pyplot as plt
from PIL import Image as _PILImage
def show(image, title=None, max_size=512):
'''Render an image inline with optional title.'''
if isinstance(image, (str, Path)):
img = _PILImage.open(image).convert('RGB')
else:
img = image
img.thumbnail((max_size, max_size))
plt.figure(figsize=(6, 6))
plt.imshow(img)
plt.axis('off')
if title:
plt.title(title)
plt.show()
def pretty(d: dict, header: str = None):
'''Pretty-print a dict of {label: text}.'''
if header:
print('=' * 80)
print(header)
for k, v in d.items():
print('-' * 80)
print(f'[{k.upper()}]')
print(v)
print('=' * 80)
"""))
CELLS.append(md("inf-ex1-md", """\
## 9. Examples
### Example A — single image, all tasks
Edit `IMAGE_PATH` and `VQA_QUESTION` below, then run.
"""))
CELLS.append(code("inf-ex1", """\
# Pick an image — anywhere on disk. Colab: drag-and-drop into the Files pane
# (left sidebar) and use '/content/<filename>'. Local: any path that exists.
IMAGE_PATH = '/content/sample_cxr.jpg'
# Optional PNU override (see helpers section for format). None → no PNU block.
STRUCTURED_FINDINGS = None
# A clinical question for the VQA call below.
VQA_QUESTION = 'Is there any pleural effusion visible in this chest X-ray?'
if not Path(IMAGE_PATH).is_file():
print(f'!! {IMAGE_PATH} does not exist. Upload an image or change IMAGE_PATH.')
else:
show(IMAGE_PATH, title=Path(IMAGE_PATH).name)
if STRUCTURED_FINDINGS:
print('Structured findings (PNU) override:')
print(STRUCTURED_FINDINGS)
print()
findings = predict(IMAGE_PATH, task='findings',
structured_findings=STRUCTURED_FINDINGS)
impression = predict(IMAGE_PATH, task='impression',
structured_findings=STRUCTURED_FINDINGS)
vqa_ans = predict(IMAGE_PATH, task='vqa', question=VQA_QUESTION,
structured_findings=STRUCTURED_FINDINGS)
pretty({
'findings': findings,
'impression': impression,
f'vqa ({VQA_QUESTION!r})': vqa_ans,
}, header=Path(IMAGE_PATH).name)
"""))
CELLS.append(md("inf-ex2-md", """\
### Example B — cascade report (findings → impression on model output)
"""))
CELLS.append(code("inf-ex2", """\
if Path(IMAGE_PATH).is_file():
out = predict_report(IMAGE_PATH)
show(IMAGE_PATH, title='Cascade report')
pretty(out, header=f'Cascade: {Path(IMAGE_PATH).name}')
"""))
CELLS.append(md("inf-ex3-md", """\
### Example C — batch a folder of images
Drops all results into `BATCH_OUT_JSON` for downstream analysis.
"""))
CELLS.append(code("inf-ex3", """\
import json
from tqdm.auto import tqdm
BATCH_FOLDER = '/content/cxr_inbox' # folder of *.jpg / *.png
BATCH_TASK = 'report' # 'findings' | 'impression' | 'report' | 'vqa' | 'cascade'
BATCH_QUESTION = None # only used when BATCH_TASK == 'vqa'
BATCH_OUT_JSON = WORK / f'inference_{RUN_ID}_{BATCH_TASK}.json'
EXTS = {'.jpg', '.jpeg', '.png'}
folder = Path(BATCH_FOLDER)
imgs = sorted(p for p in folder.rglob('*') if p.suffix.lower() in EXTS) \\
if folder.is_dir() else []
print(f'{len(imgs)} image(s) under {folder}')
results = []
for p in tqdm(imgs, desc=f'Inference [{BATCH_TASK}]'):
try:
if BATCH_TASK == 'cascade':
out = predict_report(p)
else:
out = {BATCH_TASK: predict(p, task=BATCH_TASK,
question=BATCH_QUESTION)}
results.append({'image': str(p), **out})
except Exception as e:
results.append({'image': str(p), 'error': f'{type(e).__name__}: {e}'})
if results:
BATCH_OUT_JSON.write_text(json.dumps(results, indent=2))
print(f'Wrote {len(results)} entries -> {BATCH_OUT_JSON}')
"""))
CELLS.append(md("inf-done-md", """\
### Tips
- Loading the model takes 5–8 min on T4 cold cache. After that, each generation is ~3–8 s.
- To switch to a different trained run: change `RUN_ID` in section 0, re-run sections 3–5 (skip the env / pip sections).
- For deterministic outputs keep `DO_SAMPLE=False`. For diversity, set `DO_SAMPLE=True, TEMPERATURE=0.7`.
- `predict_report()` is the realistic end-to-end pipeline (no GT leakage). `predict(task='report')` only works on runs trained with `report_mode='merged'`.
"""))
# ─────────────────────────────────────────────────────────────────────
nb = {
"cells": CELLS,
"metadata": {
"accelerator": "GPU",
"colab": {
"gpuType": "T4",
"provenance": [],
"machine_shape": "hm",
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3",
},
"language_info": {
"name": "python",
"version": "3.10",
},
},
"nbformat": 4,
"nbformat_minor": 5,
}
NB_PATH.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
print(f"wrote {NB_PATH} ({len(CELLS)} cells)")