Matanech commited on
Commit
6a02ba5
·
verified ·
1 Parent(s): 87643bf

Delete build_notebook.py

Browse files
Files changed (1) hide show
  1. build_notebook.py +0 -739
build_notebook.py DELETED
@@ -1,739 +0,0 @@
1
- """
2
- Fills the empty code cells in the Assignment 3 notebook with working code
3
- for a chest-X-ray recommendation system using CLIP embeddings.
4
-
5
- Dataset: MLforHealthcare/mimic-cxr
6
- - 30.6k chest X-ray images (train 21.4k / val 4.59k / test 4.6k)
7
- - Columns: image (512x512), reports (free-text radiology report)
8
-
9
- Model: openai/clip-vit-base-patch32
10
-
11
- NOTE: Standard CLIP wasn't trained on medical images, but it still produces
12
- useful similarity signals on chest X-rays. For higher-fidelity results you
13
- can swap MODEL_ID for `flaviagiammarino/pubmed-clip-vit-base-patch32` (a
14
- biomedical CLIP variant) — the rest of the pipeline is identical.
15
- """
16
- import json
17
- from pathlib import Path
18
-
19
- SRC_PATH = Path("/sessions/focused-inspiring-bohr/mnt/uploads/Copy_of_Assignment_3_Embeddings,_RecSys,_Spaces.ipynb")
20
- NB_PATH = Path("/sessions/focused-inspiring-bohr/mnt/outputs/Assignment_3_MIMIC_CXR_Recommender_v3.ipynb")
21
-
22
- with SRC_PATH.open() as f:
23
- nb = json.load(f)
24
-
25
-
26
- def replace_cell(idx: int, src: str) -> None:
27
- """Replace the source of cell `idx` with code `src`."""
28
- cell = nb["cells"][idx]
29
- assert cell["cell_type"] == "code", f"Cell {idx} is {cell['cell_type']}, not code"
30
- lines = src.split("\n")
31
- cell["source"] = [l + "\n" for l in lines[:-1]] + ([lines[-1]] if lines[-1] else [])
32
- cell["execution_count"] = None
33
- cell["outputs"] = []
34
-
35
-
36
- # ============================================================
37
- # Part 0: Config
38
- # ============================================================
39
-
40
- # Cell 12: imports
41
- replace_cell(12, '''\
42
- # Install dependencies (run once in Colab):
43
- # !pip install -q transformers datasets sentence-transformers umap-learn gradio scikit-learn wordcloud
44
-
45
- import os
46
- import io
47
- import json
48
- import base64
49
- import random
50
- import re
51
- from collections import Counter
52
-
53
- import numpy as np
54
- import pandas as pd
55
-
56
- import matplotlib.pyplot as plt
57
- import seaborn as sns
58
-
59
- import torch
60
- from PIL import Image
61
-
62
- from datasets import load_dataset
63
- from transformers import CLIPModel, CLIPProcessor
64
-
65
- from sklearn.cluster import KMeans
66
- from sklearn.decomposition import PCA
67
- from sklearn.manifold import TSNE
68
- from sklearn.metrics import silhouette_score
69
- from sklearn.feature_extraction.text import TfidfVectorizer
70
-
71
- try:
72
- import umap # umap-learn
73
- except ImportError:
74
- umap = None''')
75
-
76
- # Cell 14: seeds
77
- replace_cell(14, '''\
78
- SEED = 42
79
-
80
- random.seed(SEED)
81
- np.random.seed(SEED)
82
- torch.manual_seed(SEED)
83
- if torch.cuda.is_available():
84
- torch.cuda.manual_seed_all(SEED)
85
- os.environ["PYTHONHASHSEED"] = str(SEED)''')
86
-
87
- # ============================================================
88
- # Part 1: Select a Visual Dataset
89
- # ============================================================
90
-
91
- # Cell 21: dataset config
92
- replace_cell(21, '''\
93
- # Chosen dataset:
94
- # MLforHealthcare/mimic-cxr - ~30,600 chest X-ray images paired with
95
- # free-text radiology reports.
96
- #
97
- # Why this dataset?
98
- # - Visual modality (X-ray images) -> works with image embeddings
99
- # - Mid-size (~30k rows) -> fits the 10K-100K bracket
100
- # - Each image has a paired radiology report -> rich semantic information
101
- # we can use for clustering
102
- # interpretation
103
- # - Medical/niche -> realistic recommendation
104
- # use case: "find prior
105
- # studies that look like
106
- # this one"
107
- #
108
- # DISCLAIMER: This notebook is for educational purposes only. The resulting
109
- # app is NOT a medical device and must not be used for clinical decisions.
110
-
111
- DATASET_ID = "MLforHealthcare/mimic-cxr"
112
- SPLIT = "train"
113
- N_SAMPLES = 2000 # subsample for fast end-to-end runs on a Colab GPU
114
- # set to None to use the full split''')
115
-
116
- # Cell 23: load dataset
117
- replace_cell(23, '''\
118
- # Load the dataset from HuggingFace
119
- raw_ds = load_dataset(DATASET_ID, split=SPLIT)
120
- print("Full split size:", len(raw_ds))
121
- print("Features:", raw_ds.features)
122
-
123
- # Take a reproducible random subset so the notebook is fast end-to-end
124
- if N_SAMPLES is not None and N_SAMPLES < len(raw_ds):
125
- raw_ds = raw_ds.shuffle(seed=SEED).select(range(N_SAMPLES))
126
-
127
- print("Working with:", len(raw_ds), "samples")
128
- print()
129
- print("Example row:")
130
- row = raw_ds[0]
131
- print(" image:", row["image"].size, row["image"].mode)
132
- print(" report:", row["reports"][:160] + ("..." if len(row["reports"]) > 160 else ""))''')
133
-
134
- # Cell 25: describe
135
- replace_cell(25, '''\
136
- # Quick description of the dataset
137
-
138
- print(f"Dataset : {DATASET_ID}")
139
- print(f"Source : HuggingFace Hub - https://huggingface.co/datasets/{DATASET_ID}")
140
- print(f"Split : {SPLIT}")
141
- print(f"Size : {len(raw_ds):,} images (after subsampling)")
142
- print(f"Schema : {list(raw_ds.features.keys())}")
143
- print()
144
- print("Per-row contents:")
145
- print(" - image : PIL Image, 512x512 grayscale chest X-ray")
146
- print(" - reports: free-text radiology report describing the findings")
147
- print()
148
- print("Key context:")
149
- print(" - Real (de-identified) chest X-rays.")
150
- print(" - No discrete class labels - the medical meaning lives in the text reports.")
151
- print(" - We'll mine that text below to interpret the visual clusters.")''')
152
-
153
- # ============================================================
154
- # Part 2: EDA
155
- # ============================================================
156
-
157
- # Cell 29: build metadata DataFrame
158
- replace_cell(29, '''\
159
- # Build a metadata DataFrame for EDA
160
- records = []
161
- for i, row in enumerate(raw_ds):
162
- img = row["image"]
163
- txt = row["reports"] or ""
164
- records.append({
165
- "idx" : i,
166
- "width" : img.size[0],
167
- "height" : img.size[1],
168
- "mode" : img.mode,
169
- "report" : txt,
170
- "report_len" : len(txt),
171
- "report_words" : len(txt.split()),
172
- })
173
-
174
- df = pd.DataFrame(records)
175
- print("Shape:", df.shape)
176
- df.head()''')
177
-
178
- # Cell 30: visual EDA
179
- replace_cell(30, '''\
180
- # Sanity checks
181
- print("Missing values per column:")
182
- print(df.isna().sum(), "\\n")
183
-
184
- print("Image size statistics:")
185
- print(df[["width", "height"]].describe(), "\\n")
186
-
187
- print("Report length statistics (chars / words):")
188
- print(df[["report_len", "report_words"]].describe(), "\\n")
189
-
190
- # --- Plot 1: image dimensions ---
191
- fig, axes = plt.subplots(1, 2, figsize=(12, 4))
192
- axes[0].hist(df["width"], bins=30, color="steelblue", edgecolor="white")
193
- axes[0].set_title("Image width (px)"); axes[0].set_xlabel("px")
194
- axes[1].hist(df["height"], bins=30, color="indianred", edgecolor="white")
195
- axes[1].set_title("Image height (px)"); axes[1].set_xlabel("px")
196
- plt.tight_layout(); plt.show()
197
-
198
- # --- Plot 2: report length distribution ---
199
- fig, axes = plt.subplots(1, 2, figsize=(12, 4))
200
- axes[0].hist(df["report_len"], bins=40, color="seagreen", edgecolor="white")
201
- axes[0].set_title("Report length (chars)"); axes[0].set_xlabel("characters")
202
- axes[1].hist(df["report_words"], bins=40, color="darkorange", edgecolor="white")
203
- axes[1].set_title("Report length (words)"); axes[1].set_xlabel("words")
204
- plt.tight_layout(); plt.show()
205
-
206
- # --- Plot 3: most common medical keywords in the reports ---
207
- STOP = set("""
208
- the a an of and or in is are was were be been being has have had this that with
209
- no not on at to for from by as it its it's there their normal stable unchanged
210
- unremarkable seen which than have without left right side chest patient study
211
- view ap pa portable single small large mild moderate severe acute new no new
212
- since prior compared comparison radiograph radiographs frontal lateral process
213
- finding findings impression history clinical
214
- """.split())
215
-
216
- def tokenize(t):
217
- return [w for w in re.findall(r"[a-zA-Z]+", t.lower()) if w not in STOP and len(w) > 3]
218
-
219
- vocab = Counter()
220
- for t in df["report"]:
221
- vocab.update(tokenize(t))
222
-
223
- top = vocab.most_common(25)
224
- print("\\nTop 25 medical terms in the reports:")
225
- for term, count in top:
226
- print(f" {term:25s} {count}")
227
-
228
- plt.figure(figsize=(10, 5))
229
- terms, counts = zip(*top)
230
- plt.barh(terms, counts, color="purple")
231
- plt.gca().invert_yaxis()
232
- plt.title("Most frequent terms in radiology reports")
233
- plt.xlabel("count"); plt.tight_layout(); plt.show()
234
-
235
- # --- Plot 4: grid of random X-rays ---
236
- n_show = 12
237
- sample_idx = np.random.RandomState(SEED).choice(len(raw_ds), n_show, replace=False)
238
- fig, axes = plt.subplots(2, 6, figsize=(15, 6))
239
- for ax, i in zip(axes.ravel(), sample_idx):
240
- ax.imshow(raw_ds[int(i)]["image"], cmap="gray")
241
- ax.set_axis_off()
242
- plt.suptitle("Random chest X-rays from the working subset")
243
- plt.tight_layout(); plt.show()''')
244
-
245
- # ============================================================
246
- # Part 3: Embeddings
247
- # ============================================================
248
-
249
- # Cell 34: pick model
250
- replace_cell(34, '''\
251
- # Embedding model:
252
- # openai/clip-vit-base-patch32 - CLIP ViT-B/32, ~150M params
253
- # - Multimodal (image + text), so the Gradio app can accept either input.
254
- # - 512-dim L2-normalisable embeddings.
255
- # - Robust enough on chest X-rays for cluster-level similarity, even though
256
- # it wasn't trained on medical data.
257
- #
258
- # Alternative (better for medical images, drop-in compatible):
259
- # MODEL_ID = "flaviagiammarino/pubmed-clip-vit-base-patch32"
260
-
261
- MODEL_ID = "openai/clip-vit-base-patch32"
262
- EMBED_DIM = 512''')
263
-
264
- # Cell 36: build the embeddings
265
- replace_cell(36, '''\
266
- device = "cuda" if torch.cuda.is_available() else "cpu"
267
- print("Using device:", device)
268
-
269
- clip_model = CLIPModel.from_pretrained(MODEL_ID).to(device).eval()
270
- clip_processor = CLIPProcessor.from_pretrained(MODEL_ID)
271
-
272
-
273
- def _to_tensor(out):
274
- """Unwrap a CLIP output that may be a tensor or a HuggingFace ModelOutput."""
275
- if torch.is_tensor(out):
276
- return out
277
- if hasattr(out, "image_embeds"):
278
- return out.image_embeds
279
- if hasattr(out, "text_embeds"):
280
- return out.text_embeds
281
- if hasattr(out, "pooler_output"):
282
- return out.pooler_output
283
- if hasattr(out, "last_hidden_state"):
284
- return out.last_hidden_state[:, 0]
285
- raise TypeError(f"Cannot unwrap CLIP output of type {type(out)}")
286
-
287
-
288
- @torch.no_grad()
289
- def embed_images(images, batch_size: int = 32) -> np.ndarray:
290
- """Compute L2-normalised CLIP image embeddings for a list of PIL images.
291
- Uses vision_model + visual_projection explicitly so it works across
292
- different transformers versions (some return BaseModelOutputWithPooling
293
- from get_image_features instead of a tensor).
294
- """
295
- feats = []
296
- for start in range(0, len(images), batch_size):
297
- batch = [img.convert("RGB") for img in images[start:start + batch_size]]
298
- inputs = clip_processor(images=batch, return_tensors="pt").to(device)
299
- vision_out = clip_model.vision_model(pixel_values=inputs["pixel_values"])
300
- pooled = _to_tensor(vision_out) # [B, vision_hidden]
301
- emb = clip_model.visual_projection(pooled) # [B, projection_dim]
302
- emb = emb / emb.norm(p=2, dim=-1, keepdim=True) # L2 normalise -> cosine = dot product
303
- feats.append(emb.cpu().numpy())
304
- return np.concatenate(feats, axis=0)
305
-
306
-
307
- @torch.no_grad()
308
- def embed_texts(texts) -> np.ndarray:
309
- """Compute L2-normalised CLIP text embeddings for a list of strings."""
310
- if isinstance(texts, str):
311
- texts = [texts]
312
- inputs = clip_processor(text=texts, return_tensors="pt",
313
- padding=True, truncation=True, max_length=77).to(device)
314
- text_out = clip_model.text_model(
315
- input_ids=inputs["input_ids"],
316
- attention_mask=inputs.get("attention_mask"),
317
- )
318
- pooled = _to_tensor(text_out)
319
- emb = clip_model.text_projection(pooled)
320
- emb = emb / emb.norm(p=2, dim=-1, keepdim=True)
321
- return emb.cpu().numpy()
322
-
323
-
324
- # Encode every image in the working subset
325
- images = [raw_ds[i]["image"] for i in range(len(raw_ds))]
326
- print(f"Encoding {len(images)} chest X-rays with CLIP...")
327
- embeddings = embed_images(images, batch_size=64)
328
- print("Embeddings shape:", embeddings.shape)
329
-
330
- df["embedding"] = list(embeddings)
331
- df.head(3)''')
332
-
333
- # ============================================================
334
- # Part 3.1: 2D projection
335
- # ============================================================
336
-
337
- # Cell 42: PCA
338
- replace_cell(42, '''\
339
- # PCA -> 2 components (linear, very fast)
340
- pca = PCA(n_components=2, random_state=SEED)
341
- pca_2d = pca.fit_transform(embeddings)
342
- print(f"PCA explained variance ratio: {pca.explained_variance_ratio_.sum():.3f}")''')
343
-
344
- # Cell 43: t-SNE + UMAP + side-by-side plot
345
- replace_cell(43, '''\
346
- # t-SNE -> 2D (non-linear)
347
- tsne = TSNE(n_components=2, random_state=SEED, perplexity=30, init="pca")
348
- tsne_2d = tsne.fit_transform(embeddings)
349
-
350
- # UMAP -> 2D (usually the cleanest separation)
351
- if umap is not None:
352
- umap_model = umap.UMAP(n_components=2, random_state=SEED,
353
- n_neighbors=15, min_dist=0.1)
354
- umap_2d = umap_model.fit_transform(embeddings)
355
- else:
356
- umap_2d = None
357
-
358
- # Side-by-side plot (colour by report length as a stand-in until we have clusters)
359
- color = df["report_len"]
360
- fig, axes = plt.subplots(1, 3, figsize=(18, 5))
361
- for ax, coords, title in zip(
362
- axes,
363
- [pca_2d, tsne_2d, umap_2d if umap_2d is not None else pca_2d],
364
- ["PCA", "t-SNE", "UMAP" if umap_2d is not None else "PCA (UMAP unavailable)"],
365
- ):
366
- sc = ax.scatter(coords[:, 0], coords[:, 1], c=color,
367
- cmap="viridis", s=10, alpha=0.7)
368
- ax.set_title(f"{title} - CLIP embeddings of X-rays")
369
- ax.set_xticks([]); ax.set_yticks([])
370
- plt.colorbar(sc, ax=axes[-1], label="report length (chars)")
371
- plt.tight_layout(); plt.show()''')
372
-
373
- # ============================================================
374
- # Part 3.2: clustering
375
- # ============================================================
376
-
377
- # Cell 45: KMeans
378
- replace_cell(45, '''\
379
- # Pick k by silhouette score
380
- ks = list(range(3, 12))
381
- sil_scores = []
382
- for k in ks:
383
- km = KMeans(n_clusters=k, n_init=10, random_state=SEED).fit(embeddings)
384
- sil_scores.append(silhouette_score(
385
- embeddings, km.labels_,
386
- sample_size=min(1000, len(embeddings)),
387
- random_state=SEED,
388
- ))
389
-
390
- best_k = ks[int(np.argmax(sil_scores))]
391
- print(f"Best k by silhouette = {best_k} (score={max(sil_scores):.3f})")
392
-
393
- plt.figure(figsize=(7, 3))
394
- plt.plot(ks, sil_scores, marker="o")
395
- plt.xlabel("k"); plt.ylabel("silhouette")
396
- plt.title("Choosing k for KMeans on CLIP embeddings")
397
- plt.grid(alpha=0.3); plt.show()
398
-
399
- kmeans = KMeans(n_clusters=best_k, n_init=10, random_state=SEED).fit(embeddings)
400
- df["cluster"] = kmeans.labels_''')
401
-
402
- # Cell 46: visualise clusters in 2D
403
- replace_cell(46, '''\
404
- # Show the chosen 2D projection coloured by cluster
405
- plt.figure(figsize=(9, 7))
406
- plt.scatter(tsne_2d[:, 0], tsne_2d[:, 1], c=df["cluster"],
407
- cmap="tab10", s=14, alpha=0.85)
408
- plt.title(f"KMeans clusters (k={best_k}) on CLIP X-ray embeddings - t-SNE projection")
409
- plt.xticks([]); plt.yticks([])
410
- plt.colorbar(label="cluster")
411
- plt.show()''')
412
-
413
- # ============================================================
414
- # Part 3.3: cluster interpretation
415
- # ============================================================
416
-
417
- # Cell 48: representative images per cluster
418
- replace_cell(48, '''\
419
- # Show the items closest to each cluster centroid - a visual summary
420
- def closest_to_centroid(cluster_id, k=6):
421
- members = np.where(df["cluster"] == cluster_id)[0]
422
- centroid = kmeans.cluster_centers_[cluster_id]
423
- dists = np.linalg.norm(embeddings[members] - centroid, axis=1)
424
- return members[np.argsort(dists)[:k]]
425
-
426
- fig, axes = plt.subplots(best_k, 6, figsize=(14, 2.4 * best_k))
427
- if best_k == 1:
428
- axes = np.array([axes])
429
- for c in range(best_k):
430
- for j, idx in enumerate(closest_to_centroid(c, 6)):
431
- ax = axes[c, j]
432
- ax.imshow(raw_ds[int(idx)]["image"], cmap="gray")
433
- ax.set_axis_off()
434
- if j == 0:
435
- ax.set_ylabel(f"cluster {c}",
436
- rotation=0, ha="right", va="center", fontsize=11)
437
- plt.suptitle("Representative X-rays per cluster (closest to centroid)")
438
- plt.tight_layout(); plt.show()''')
439
-
440
- # Cell 49: TF-IDF over the reports for each cluster -> medical-sense interpretation
441
- replace_cell(49, '''\
442
- # Mine the radiology reports inside each cluster to give the clusters a
443
- # medical interpretation. TF-IDF tells us which terms are distinctive of
444
- # each cluster relative to the others.
445
-
446
- vec = TfidfVectorizer(stop_words="english", max_features=3000,
447
- ngram_range=(1, 2), min_df=5)
448
- X_tfidf = vec.fit_transform(df["report"].fillna(""))
449
- terms = np.array(vec.get_feature_names_out())
450
-
451
- print("Most distinctive terms per cluster:")
452
- print("=" * 70)
453
- for c in range(best_k):
454
- mask = df["cluster"].values == c
455
- if mask.sum() == 0:
456
- continue
457
- mean_tfidf = np.asarray(X_tfidf[mask].mean(axis=0)).ravel()
458
- top_terms = terms[np.argsort(-mean_tfidf)[:8]]
459
- print(f"cluster {c} (n={int(mask.sum()):>4d}): {', '.join(top_terms)}")
460
- print("=" * 70)
461
-
462
- # Distribution of cluster sizes
463
- plt.figure(figsize=(8, 3))
464
- df["cluster"].value_counts().sort_index().plot(
465
- kind="bar", color="teal", edgecolor="white",
466
- )
467
- plt.title("Items per cluster"); plt.xlabel("cluster"); plt.ylabel("count")
468
- plt.tight_layout(); plt.show()
469
-
470
- print("""
471
- Interpretation:
472
- - Clusters tend to group X-rays by anatomy / equipment / pathology cues that
473
- CLIP can pick up visually (lung opacities, presence of catheters and tubes,
474
- patient positioning, cardiac silhouette size).
475
- - The TF-IDF terms above tell us *what kind of finding* each cluster is
476
- dominated by - e.g. one cluster may be biased toward "pleural effusion",
477
- another toward "cardiomegaly", and a "normal-looking" cluster will be
478
- dominated by phrases like "no acute" and "clear".
479
- """)''')
480
-
481
- # ============================================================
482
- # Part 3.4: save embeddings
483
- # ============================================================
484
-
485
- # Cell 51: persist parquet (used by the Gradio Space)
486
- replace_cell(51, '''\
487
- def img_to_b64(pil_img, size=224) -> str:
488
- """Encode a PIL image as a base64 JPEG thumbnail so we can ship it in a single parquet."""
489
- img = pil_img.convert("L").copy() # X-rays are grayscale
490
- img.thumbnail((size, size))
491
- buf = io.BytesIO(); img.save(buf, format="JPEG", quality=85)
492
- return base64.b64encode(buf.getvalue()).decode("ascii")
493
-
494
- print("Encoding thumbnails...")
495
- df["image_b64"] = [img_to_b64(raw_ds[i]["image"]) for i in range(len(raw_ds))]
496
-
497
- # Store embedding as a python list so it survives the parquet round-trip
498
- df["embedding"] = df["embedding"].apply(lambda v: np.asarray(v).tolist())
499
-
500
- out_path = "embeddings.parquet"
501
- df.to_parquet(out_path, index=False)
502
- print(f"Saved {len(df):,} rows to {out_path} ({os.path.getsize(out_path) / 1e6:.1f} MB)")''')
503
-
504
- # Cell 52: sanity-check the parquet
505
- replace_cell(52, '''\
506
- # Reload it the way the Gradio Space will
507
- reload_df = pd.read_parquet(out_path)
508
- print(reload_df.shape)
509
- print(reload_df.columns.tolist())
510
- print("First embedding length:", len(reload_df.loc[0, "embedding"]))
511
- print("First report preview :", reload_df.loc[0, "report"][:120], "...")''')
512
-
513
- # ============================================================
514
- # Part 4: Inputs & Outputs
515
- # ============================================================
516
-
517
- # Cell 55: in-memory embedding matrix
518
- replace_cell(55, '''\
519
- # Build the in-memory matrix the recommender uses
520
- EMB_MATRIX = np.vstack(df["embedding"].values).astype("float32")
521
- print("Embedding matrix:", EMB_MATRIX.shape)''')
522
-
523
- # Cell 56: pre-decode thumbnails
524
- replace_cell(56, '''\
525
- def _b64_to_pil(b64: str) -> Image.Image:
526
- img = Image.open(io.BytesIO(base64.b64decode(b64)))
527
- img.load() # force-load before BytesIO is garbage-collected
528
- return img.convert("RGB") # RGB renders more reliably in Gradio than "L"
529
-
530
- THUMBS = [_b64_to_pil(b) for b in df["image_b64"]]''')
531
-
532
- # 4.1
533
- replace_cell(58, '''\
534
- # 4.1 - Embeddings are persisted to `embeddings.parquet` (see Part 3.4).
535
- # The Gradio Space loads that single file at startup.
536
- print("Parquet ready at:", out_path)''')
537
-
538
- replace_cell(59, '''\
539
- # Optional - peek at a single saved row
540
- pd.read_parquet(out_path).head(1)''')
541
-
542
- # 4.2 user input -> embedding
543
- replace_cell(61, '''\
544
- def embed_user_input(user_input):
545
- """
546
- Convert a user input (text or PIL image) into a normalised CLIP embedding.
547
- Returns a 1D numpy array of length EMBED_DIM.
548
- """
549
- if isinstance(user_input, str):
550
- return embed_texts([user_input])[0]
551
- elif isinstance(user_input, Image.Image):
552
- return embed_images([user_input])[0]
553
- else:
554
- raise TypeError(f"Unsupported input type: {type(user_input)}")''')
555
-
556
- replace_cell(62, '''\
557
- # Quick test - text query
558
- q = embed_user_input("chest x-ray showing pleural effusion")
559
- print("query shape:", q.shape, " L2 norm:", np.linalg.norm(q))''')
560
-
561
- # 4.3 similarity
562
- replace_cell(64, '''\
563
- def similarity_scores(query_vec: np.ndarray) -> np.ndarray:
564
- """Cosine similarity between a single query vector and every catalog embedding."""
565
- # Embeddings are L2-normalised -> dot product == cosine similarity
566
- return EMB_MATRIX @ query_vec.astype("float32")''')
567
-
568
- replace_cell(65, '''\
569
- scores = similarity_scores(q)
570
- print("min/mean/max:", scores.min(), scores.mean(), scores.max())''')
571
-
572
- # 4.4 top-k
573
- replace_cell(67, '''\
574
- def top_k(user_input, k: int = 3):
575
- """Return the k catalog items most similar to `user_input` (text or PIL image)."""
576
- q = embed_user_input(user_input)
577
- scores = similarity_scores(q)
578
- idx = np.argsort(-scores)[:k]
579
- return [
580
- {
581
- "index" : int(i),
582
- "score" : float(scores[i]),
583
- "image" : THUMBS[i],
584
- "report" : df.loc[i, "report"],
585
- "cluster" : int(df.loc[i, "cluster"]),
586
- }
587
- for i in idx
588
- ]''')
589
-
590
- replace_cell(68, '''\
591
- # Demo: text query
592
- QUERY = "bilateral pleural effusion with cardiomegaly"
593
- results = top_k(QUERY, k=3)
594
-
595
- fig, axes = plt.subplots(1, 3, figsize=(12, 5))
596
- for ax, r in zip(axes, results):
597
- ax.imshow(r["image"], cmap="gray"); ax.set_axis_off()
598
- ax.set_title(f"score={r['score']:.3f}\\ncluster {r['cluster']}", fontsize=10)
599
- plt.suptitle(f'Top-3 X-rays for: "{QUERY}"')
600
- plt.tight_layout(); plt.show()
601
-
602
- print("Top match report excerpt:")
603
- print(results[0]["report"][:400] + ("..." if len(results[0]["report"]) > 400 else ""))''')
604
-
605
- # ============================================================
606
- # Part 5: Gradio app
607
- # ============================================================
608
-
609
- replace_cell(71, '''\
610
- import gradio as gr
611
-
612
- def gradio_recommend(text_query, image_query):
613
- """
614
- Either text or image can be filled in. If both are provided, the image
615
- takes precedence (use whichever signal the user actually gave us).
616
- Returns (gallery, details_text).
617
- """
618
- if image_query is not None:
619
- query = image_query
620
- elif text_query and text_query.strip():
621
- query = text_query.strip()
622
- else:
623
- return [], "Please type a description **or** upload an X-ray."
624
-
625
- results = top_k(query, k=3)
626
- gallery = [(r["image"], f"#{r['index']} - score {r['score']:.3f}") for r in results]
627
- details = "\\n\\n".join(
628
- f"### Match {n+1} (score {r['score']:.3f}, cluster {r['cluster']})\\n"
629
- f"{r['report'][:600]}{'...' if len(r['report']) > 600 else ''}"
630
- for n, r in enumerate(results)
631
- )
632
- return gallery, details
633
-
634
-
635
- DESCRIPTION = """
636
- # Chest X-ray Recommender - CLIP embeddings
637
-
638
- Upload a chest X-ray **or** describe a finding in words, and the app will
639
- retrieve the 3 most visually similar X-rays from a 2,000-image catalog
640
- drawn from `MLforHealthcare/mimic-cxr`.
641
-
642
- > Educational demo only. **Not** a medical device. Do not use for clinical decisions.
643
- """
644
-
645
- demo = gr.Interface(
646
- fn=gradio_recommend,
647
- inputs=[
648
- gr.Textbox(lines=2,
649
- label="Describe a finding (English)",
650
- placeholder='e.g. "bilateral pleural effusion with cardiomegaly"'),
651
- gr.Image(type="pil", label="...or upload an X-ray"),
652
- ],
653
- outputs=[
654
- gr.Gallery(label="Top-3 similar X-rays", columns=3, height=350),
655
- gr.Markdown(label="Matching radiology reports"),
656
- ],
657
- title="Chest X-ray Recommender",
658
- description=DESCRIPTION,
659
- examples=[
660
- ["bilateral pleural effusion with cardiomegaly", None],
661
- ["clear lungs, no acute cardiopulmonary process", None],
662
- ["right lower lobe pneumonia", None],
663
- ["pneumothorax", None],
664
- ],
665
- )''')
666
-
667
- replace_cell(72, '''\
668
- # Launch (set share=True if you want a public link from Colab)
669
- demo.launch(share=False, debug=False)''')
670
-
671
- # ============================================================
672
- # Part 5 notes (74)
673
- # ============================================================
674
- replace_cell(74, '''\
675
- # To deploy as a HuggingFace Space:
676
- #
677
- # 1. Create a new Space (SDK = Gradio) at https://huggingface.co/new-space
678
- # 2. Upload these files to the Space repo:
679
- # app.py (Gradio interface, same recommender as above)
680
- # requirements.txt (transformers, torch, datasets, gradio, ...)
681
- # embeddings.parquet (built in Part 3.4 of this notebook)
682
- # README.md (Space card + medical disclaimer)
683
- # 3. The Space will build automatically and host your demo.
684
- #
685
- # Files for the Space are bundled next to this notebook (app.py /
686
- # requirements.txt / README.md). Just upload the four files above.
687
- print("See app.py, requirements.txt, README.md in the same folder as this notebook.")''')
688
-
689
- # ============================================================
690
- # Part 6 / 7 stubs
691
- # ============================================================
692
- replace_cell(77, '''\
693
- # Replace the YouTube embed URL with the link to your own walk-through video,
694
- # then add this snippet to app.py so the Space page shows your presentation.
695
-
696
- VIDEO_EMBED_HTML = """
697
- <iframe width="720" height="405"
698
- src="https://www.youtube.com/embed/REPLACE_ME"
699
- title="Assignment 3 walk-through" frameborder="0"
700
- allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen>
701
- </iframe>
702
- """
703
- print(VIDEO_EMBED_HTML)''')
704
-
705
- replace_cell(82, '''\
706
- # Paste your real Space URL here once it's live
707
- SPACE_URL = "https://huggingface.co/spaces/<your-username>/chest-xray-recommender"
708
- print(SPACE_URL)''')
709
-
710
- # Cell 90 - HF token
711
- replace_cell(90, '''\
712
- # Set HF_TOKEN in Colab via:
713
- # from google.colab import userdata
714
- # os.environ["HF_TOKEN"] = userdata.get("HF_TOKEN")
715
- #
716
- # Or paste it directly (NOT recommended for shared notebooks):
717
- # os.environ["HF_TOKEN"] = "hf_..."
718
- #
719
- # This assignment doesn't strictly need an HF token to read the dataset
720
- # (it's public), but you'll need one to push your Space.
721
- if "HF_TOKEN" in os.environ:
722
- print("HF_TOKEN is set.")
723
- else:
724
- print("HF_TOKEN is NOT set. Set it before pushing to a Space.")''')
725
-
726
- # ============================================================
727
- # Save
728
- # ============================================================
729
- NB_PATH.write_text(json.dumps(nb, indent=1, ensure_ascii=False))
730
- print("OK - wrote", NB_PATH, "with", len(nb["cells"]), "cells")
731
-
732
- # Validate with nbformat
733
- try:
734
- import nbformat
735
- nb_validated = nbformat.read(str(NB_PATH), as_version=4)
736
- nbformat.validate(nb_validated)
737
- print("Notebook validates against nbformat v4")
738
- except ImportError:
739
- print("nbformat not installed; skipping validation step (notebook JSON is still well-formed)")