{
  "nbformat": 4,
  "nbformat_minor": 5,
  "metadata": {
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "name": "python",
      "version": "3.11"
    },
    "colab": {
      "name": "week-07-h3-exercise.ipynb"
    },
    "course_exercise": {
      "id": "W07-H3",
      "week": "07",
      "hour": 3,
      "date": "2026-10-19",
      "title": "Trace and test a tiny causal Transformer",
      "minutes": 25,
      "optional": false,
      "worksheet": "notes/exercises/week-07-h3-exercise.html",
      "notebook": "notebooks/exercises/week-07-h3-exercise.ipynb",
      "colab": "https://colab.research.google.com/github/CHjonnyYu/MLES-Fall-2026/blob/main/notebooks/exercises/week-07-h3-exercise.ipynb",
      "guided_notebook": "notebooks/week-07-h3-transformer.ipynb",
      "execution_status": "New exercise extension not executed in this authoring run"
    }
  },
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "# W07-H3 Exercise: Trace and test a tiny causal Transformer\n\nMachine Learning in Engineering Science | NCKU / LAiMM | Fall 2026\n\n**Date:** 2026-10-19. **Exercise time:** 25 minutes within the class/practice allocation, not extra time after class.\n\n**Classroom formative practice. No new weekly graded submission is created.**\n\n## How to work independently\n1. In Colab, save a copy to your Drive and use a CPU runtime. In Jupyter, open this notebook and select a Python kernel.\n2. Run the complete worked walkthrough below from top to bottom. Read every Markdown checkpoint; the walkthrough is included so no previous notebook state is needed.\n3. At the Independent Exercise section, write each prediction in the response cell before running its experiment.\n4. Run the starter experiment, make the requested controlled change, and record the actual result. Edit only the declared factor; preserve split rules and preprocessing.\n5. Explain one mechanism and one limitation. Export the evidence JSON and discuss it with a partner.\n\nThe core exercises need no paid API, private dataset, or GPU. The agent units use clearly labeled offline simulators, not real LLM calls.\n\n**Reproducibility boundary:** the original guided notebooks were previously executed; the new extension cells have not yet been execution-tested in this revision. Report unexpected behavior rather than claiming a check passed.\n"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "# W07 H3: Build and Inspect a Tiny Transformer\n\nMachine Learning in Engineering Science | NCKU + LAiMM | Fall 2026\n\n**Teaching date:** Monday 19 October 2026. **Guided notebook time:** 40 minutes within a 60-minute module.\n\n### What you will be able to explain\n- Trace embeddings, positional information, masked attention, and a feed-forward block.\n- Train a tiny decoder on synthetic sequences with shifted targets.\n- Test causality and permutation behavior, and document the limits of the experiment.\n\n### Start here, even if this is your first notebook\n1. In Colab choose **Runtime > Change runtime type > CPU**. In Jupyter choose a Python 3 kernel.\n2. Run cells from top to bottom with **Shift+Enter**. Read the Markdown before each code cell.\n3. The setup installs only missing free packages. A network connection is needed for first installation; the data itself is generated locally.\n4. If a variable is undefined, restart the kernel and run all earlier cells. Do not skip a setup cell.\n5. Save a personal copy before changing parameters. Every core cell is complete: there are no hidden files, tokens, paid services, or unfinished code blocks.\n\n**Data:** synthetic teaching data, not engineering measurements. Record your actual output; a model is not required to outperform a baseline.\n**Practice policy:** in-class exercise and optional Homework extension, not a new weekly graded assignment.\n",
      "id": "cell-000"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## Mechanism reference: our complete decoder block\n\nToken IDs index learned vectors. Add a learned position vector, split projected queries/keys/values into heads, compute causal attention, and concatenate/project heads back to model width. Our pre-normalization block is\n\n$$u=x+\\operatorname{Attention}(\\operatorname{LayerNorm}(x)),$$\n$$y=u+\\operatorname{FFN}(\\operatorname{LayerNorm}(u)).$$\n\nThe feed-forward network expands width 32 to 128, applies GELU, and returns to 32. It uses shared parameters independently at each position. Attention mixes positions; the MLP transforms local features. Residual additions provide an identity route and require matching widths.\n\nThis decoder-only teaching variant differs from the original 2017 encoder-decoder with post-normalization and ReLU. Cross-entropy receives raw logits and shifted targets. Training processes known positions in parallel under a mask; generation appends unknown next tokens sequentially.\n\n",
      "id": "cell-001"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 1. Set up a CPU experiment\n\nRun independently of H1 and H2. This toy decoder is an educational implementation, not a pretrained LLM.\n",
      "id": "cell-002"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "import sys, subprocess, importlib.util, platform, json, random, hashlib\nfrom pathlib import Path\nrequired = {\"numpy\": \"numpy\", \"matplotlib\": \"matplotlib\", \"sklearn\": \"scikit-learn\"}\nmissing = [package for module, package in required.items() if importlib.util.find_spec(module) is None]\nif missing:\n    subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", *missing])\nimport numpy as np\nimport matplotlib.pyplot as plt\nSEED = 2026\nrandom.seed(SEED)\nnp.random.seed(SEED)\nrng = np.random.default_rng(SEED)\nARTIFACTS = Path(\"artifacts\")\nARTIFACTS.mkdir(exist_ok=True)\nprint(\"Python:\", platform.python_version(), \"NumPy:\", np.__version__, \"Seed:\", SEED)\n\nif importlib.util.find_spec(\"torch\") is None:\n    subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"torch\"])\nimport torch\nfrom torch import nn\nimport copy, time\ntorch.set_num_threads(1)\ntorch.manual_seed(SEED)\ntorch.use_deterministic_algorithms(True)\ndevice = torch.device(\"cpu\")\nprint(\"PyTorch:\", torch.__version__, \"Device:\", device)\n",
      "id": "cell-003"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** CPU is sufficient. Generation is not evidence of factual knowledge.\n",
      "id": "cell-004"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 2. Define a reproducible symbolic sequence task\n\nWe use a small repeating process with eight symbolic states. Training, validation, and test are separate generated streams with different seeds and small random perturbations. The target at each position is the next token; inputs never include that target position. No natural-language corpus is downloaded.\n",
      "id": "cell-005"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "VOCAB, CONTEXT = 8, 16\ndef make_stream(seed,n=1800):\n    generator=np.random.default_rng(seed)\n    motif=np.array([0,1,2,3,4,5,6,7,6,5,4,3,2,1],dtype=np.int64)\n    stream=np.resize(motif,n).copy()\n    corrupt=generator.random(n)<0.04\n    stream[corrupt]=generator.integers(0,VOCAB,corrupt.sum())\n    return torch.tensor(stream,dtype=torch.long)\ntrain_stream=make_stream(SEED)\nval_stream=make_stream(SEED+1,600)\ntest_stream=make_stream(SEED+2,600)\ndef windows(stream):\n    # Nonoverlapping blocks here keep sample accounting easy to inspect.\n    starts=range(0,len(stream)-CONTEXT,CONTEXT)\n    return (torch.stack([stream[i:i+CONTEXT] for i in starts]),\n            torch.stack([stream[i+1:i+CONTEXT+1] for i in starts]))\ntrain_x,train_y=windows(train_stream)\nval_x,val_y=windows(val_stream)\ntest_x,test_y=windows(test_stream)\nprint(\"Example input:\",train_x[0].tolist())\nprint(\"Shifted target:\",train_y[0].tolist())\nassert torch.equal(train_x[0,1:],train_y[0,:-1])\n",
      "id": "cell-006"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** The final target token lies just beyond the input window. Do not report performance as generalization to a different physical process: all streams share the same motif.\n",
      "id": "cell-007"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 3. Write attention with explicit axes and masks\n\nRepresentations have shape (B,T,D). Split D into H heads of width d=D/H. Attention scores have shape (B,H,T,T), with query positions on rows and key positions on columns. Store weights for inspection, but detach them so diagnostic storage does not retain training graphs.\n",
      "id": "cell-008"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "class CausalAttention(nn.Module):\n    def __init__(self,d_model=32,heads=4):\n        super().__init__()\n        assert d_model%heads==0\n        self.heads,self.d_head=heads,d_model//heads\n        self.qkv=nn.Linear(d_model,3*d_model)\n        self.proj=nn.Linear(d_model,d_model)\n        self.last_weights=None\n    def forward(self,x):\n        B,T,D=x.shape\n        q,k,v=self.qkv(x).chunk(3,dim=-1)\n        def split(z): return z.reshape(B,T,self.heads,self.d_head).transpose(1,2)\n        q,k,v=map(split,(q,k,v))\n        scores=q@k.transpose(-2,-1)/(self.d_head**0.5)\n        allowed=torch.ones(T,T,dtype=torch.bool,device=x.device).tril()\n        weights=scores.masked_fill(~allowed,float(\"-inf\")).softmax(dim=-1)\n        self.last_weights=weights.detach()\n        mixed=(weights@v).transpose(1,2).contiguous().reshape(B,T,D)\n        return self.proj(mixed)\n",
      "id": "cell-009"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** The softmax dimension is the last dimension: source keys. Upper-triangular weights must be exactly zero.\n",
      "id": "cell-010"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 4. Complete the block and output head\n\nThis is a **pre-normalization decoder-only** block, a common educational variant. The 2017 paper used a full encoder-decoder with post-normalization. Residual paths preserve an identity route; the feed-forward network transforms each position separately with shared weights.\n",
      "id": "cell-011"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "class Block(nn.Module):\n    def __init__(self,d=32,heads=4):\n        super().__init__()\n        self.ln1,self.ln2=nn.LayerNorm(d),nn.LayerNorm(d)\n        self.attn=CausalAttention(d,heads)\n        self.ff=nn.Sequential(nn.Linear(d,4*d),nn.GELU(),nn.Linear(4*d,d))\n    def forward(self,x):\n        x=x+self.attn(self.ln1(x))\n        return x+self.ff(self.ln2(x))\n\nclass TinyTransformer(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.tokens=nn.Embedding(VOCAB,32)\n        self.positions=nn.Embedding(CONTEXT,32)\n        self.block=Block()\n        self.norm=nn.LayerNorm(32)\n        self.head=nn.Linear(32,VOCAB)\n    def forward(self,ids,use_positions=True):\n        B,T=ids.shape\n        assert T<=CONTEXT\n        x=self.tokens(ids)\n        if use_positions: x=x+self.positions(torch.arange(T,device=ids.device))[None,:,:]\n        return self.head(self.norm(self.block(x)))\n\ntorch.manual_seed(SEED)\nmodel=TinyTransformer().to(device)\nprint(\"Parameters:\",sum(p.numel() for p in model.parameters()))\nassert model(train_x[:2]).shape==(2,CONTEXT,VOCAB)\n",
      "id": "cell-012"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** The model returns logits (B,T,V), not token IDs or probabilities. The vocabulary size is eight, not the embedding width 32.\n",
      "id": "cell-013"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 5. Establish a training-only bigram baseline\n\nA bigram baseline estimates P(next|current) from training transitions. Add-one smoothing avoids zero probabilities. Our motif contains ambiguous next states, so longer context may help. Compare NLL in nats/token on identical test targets.\n",
      "id": "cell-014"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "counts=np.ones((VOCAB,VOCAB),dtype=np.float64)\nfor a,b in zip(train_stream[:-1],train_stream[1:]): counts[int(a),int(b)]+=1\nbigram=counts/counts.sum(axis=1,keepdims=True)\ndef bigram_nll(x,y): return float(-np.log(bigram[x.numpy(),y.numpy()]).mean())\nprint(\"Uniform baseline NLL:\",np.log(VOCAB))\nprint(\"Bigram validation NLL:\",bigram_nll(val_x,val_y))\n",
      "id": "cell-015"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** A uniform eight-token model has NLL log(8), about 2.079. This is a mathematical reference, not a measured model score.\n",
      "id": "cell-016"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 6. Train with shifted-target cross-entropy\n\nAll positions train in parallel, but the causal mask prevents future access. Flatten (B,T,V) into (B*T,V) for cross-entropy. Select the best state using validation NLL and inspect test only after selection.\n",
      "id": "cell-017"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "criterion=nn.CrossEntropyLoss()\noptimizer=torch.optim.AdamW(model.parameters(),lr=0.003,weight_decay=0.01)\nbest,best_state=float(\"inf\"),None\ncurve=[]\nstart=time.perf_counter()\nfor epoch in range(45):\n    model.train()\n    order=torch.randperm(len(train_x))\n    losses=[]\n    for batch in order.split(16):\n        optimizer.zero_grad()\n        logits=model(train_x[batch])\n        loss=criterion(logits.reshape(-1,VOCAB),train_y[batch].reshape(-1))\n        loss.backward(); nn.utils.clip_grad_norm_(model.parameters(),1.0); optimizer.step()\n        losses.append(loss.item())\n    model.eval()\n    with torch.no_grad():\n        val_loss=criterion(model(val_x).reshape(-1,VOCAB),val_y.reshape(-1)).item()\n    curve.append((float(np.mean(losses)),val_loss))\n    if val_loss<best: best,best_state=val_loss,copy.deepcopy(model.state_dict())\nmodel.load_state_dict(best_state)\nprint(\"Training seconds:\",round(time.perf_counter()-start,2))\nplt.plot(curve); plt.legend([\"train NLL\",\"validation NLL\"])\nplt.xlabel(\"Epoch\"); plt.ylabel(\"Nats/token\"); plt.show()\n",
      "id": "cell-018"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** The validation curve determines the retained checkpoint. If you change architecture, do not tune against test NLL.\n",
      "id": "cell-019"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 7. Evaluate and inspect a head\n\nNLL measures probability assigned to true next tokens. Accuracy measures the fraction of argmax choices that are correct. Inspect a real trained attention matrix and state what its axes mean.\n",
      "id": "cell-020"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "model.eval()\nwith torch.no_grad():\n    logits=model(test_x)\n    test_nll=criterion(logits.reshape(-1,VOCAB),test_y.reshape(-1)).item()\n    accuracy=(logits.argmax(-1)==test_y).float().mean().item()\nprint({\"bigram_test_nll\":bigram_nll(test_x,test_y),\"transformer_test_nll\":test_nll,\n       \"transformer_test_accuracy\":accuracy})\nhead=model.block.attn.last_weights[0,0].numpy()\nassert np.all(head[np.triu_indices(CONTEXT,1)]==0)\nplt.figure(figsize=(6,5)); plt.imshow(head,cmap=\"YlGnBu\",vmin=0,vmax=1)\nplt.xlabel(\"Key/source position\"); plt.ylabel(\"Query/destination position\")\nplt.title(\"Actual trained weights: test example 0, head 0\"); plt.colorbar(); plt.show()\n",
      "id": "cell-021"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** The heatmap is an actual run output. The zero upper triangle is a mask property, not a relationship the model discovered.\n",
      "id": "cell-022"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 8. Perform a future-token intervention\n\nChange only tokens at positions >=8. Under causal attention, logits at positions 0 through 7 must remain unchanged because the changed tokens lie in their future. This checks architecture behavior even if model accuracy is poor.\n",
      "id": "cell-023"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "original=test_x[:1].clone()\nchanged=original.clone(); changed[:,8:]=(changed[:,8:]+3)%VOCAB\nwith torch.no_grad():\n    old_logits=model(original)\n    new_logits=model(changed)\nprefix_error=(old_logits[:,:8]-new_logits[:,:8]).abs().max().item()\nprint(\"Maximum earlier-logit change:\",prefix_error)\nassert torch.allclose(old_logits[:,:8],new_logits[:,:8],atol=1e-6)\n",
      "id": "cell-024"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** A passing test supports the no-future-access property for this implementation and example. It does not validate the dataset or guarantee every possible deployment behavior.\n",
      "id": "cell-025"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 9. Show why unmasked attention needs position information\n\nFor unmasked self-attention without positions, permuting token rows permutes output rows in the same way. We test the isolated mathematical operation. A causal mask itself introduces an order constraint, so do not apply this permutation claim unchanged to a masked decoder.\n",
      "id": "cell-026"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "z=torch.tensor([[1.,0.],[0.,1.],[1.,1.]])\np=torch.tensor([2,0,1])\ndef unmasked_attention(z): return (z@z.T/(z.shape[-1]**0.5)).softmax(-1)@z\nassert torch.allclose(unmasked_attention(z[p]),unmasked_attention(z)[p],atol=1e-6)\nprint(\"Unmasked attention is permutation-equivariant without positions.\")\nwith torch.no_grad():\n    no_position_nll=criterion(model(test_x,use_positions=False).reshape(-1,VOCAB),test_y.reshape(-1)).item()\nprint(\"Remove learned positions at inference NLL:\",no_position_nll)\n",
      "id": "cell-027"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** Removing positions from a trained model is an inference intervention, not a fair retraining comparison. It may worsen, improve, or barely change this toy task; report the observed result.\n",
      "id": "cell-028"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 10. Generate sequentially from a prefix\n\nTraining processes many known target positions together. Autoregressive generation still produces one new token at a time and feeds it back. Cropping retains only CONTEXT tokens; learned absolute positions restart in each cropped window in this teaching implementation.\n",
      "id": "cell-029"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "outputs": [],
      "metadata": {},
      "source": "generated=test_x[:1,:4].clone()\nfor _ in range(20):\n    with torch.no_grad():\n        next_logits=model(generated[:,-CONTEXT:])[:,-1,:]\n        next_id=next_logits.argmax(-1,keepdim=True)\n    generated=torch.cat([generated,next_id],dim=1)\nprint(\"Generated symbolic sequence:\",generated[0].tolist())\nrecord={\"seed\":SEED,\"vocab\":VOCAB,\"context\":CONTEXT,\"torch\":torch.__version__,\n        \"architecture\":\"one-block pre-norm decoder; 4 heads; width 32; learned positions\",\n        \"training_data_sha256\":hashlib.sha256(train_stream.numpy().tobytes()).hexdigest(),\n        \"validation_nll\":best,\"test_nll\":test_nll,\"test_accuracy\":accuracy,\n        \"bigram_test_nll\":bigram_nll(test_x,test_y),\"causal_prefix_max_error\":prefix_error,\n        \"position_removal_nll\":no_position_nll,\"generated_tokens\":generated[0].tolist()}\n(ARTIFACTS/\"w07-h3-transformer.json\").write_text(json.dumps(record,indent=2))\ntorch.save(model.state_dict(),ARTIFACTS/\"w07-h3-transformer.pt\")\n",
      "id": "cell-030"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "**Checkpoint.** These symbols demonstrate sequence modeling only. A token generator does not automatically have tools, memory management, permissions, or verification required by an agent.\n",
      "id": "cell-031"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## 11. Independent experiment and exit ticket\nChoose **one** intervention: change heads from 4 to 2 in Block; change CONTEXT from 16 to 8; or change the corruption rate from 0.04 to 0.10. Make a prediction, rerun from data/model creation, and compare validation NLL before observing test results.\n\n**Explain back:**\n1. Where are the learned parameters in the block? Which weights are computed dynamically for each input?\n2. Why does parallel training not imply parallel generation of unknown next tokens?\n3. What does O(T^2) refer to in dense attention? At T=16, scores per head are 256; doubling T produces 1024.\n4. Why is an attention heatmap not a causal explanation? Name one controlled intervention you performed.\n5. Why is this small decoder not an LLM or a complete agent?\n\n**Stop when:** you have baseline and Transformer NLL, a trained head plot, a passing causal test, and an evidence JSON.\n\n### Sources\n- [Vaswani et al., Attention Is All You Need](https://arxiv.org/abs/1706.03762)\n- [Dive into Deep Learning: Transformer architecture](https://d2l.ai/chapter_attention-mechanisms-and-transformers/transformer.html)\n- [Karpathy, ng-video-lecture](https://github.com/karpathy/ng-video-lecture)\n",
      "id": "cell-032"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "## Independent Exercise: W07-H3\n\nComplete the three tasks below after the walkthrough. The starter cells produce observations, not your written interpretation. Fill the response fields in the next cell. Save separate records for the original run and any controlled change."
    },
    {
      "cell_type": "code",
      "metadata": {},
      "execution_count": null,
      "outputs": [],
      "source": "exercise_id=\"W07-H3\"\nobservations={}\nresponses={\n    'task1_prediction':'', 'task1_explanation':'',\n    'task2_prediction':'', 'task2_explanation':'',\n    'task3_prediction':'', 'task3_explanation':'',\n    'controlled_change':'', 'limitation':'', 'ai_use_and_checks':''\n}\n# Fill these strings before and after each experiment. Use 'none' if no AI assistance was used.\nprint('Exercise record initialized:', exercise_id)"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "### Task 1: Trace model shapes\n\nAfter the guided training run, inspect a two-example batch. Explain the final axis and why logits are not probabilities.\n\n**Steps**\n1. Write your prediction in `responses['task1_prediction']` before running.\n2. Run the starter cell; read every printed quantity and retain the observation.\n3. Make the controlled comparison requested above. Record its configuration in `responses['controlled_change']`.\n4. Write a mechanism-based explanation in `responses['task1_explanation']`; do not merely repeat a score.\n\n**Checkpoint:** Input shape is (2,16) and logits have shape (2,16,8). Each position predicts a distribution over the next token."
    },
    {
      "cell_type": "code",
      "metadata": {},
      "execution_count": null,
      "outputs": [],
      "source": "model.eval()\nwith torch.no_grad():\n    ex_logits=model(test_x[:2])\nobservations['task1']={'input_shape':list(test_x[:2].shape),'logit_shape':list(ex_logits.shape),'vocabulary_size':VOCAB}\nprint(observations['task1'])"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "### Task 2: Change future positions only\n\nKeep positions 0 through 7 fixed. Change positions 8 through 15 using modulo VOCAB, then compare earlier logits. Explain why model.eval() matters.\n\n**Steps**\n1. Write your prediction in `responses['task2_prediction']` before running.\n2. Run the starter cell; read every printed quantity and retain the observation.\n3. Make the controlled comparison requested above. Record its configuration in `responses['controlled_change']`.\n4. Write a mechanism-based explanation in `responses['task2_explanation']`; do not merely repeat a score.\n\n**Checkpoint:** The earlier logits should agree within the declared tolerance. Token values remain in 0 through 7. This is a behavioral causality check, not a model-accuracy result."
    },
    {
      "cell_type": "code",
      "metadata": {},
      "execution_count": null,
      "outputs": [],
      "source": "original_ex=test_x[:1].clone(); changed_ex=original_ex.clone()\nchanged_ex[:,8:]=(changed_ex[:,8:]+3)%VOCAB\nwith torch.no_grad():\n    original_logits=model(original_ex); changed_logits=model(changed_ex)\ndelta_ex=float((original_logits[:,:8]-changed_logits[:,:8]).abs().max())\nobservations['task2']={'unchanged_positions':list(range(8)),'changed_positions':list(range(8,16)),'max_earlier_logit_difference':delta_ex,'passes_1e_minus_6':delta_ex<1e-6}\nprint(observations['task2'])"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "### Task 3: Explain sequential generation\n\nGenerate five new symbols using greedy selection. Explain why training can score many positions at once while this generation loop is sequential.\n\n**Steps**\n1. Write your prediction in `responses['task3_prediction']` before running.\n2. Run the starter cell; read every printed quantity and retain the observation.\n3. Make the controlled comparison requested above. Record its configuration in `responses['controlled_change']`.\n4. Write a mechanism-based explanation in `responses['task3_explanation']`; do not merely repeat a score.\n\n**Checkpoint:** Each generated symbol becomes part of the next input. This tiny synthetic model does not demonstrate natural-language understanding or autonomous tool use."
    },
    {
      "cell_type": "code",
      "metadata": {},
      "execution_count": null,
      "outputs": [],
      "source": "prefix_ex=test_x[:1,:4].clone()\nfor _ in range(5):\n    with torch.no_grad():\n        next_ex=model(prefix_ex[:,-CONTEXT:])[:,-1].argmax(dim=-1,keepdim=True)\n    prefix_ex=torch.cat([prefix_ex,next_ex],dim=1)\nobservations['task3']={'generated_ids':prefix_ex[0].tolist(),'new_symbols':5,'decoding':'greedy'}\nprint(observations['task3'])"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": "### Finish: preserve and explain\n\nFill all response strings, then run the export cell. A draft file is intentionally labeled incomplete; a complete response record is not automatically a scientifically correct result. Download the JSON before the Colab session ends. Share it with a partner for explain-back, not as an additional weekly submission.\n\n**Exit questions:** What changed? Which observation supports your explanation? What does this experiment not establish?"
    },
    {
      "cell_type": "code",
      "metadata": {},
      "execution_count": null,
      "outputs": [],
      "source": "missing_responses=[key for key,value in responses.items() if not str(value).strip()]\nexercise_record={'exercise':exercise_id,'status':'draft' if missing_responses else 'ready for peer discussion','responses':responses,'observations':observations,'missing_responses':missing_responses,'environment':{'python':platform.python_version(),'numpy':np.__version__},'data_scope':'course-provided or synthetic teaching data'}\nexercise_folder=Path('artifacts')/'exercises'; exercise_folder.mkdir(parents=True,exist_ok=True)\nexercise_path=exercise_folder/(exercise_id+'-evidence.json')\nexercise_path.write_text(json.dumps(exercise_record,indent=2,default=lambda value:value.tolist() if hasattr(value,'tolist') else str(value)))\nprint('Saved:',exercise_path)\nprint('Status:',exercise_record['status'])\nprint('Fields still to complete:',missing_responses)"
    }
  ]
}
