> ## Documentation Index
> Fetch the complete documentation index at: https://aitutorial.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Linear Classifiers on MNIST

> Build intuition with the simplest possible image classifier before moving to CNNs

export const QuizQuestion = ({question, options, answer, explanation}) => {
  const [selected, setSelected] = useState(null);
  const [revealed, setRevealed] = useState(false);
  const handleSelect = index => {
    if (revealed) return;
    setSelected(index);
    setRevealed(true);
  };
  const isCorrect = selected === answer;
  const getOptionClass = i => {
    const classes = ['quiz-option'];
    if (revealed) {
      classes.push('quiz-option-disabled');
      if (i === answer) classes.push('quiz-option-correct'); else if (i === selected && !isCorrect) classes.push('quiz-option-wrong');
    }
    return classes.join(' ');
  };
  return <div className="quiz-card">
      <p className="quiz-question">{question}</p>
      <div className="quiz-options">
        {options.map((option, i) => <button key={i} onClick={() => handleSelect(i)} className={getOptionClass(i)}>
            <span className="quiz-letter">{String.fromCharCode(65 + i)}</span>
            {option}
          </button>)}
      </div>
      {revealed && <div className={`quiz-feedback ${isCorrect ? 'quiz-feedback-correct' : 'quiz-feedback-wrong'}`}>
          <strong>{isCorrect ? 'Correct!' : 'Incorrect.'}</strong> {explanation}
        </div>}
    </div>;
};

export const Quiz = ({title = "Check Your Understanding", children}) => {
  return <div style={{
    marginTop: '24px'
  }}>
      <div className="quiz-title">{title}</div>
      {children}
    </div>;
};

export const CodeEditor = ({file = 'src/hello_world.ts', lines, title = 'Code Example', repo = 'ai-tutorial/typescript-examples', height = '650px', functionName, theme: userTheme}) => {
  const STORAGE_KEY = 'openai_api_key';
  const GEMINI_STORAGE_KEY = 'gemini_api_key';
  const ANTHROPIC_STORAGE_KEY = 'anthropic_api_key';
  const PROVIDER_STORAGE_KEY = 'llm_playground_provider';
  if (!functionName) {
    console.warn('CodeEditor: functionName parameter is required');
  }
  const hasCreatedEnvRef = useRef(false);
  const vmRef = useRef(null);
  const [isMaximized, setIsMaximized] = useState(false);
  const [isCollapsed, setIsCollapsed] = useState(false);
  const [isStuck, setIsStuck] = useState(false);
  const [iframeKey, setIframeKey] = useState(0);
  const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
  const [apiKey, setApiKey] = useState('');
  const [error, setError] = useState('');
  const [success, setSuccess] = useState(false);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isValidating, setIsValidating] = useState(false);
  const [detectedTheme, setDetectedTheme] = useState('dark');
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const checkTheme = () => {
      const isDark = document.documentElement.classList.contains('dark');
      setDetectedTheme(isDark ? 'dark' : 'light');
    };
    checkTheme();
    const observer = new MutationObserver(checkTheme);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['class']
    });
    return () => observer.disconnect();
  }, []);
  const theme = userTheme || detectedTheme;
  const [selectedProvider, setSelectedProvider] = useState(() => {
    if (typeof window === 'undefined') return 'gemini';
    return localStorage.getItem(PROVIDER_STORAGE_KEY) || 'gemini';
  });
  const isApiKeyConfigured = () => {
    const openaiKey = localStorage.getItem(STORAGE_KEY);
    const geminiKey = localStorage.getItem(GEMINI_STORAGE_KEY);
    const anthropicKey = localStorage.getItem(ANTHROPIC_STORAGE_KEY);
    return openaiKey !== null && openaiKey.trim().length > 0 || geminiKey !== null && geminiKey.trim().length > 0 || anthropicKey !== null && anthropicKey.trim().length > 0;
  };
  const dispatchApiKeyChanged = () => {
    if (typeof window !== 'undefined' && window.dispatchEvent) {
      window.dispatchEvent(new CustomEvent('apiKeyChanged', {
        detail: {
          configured: isApiKeyConfigured()
        }
      }));
    }
  };
  const saveApiKey = apiKey => {
    if (apiKey && apiKey.trim()) {
      const trimmedKey = apiKey.trim();
      localStorage.setItem(STORAGE_KEY, trimmedKey);
      dispatchApiKeyChanged();
      return true;
    }
    return false;
  };
  const buildEnvContent = () => {
    const openaiKey = localStorage.getItem(STORAGE_KEY)?.trim();
    const geminiKey = localStorage.getItem(GEMINI_STORAGE_KEY)?.trim();
    const anthropicKey = localStorage.getItem(ANTHROPIC_STORAGE_KEY)?.trim();
    if (!openaiKey && !geminiKey && !anthropicKey) {
      return `OPENAI_MODEL=gpt-4.1-nano
OPENAI_API_KEY=sk-mock-key-1234567890abcdef
GEMINI_MODEL=gemini-2.5-flash-lite
GOOGLE_GENERATIVE_AI_API_KEY=
GOOGLE_API_KEY=
ANTHROPIC_API_KEY=
AI_PROVIDER=openai
# API key not found in browser storage
# To configure your API key:
# 1. For Gemini (free): Go to https://aistudio.google.com/apikey
# 2. For OpenAI: Go to https://platform.openai.com/api-keys
# 3. For Claude: Go to https://console.anthropic.com/settings/keys
# 4. Enter it in the configuration form above this editor
# 5. The .env file will be automatically updated with your key`;
    }
    const envLines = ['# Using the API key(s) you configured. This file will be created when the dialog is loaded.'];
    if (openaiKey) {
      envLines.push(`OPENAI_MODEL=gpt-4.1-nano`);
      envLines.push(`OPENAI_API_KEY=${openaiKey}`);
    }
    if (geminiKey) {
      envLines.push(`GEMINI_MODEL=gemini-2.5-flash-lite`);
      envLines.push(`# Vercel AI SDK uses GOOGLE_GENERATIVE_AI_API_KEY, LangChain uses GOOGLE_API_KEY`);
      envLines.push(`GOOGLE_GENERATIVE_AI_API_KEY=${geminiKey}`);
      envLines.push(`GOOGLE_API_KEY=${geminiKey}`);
    }
    if (anthropicKey) {
      envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
    }
    const provider = anthropicKey ? 'anthropic' : geminiKey ? 'gemini' : 'openai';
    envLines.push(`AI_PROVIDER=${provider}`);
    return envLines.join('\n');
  };
  const updateEnvFile = async vm => {
    if (!vm) return;
    try {
      await vm.applyFsDiff({
        create: {
          'env/.env': buildEnvContent(),
          'env/run.conf': `file=${file}`
        },
        destroy: []
      });
      hasCreatedEnvRef.current = true;
    } catch (error) {
      console.error('Failed to write env files:', error);
      hasCreatedEnvRef.current = false;
    }
  };
  useEffect(() => {
    if (!isApiKeyConfigured()) {
      setShowApiKeyDialog(true);
    }
    const handleApiKeyChanged = () => {
      if (isApiKeyConfigured()) {
        setShowApiKeyDialog(false);
      }
    };
    if (typeof window !== 'undefined') {
      window.addEventListener('apiKeyChanged', handleApiKeyChanged);
      return () => {
        window.removeEventListener('apiKeyChanged', handleApiKeyChanged);
      };
    }
  }, []);
  const validateApiKey = async (key, provider) => {
    try {
      const urls = {
        gemini: 'https://generativelanguage.googleapis.com/v1beta/models?key=' + encodeURIComponent(key.trim()),
        openai: 'https://api.openai.com/v1/models',
        anthropic: 'https://api.anthropic.com/v1/models'
      };
      const headerMap = {
        gemini: {
          'Content-Type': 'application/json'
        },
        openai: {
          'Authorization': `Bearer ${key.trim()}`,
          'Content-Type': 'application/json'
        },
        anthropic: {
          'x-api-key': key.trim(),
          'anthropic-version': '2023-06-01',
          'Content-Type': 'application/json'
        }
      };
      const url = urls[provider];
      const headers = headerMap[provider];
      const response = await fetch(url, {
        method: 'GET',
        headers
      });
      if (response.ok) {
        return {
          valid: true
        };
      } else if (response.status === 401 || response.status === 403) {
        return {
          valid: false,
          error: 'Invalid API key. Please check your key and try again.'
        };
      } else if (response.status === 429) {
        return {
          valid: false,
          error: 'Rate limit exceeded. Please try again later.'
        };
      } else {
        const errorData = await response.json().catch(() => ({}));
        return {
          valid: false,
          error: errorData.error?.message || `API request failed with status ${response.status}`
        };
      }
    } catch (err) {
      if (err.name === 'TypeError' && err.message.includes('fetch')) {
        return {
          valid: false,
          error: 'Network error. Please check your connection and try again.'
        };
      }
      return {
        valid: false,
        error: err.message || 'Failed to validate API key. Please try again.'
      };
    }
  };
  const handleSkipConfiguration = () => {
    const skipKey = 'sk-<configure-your-key>';
    saveApiKey(skipKey);
    setShowApiKeyDialog(false);
  };
  const handleApiKeySubmit = async e => {
    e.preventDefault();
    setError('');
    setSuccess(false);
    setIsSubmitting(true);
    const providerNames = {
      gemini: 'Gemini',
      openai: 'OpenAI',
      anthropic: 'Claude'
    };
    if (!apiKey || !apiKey.trim()) {
      setError(`Please enter your ${providerNames[selectedProvider]} API key`);
      setIsSubmitting(false);
      return;
    }
    const trimmedKey = apiKey.trim();
    if (selectedProvider === 'openai' && !trimmedKey.startsWith('sk-')) {
      setError('Invalid API key format. OpenAI API keys should start with "sk-"');
      setIsSubmitting(false);
      return;
    }
    if (selectedProvider === 'anthropic' && !trimmedKey.startsWith('sk-ant-')) {
      setError('Invalid API key format. Anthropic API keys should start with "sk-ant-"');
      setIsSubmitting(false);
      return;
    }
    setIsValidating(true);
    setError('');
    const validation = await validateApiKey(trimmedKey, selectedProvider);
    setIsValidating(false);
    if (!validation.valid) {
      setError(validation.error || 'Invalid API key. Please check your key and try again.');
      setIsSubmitting(false);
      return;
    }
    try {
      const storageKeys = {
        gemini: GEMINI_STORAGE_KEY,
        openai: STORAGE_KEY,
        anthropic: ANTHROPIC_STORAGE_KEY
      };
      localStorage.setItem(storageKeys[selectedProvider], trimmedKey);
      localStorage.setItem(PROVIDER_STORAGE_KEY, selectedProvider);
      dispatchApiKeyChanged();
      setSuccess(true);
      setApiKey('');
      setTimeout(() => {
        window.location.reload();
      }, 1000);
    } catch (err) {
      setError(err.message || 'Failed to save API key. Please try again.');
      setIsSubmitting(false);
    }
  };
  const baseFilePath = file || 'src/hello_world.ts';
  let filePath = baseFilePath;
  if (typeof lines === 'string' && lines.trim()) {
    const lineParts = lines.split('-');
    if (lineParts.length === 2) {
      filePath = `${filePath}:L${lineParts[0].trim()}-L${lineParts[1].trim()}`;
    } else {
      filePath = `${filePath}:L${lineParts[0].trim()}`;
    }
  } else if (typeof lines === 'object' && lines.start !== undefined) {
    filePath = lines.end !== undefined ? `${filePath}:L${lines.start}-L${lines.end}` : `${filePath}:L${lines.start}`;
  }
  const stackblitzUrl = `https://stackblitz.com/github/${repo}?file=${encodeURIComponent(filePath)}&embed=1&view=editor&theme=${theme}`;
  const loadSDK = () => {
    return new Promise((resolve, reject) => {
      if (window.StackBlitzSDK || window.stackblitzSDK) {
        resolve(window.StackBlitzSDK || window.stackblitzSDK);
        return;
      }
      if (document.querySelector('script[data-stackblitz-sdk]')) {
        const checkInterval = setInterval(() => {
          if (window.StackBlitzSDK || window.stackblitzSDK) {
            clearInterval(checkInterval);
            resolve(window.StackBlitzSDK || window.stackblitzSDK);
          }
        }, 100);
        setTimeout(() => {
          clearInterval(checkInterval);
          reject(new Error('SDK loading timeout'));
        }, 10000);
        return;
      }
      const script = document.createElement('script');
      script.src = 'https://unpkg.com/@stackblitz/sdk/bundles/sdk.umd.js';
      script.async = true;
      script.setAttribute('data-stackblitz-sdk', 'true');
      script.onload = () => {
        const sdk = window.StackBlitzSDK || window.stackblitzSDK;
        if (sdk) {
          resolve(sdk);
        } else {
          reject(new Error('SDK loaded but not available on window'));
        }
      };
      script.onerror = () => {
        reject(new Error('Failed to load StackBlitz SDK'));
      };
      document.head.appendChild(script);
    });
  };
  const LOAD_TIMEOUT_MS = 10000;
  const iframeElRef = useRef(null);
  const reloadCountRef = useRef(0);
  const handleRetry = () => {
    vmRef.current = null;
    hasCreatedEnvRef.current = false;
    reloadCountRef.current = 0;
    setIsStuck(false);
    setIframeKey(prev => prev + 1);
  };
  const iframeRef = iframe => {
    iframeElRef.current = iframe;
  };
  const connectToVM = async iframe => {
    const sdk = await loadSDK();
    return sdk.connect(iframe);
  };
  const handleIframeLoad = async () => {
    const iframe = iframeElRef.current;
    if (!iframe) return;
    if (reloadCountRef.current > 0) {
      try {
        const vm = await connectToVM(iframe);
        vmRef.current = vm;
        await updateEnvFile(vm);
      } catch (_) {}
      return;
    }
    try {
      if (vmRef.current) return;
      const vm = await Promise.race([connectToVM(iframe), new Promise((_, reject) => setTimeout(() => reject(new Error('connect timeout')), LOAD_TIMEOUT_MS))]);
      vmRef.current = vm;
      await updateEnvFile(vm);
    } catch (error) {
      console.error('Failed to connect to StackBlitz VM:', error);
      if (typeof window !== 'undefined' && window.gtag) {
        window.gtag('event', 'load_refresh_error', {
          event_category: 'stackblitz',
          event_label: file,
          error_message: error.message
        });
      }
      reloadCountRef.current = 1;
      setTimeout(() => {
        setIframeKey(prev => prev + 1);
      }, 2000);
    }
  };
  const isSafari = typeof navigator !== 'undefined' && (/^((?!chrome|android).)*safari/i).test(navigator.userAgent);
  if (isSafari) {
    return <div className="code-editor-dialog-container" style={{
      height: height
    }}>
        <div className="code-editor-dialog-box">
          <h2 className="code-editor-dialog-title">
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2">
              <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
              <line x1="12" y1="9" x2="12" y2="13"></line>
              <line x1="12" y1="17" x2="12.01" y2="17"></line>
            </svg>
            Browser Not Supported
          </h2>
          <p className="code-editor-dialog-description">
            The interactive code editor is not supported on Safari. Please use <strong>Chrome</strong>, <strong>Edge</strong>, or <strong>Firefox</strong> to run the examples.
          </p>
        </div>
      </div>;
  }
  if (showApiKeyDialog) {
    return <div className="code-editor-dialog-container" style={{
      height: height
    }}>
        <div className="code-editor-dialog-box">
          <h2 className="code-editor-dialog-title">
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2">
              <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
              <line x1="12" y1="9" x2="12" y2="13"></line>
              <line x1="12" y1="17" x2="12.01" y2="17"></line>
            </svg>
            Configure API Key
          </h2>

          <p className="code-editor-dialog-description">
            All interactive examples execute entirely within your browser environment, ensuring complete security and privacy.
            Your API key is stored locally in your browser's storage and is never transmitted to external servers.
          </p>

          <div className="llm-provider-tabs" style={{
      marginBottom: '16px'
    }}>
            <button type="button" onClick={() => {
      setSelectedProvider('gemini');
      setError('');
      setApiKey('');
    }} className={`llm-provider-tab ${selectedProvider === 'gemini' ? 'llm-provider-tab-active' : ''}`}>
              Gemini <span className="llm-provider-tab-badge">Free</span>
            </button>
            <button type="button" onClick={() => {
      setSelectedProvider('openai');
      setError('');
      setApiKey('');
    }} className={`llm-provider-tab ${selectedProvider === 'openai' ? 'llm-provider-tab-active' : ''}`}>
              OpenAI
            </button>
            <button type="button" onClick={() => {
      setSelectedProvider('anthropic');
      setError('');
      setApiKey('');
    }} className={`llm-provider-tab ${selectedProvider === 'anthropic' ? 'llm-provider-tab-active' : ''}`}>
              Claude
            </button>
          </div>

          {selectedProvider === 'gemini' && <div className="llm-gemini-recommendation" style={{
      marginBottom: '16px'
    }}>
              Gemini offers a generous free tier — great for learning! Get your free API key at{' '}
              <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer" className="code-editor-link">
                aistudio.google.com/apikey
              </a>
            </div>}

          {selectedProvider === 'openai' && <div className="code-editor-info-box">
              <p className="code-editor-info-box-title">
                Don't have an API key?
              </p>
              <p className="code-editor-info-box-text">
                Get one at{' '}
                <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="code-editor-link">
                  platform.openai.com/api-keys
                </a>
              </p>
            </div>}

          {selectedProvider === 'anthropic' && <div className="code-editor-info-box">
              <p className="code-editor-info-box-title">
                Don't have an API key?
              </p>
              <p className="code-editor-info-box-text">
                Get one at{' '}
                <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" className="code-editor-link">
                  console.anthropic.com/settings/keys
                </a>
              </p>
            </div>}

          <form onSubmit={handleApiKeySubmit}>
            <div className="code-editor-form-group">
              <label htmlFor="api-key-input" className="code-editor-label">
                {selectedProvider === 'gemini' ? 'Gemini' : 'OpenAI'} API Key
              </label>
              <input id="api-key-input" type="password" value={apiKey} onChange={e => {
      setApiKey(e.target.value);
      setError('');
      setSuccess(false);
    }} placeholder={selectedProvider === 'openai' ? 'sk-...' : 'Gemini API Key'} disabled={isSubmitting} className={`code-editor-input ${error ? 'code-editor-input-error' : ''}`} />
            </div>

            {isValidating && <div className="code-editor-message code-editor-message-info">
                <span className="code-editor-message-icon">⏳</span>
                <span>Validating API key...</span>
              </div>}

            {error && !isValidating && <div className="code-editor-message code-editor-message-error">
                <span className="code-editor-message-icon">⚠️</span>
                <span>{error}</span>
              </div>}

            {success && <div className="code-editor-message code-editor-message-success">
                <span className="code-editor-message-icon">✓</span>
                <span>API key saved successfully! Loading editor...</span>
              </div>}

            <button type="submit" disabled={isSubmitting || isValidating || !apiKey.trim()} className="code-editor-button">
              {isValidating ? 'Validating...' : isSubmitting ? 'Saving...' : 'Save API Key'}
            </button>
          </form>

          <button type="button" onClick={handleSkipConfiguration} disabled={isSubmitting || isValidating} className="code-editor-button-secondary">
            Skip Configuration
          </button>

          <div className="code-editor-footer">
            <p className="code-editor-footer-text">
              Alternatively, you may checkout the source code from{' '}
              <a href="https://github.com/ai-tutorial/typescript-examples" target="_blank" rel="noopener noreferrer" className="code-editor-link code-editor-link-break">
                https://github.com/ai-tutorial/typescript-examples
              </a>
              {' '}and run the examples locally.
            </p>
          </div>
        </div>
      </div>;
  }
  const toggleMaximize = () => setIsMaximized(!isMaximized);
  const toggleCollapse = () => setIsCollapsed(!isCollapsed);
  return <div className={`code-editor-wrapper ${isMaximized ? 'maximized' : ''} ${isCollapsed ? 'collapsed' : ''}`} data-theme={theme}>
      <div className="code-editor-header">
        <div className="code-editor-title">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
            <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
          </svg>
          {title}
        </div>
        <div className="code-editor-controls">
          {!isMaximized && <button className="code-editor-collapse-button" onClick={toggleCollapse} title={isCollapsed ? "Expand" : "Collapse"} type="button">
              <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                {isCollapsed ? <polyline points="6 9 12 15 18 9" /> : <polyline points="6 15 12 9 18 15" />}
              </svg>
            </button>}
          <button className="code-editor-maximize-button" onClick={toggleMaximize} title={isMaximized ? "Minimize" : "Maximize (Focus Mode)"} type="button">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              {isMaximized ? <><path d="M4 14h6v6" /><path d="M20 10h-6V4" /><path d="M14 10l7-7" /><path d="M3 21l7-7" /></> : <><path d="M15 3h6v6" /><path d="M9 21H3v-6" /><path d="M21 3l-7 7" /><path d="M3 21l7-7" /></>}
            </svg>
          </button>
        </div>
      </div>

      {!isCollapsed && <div style={{
    position: 'relative',
    height: isMaximized ? 'auto' : height,
    flex: isMaximized ? 1 : 'none'
  }}>
          <iframe key={iframeKey} ref={iframeRef} onLoad={handleIframeLoad} src={stackblitzUrl} className="code-editor-iframe" style={{
    height: '100%',
    flex: isMaximized ? 1 : 'none'
  }} title={title || 'Code Example'} allow="accelerometer; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; xr-spatial-tracking" sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts" />

          {isStuck && <div className="code-editor-stuck-overlay">
              <div className="code-editor-stuck-box">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2">
                  <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
                  <line x1="12" y1="9" x2="12" y2="13"></line>
                  <line x1="12" y1="17" x2="12.01" y2="17"></line>
                </svg>
                <p>StackBlitz is taking too long to load. This can happen when the repository was recently updated.</p>
                <button type="button" className="code-editor-button" onClick={handleRetry} style={{
    marginTop: '8px'
  }}>
                  Retry
                </button>
              </div>
            </div>}
        </div>}
    </div>;
};

Before reaching for a convolutional network, it's worth seeing how far you can get with the simplest classifier: one matrix multiplication and a softmax. It's a surprisingly useful baseline — and it makes the limitations we fix in the next page very concrete.

## The Problem: 10-Way Digit Classification

MNIST is 70,000 grayscale images of handwritten digits 0–9, each 28×28 pixels, split into 60K training and 10K test. It's small enough to train in seconds and famous enough that every competing model has been measured on it — the perfect first dataset.

```text theme={null}
Input:  one image  → (1, 28, 28)  → flatten → x ∈ ℝ^784
Output: class probabilities        → ŷ ∈ ℝ^10  (must sum to 1)
```

We want a function `f(x) = ŷ` that maps the 784 pixel intensities to 10 class probabilities.

## The Linear Classifier

The simplest choice is a single linear layer followed by softmax:

```text theme={null}
logits = W · x + b           # W has shape (10, 784), b has shape (10,)
ŷ     = softmax(logits)      # turns logits into a probability distribution
```

That's 7,850 parameters (10 × 784 weights + 10 biases) — tiny. Each row of `W` is a **template** for one digit class: the pixels where that template is bright tell the model "this is what a 3 looks like on average". Training adjusts those templates to maximize agreement with the labels.

**Loss:** cross-entropy between `ŷ` and the one-hot true label `y`:

$$
L = -\sum_{i=1}^{10} y_i \log \hat{y}_i
$$

Minimizing cross-entropy is equivalent to maximizing the probability the model assigns to the correct class.

## Training, Step by Step

```text theme={null}
for each epoch:
    for each mini-batch (x_batch, y_batch):
        logits = x_batch @ W.T + b
        loss   = cross_entropy(softmax(logits), y_batch)
        ∂L/∂W, ∂L/∂b = autograd.backward(loss)
        W -= α · ∂L/∂W
        b -= α · ∂L/∂b
```

A well-tuned linear classifier on MNIST plateaus around **92% test accuracy** — which sounds fine until you remember that 92% means **8 mistakes per 100 digits**. Any real postal-code, check-reading, or form-processing pipeline needs much better than that.

<CodeEditor file="src/computer-vision/mnist_linear.ts" functionName="main" lines="1-80" title="Training a linear classifier on MNIST (placeholder)" />

## What the Templates Look Like

A nice side-effect of the linear model is that each row of `W` can be reshaped back to 28×28 and visualized. You see ghostly averages of each digit — a round blob for `0`, a vertical stroke for `1`, a loopy `S` for `8`. The classifier is literally computing "how much does this input look like the average 3?" for every class in parallel, then picking the winner.

## Where It Breaks

92% is an honest ceiling for something this simple. The failure modes are instructive because every image task has a more sophisticated version of them:

* **Translation sensitivity.** Slide a `7` two pixels to the right, and every entry in `x` changes. The model has to re-learn each digit at every possible position, with no parameter sharing. Tiny shifts at test time break it.
* **No notion of locality.** Pixel `(3, 3)` and pixel `(3, 4)` are neighbors on the image, but to the classifier they are just two of 784 independent inputs. It cannot exploit the fact that nearby pixels form edges and strokes.
* **One template per class.** A `4` can be written "closed" or "open", a `7` with or without a cross-bar. A single weight vector per class cannot represent multi-modal style.
* **Parameters grow with resolution.** Doubling the image size quadruples the input dimension. A 224×224 RGB ImageNet image has 150,528 inputs — millions of parameters per class. The linear model doesn't scale.

### Practical Implication

The linear classifier is a useful smoke test, not a production model. If your deep network can't beat a well-tuned linear baseline, something is wrong with your data pipeline — not your architecture. Always run the baseline first.

#### ❌ Antipattern

Jumping straight to a 50M-parameter CNN for a 10-class problem with 500 training images. You'll overfit instantly and have no reference for what "reasonable" accuracy looks like.

#### ✅ Best Practice

Train a linear baseline, then a small CNN, then scale. Each step should measurably improve the test metric — if it doesn't, investigate before making the model bigger.

## A Note on Regression

Classification isn't the only supervised vision task. Sometimes the target is a **continuous number** rather than a category — and the pipeline barely changes.

Replace the softmax layer with a single linear output, swap cross-entropy for mean squared error, and the same model becomes a regressor that predicts a scalar from an image. Classic examples:

* **Bone age estimation from a hand X-ray.** A pediatric radiologist normally estimates skeletal maturity in months by comparing the scan to a reference atlas. The model takes the image in and outputs one number. Same CNN, same training loop — only the loss and the final layer change.
* **Image-quality score prediction.** Given a photo, predict a 0–10 human-rated quality score.
* **Keypoint / pose regression.** Predict `(x, y)` coordinates of landmarks (face keypoints, joints, anatomical markers) instead of a class label.

Everything else in this module — CNNs, detection heads, segmentation decoders — has a regression counterpart you get with the same two-line change. Keep "regression" in mind as a separate task slot whenever a dataset's label is naturally a number, not a category.

<Quiz>
  <QuizQuestion question="Your linear MNIST classifier gets 91% test accuracy. You double its parameter count by increasing hidden units — wait, there are no hidden units. What do you try next?" options={["Increase the learning rate 10×", "Add a non-linearity and a hidden layer, or switch to a convolutional architecture that can exploit spatial locality", "Train for 10× more epochs"]} answer={1} explanation="A pure linear classifier has a hard ceiling on MNIST — one template per class, no spatial reasoning. The next step is a non-linear model (MLP) or, better, a CNN that shares weights across positions." />

  <QuizQuestion question="Why does a linear classifier trained on centered MNIST digits fail on the same dataset after a small random translation augmentation?" options={["Augmentation introduces label noise", "The model has no translation-invariance built in — every shifted pixel is a completely new input to the model, so it has to relearn each digit at each position independently", "Cross-entropy loss is sensitive to pixel position"]} answer={1} explanation="Linear models treat pixel `(3, 3)` and pixel `(3, 4)` as unrelated input features. They cannot exploit spatial structure, so shifts produce inputs the model has effectively never seen." />
</Quiz>
