Files
TinyTorch/modules/source/02_tensor/tensor_dev.ipynb
T
Vijay Janapa Reddi 621be58efa Add generated Jupyter notebooks for tensor and activations modules
- Add tensor_dev.ipynb converted from tensor_dev.py
- Add activations_dev.ipynb converted from activations_dev.py

These notebooks provide interactive learning environments for students
to explore tensor operations and activation functions.
2025-09-15 13:30:20 -04:00

64 KiB
Raw Blame History

Tensor - Core Data Structure

Welcome to the Tensor module! This is where TinyTorch really begins. You'll implement the fundamental data structure that powers all ML systems.

Learning Goals

  • Understand tensors as N-dimensional arrays with ML-specific operations
  • Implement a complete Tensor class with arithmetic operations
  • Handle shape management, data types, and memory layout
  • Build the foundation for neural networks and automatic differentiation
  • Master the NBGrader workflow with comprehensive testing

Build → Use → Understand

  1. Build: Create the Tensor class with core operations
  2. Use: Perform tensor arithmetic and transformations
  3. Understand: How tensors form the foundation of ML systems
In [ ]:
#| default_exp core.tensor

#| export
import numpy as np
import sys
from typing import Union, List, Tuple, Optional, Any
In [ ]:
print("🔥 TinyTorch Tensor Module")
print(f"NumPy version: {np.__version__}")
print(f"Python version: {sys.version_info.major}.{sys.version_info.minor}")
print("Ready to build tensors!")

📦 Where This Code Lives in the Final Package

Learning Side: You work in modules/source/01_tensor/tensor_dev.py
Building Side: Code exports to tinytorch.core.tensor

# Final package structure:
from tinytorch.core.tensor import Tensor  # The foundation of everything!
from tinytorch.core.activations import ReLU, Sigmoid, Tanh
from tinytorch.core.layers import Dense, Conv2D

Why this matters:

  • Learning: Focused modules for deep understanding
  • Production: Proper organization like PyTorch's torch.Tensor
  • Consistency: All tensor operations live together in core.tensor
  • Foundation: Every other module depends on Tensor

🔧 DEVELOPMENT

Step 1: What is a Tensor?

Definition

A tensor is an N-dimensional array with ML-specific operations. Think of it as a container that can hold data in multiple dimensions:

  • Scalar (0D): A single number - 5.0
  • Vector (1D): A list of numbers - [1, 2, 3]
  • Matrix (2D): A 2D array - [[1, 2], [3, 4]]
  • Higher dimensions: 3D, 4D, etc. for images, video, batches

The Mathematical Foundation: From Scalars to Tensors

Understanding tensors requires building from mathematical fundamentals:

Scalars (Rank 0)

  • Definition: A single number with no direction
  • Examples: Temperature (25°C), mass (5.2 kg), probability (0.7)
  • Operations: Addition, multiplication, comparison
  • ML Context: Loss values, learning rates, regularization parameters

Vectors (Rank 1)

  • Definition: An ordered list of numbers with direction and magnitude
  • Examples: Position [x, y, z], RGB color [255, 128, 0], word embedding [0.1, -0.5, 0.8]
  • Operations: Dot product, cross product, norm calculation
  • ML Context: Feature vectors, gradients, model parameters

Matrices (Rank 2)

  • Definition: A 2D array organizing data in rows and columns
  • Examples: Image (height × width), weight matrix (input × output), covariance matrix
  • Operations: Matrix multiplication, transpose, inverse, eigendecomposition
  • ML Context: Linear layer weights, attention matrices, batch data

Higher-Order Tensors (Rank 3+)

  • Definition: Multi-dimensional arrays extending matrices
  • Examples:
    • 3D: Video frames (time × height × width), RGB images (height × width × channels)
    • 4D: Image batches (batch × height × width × channels)
    • 5D: Video batches (batch × time × height × width × channels)
  • Operations: Tensor products, contractions, decompositions
  • ML Context: Convolutional features, RNN states, transformer attention

Why Tensors Matter in ML: The Computational Foundation

1. Unified Data Representation

Tensors provide a consistent way to represent all ML data:

# All of these are tensors with different shapes
scalar_loss = Tensor(0.5)              # Shape: ()
feature_vector = Tensor([1, 2, 3])      # Shape: (3,)
weight_matrix = Tensor([[1, 2], [3, 4]]) # Shape: (2, 2)
image_batch = Tensor(np.random.rand(32, 224, 224, 3)) # Shape: (32, 224, 224, 3)

2. Efficient Batch Processing

ML systems process multiple samples simultaneously:

# Instead of processing one image at a time:
for image in images:
    result = model(image)  # Slow: 1000 separate operations

# Process entire batch at once:
batch_result = model(image_batch)  # Fast: 1 vectorized operation

3. Hardware Acceleration

Modern hardware (GPUs, TPUs) excels at tensor operations:

  • Parallel processing: Multiple operations simultaneously
  • Vectorization: SIMD (Single Instruction, Multiple Data) operations
  • Memory optimization: Contiguous memory layout for cache efficiency

4. Automatic Differentiation

Tensors enable gradient computation through computational graphs:

# Each tensor operation creates a node in the computation graph
x = Tensor([1, 2, 3])
y = x * 2          # Node: multiplication
z = y + 1          # Node: addition
loss = z.sum()     # Node: summation
# Gradients flow backward through this graph

Real-World Examples: Tensors in Action

Computer Vision

  • Grayscale image: 2D tensor (height, width) - (28, 28) for MNIST
  • Color image: 3D tensor (height, width, channels) - (224, 224, 3) for RGB
  • Image batch: 4D tensor (batch, height, width, channels) - (32, 224, 224, 3)
  • Video: 5D tensor (batch, time, height, width, channels)

Natural Language Processing

  • Word embedding: 1D tensor (embedding_dim,) - (300,) for Word2Vec
  • Sentence: 2D tensor (sequence_length, embedding_dim) - (50, 768) for BERT
  • Batch of sentences: 3D tensor (batch, sequence_length, embedding_dim)

Audio Processing

  • Audio signal: 1D tensor (time_steps,) - (16000,) for 1 second at 16kHz
  • Spectrogram: 2D tensor (time_frames, frequency_bins)
  • Batch of audio: 3D tensor (batch, time_steps, features)

Time Series

  • Single series: 2D tensor (time_steps, features)
  • Multiple series: 3D tensor (batch, time_steps, features)
  • Multivariate forecasting: 4D tensor (batch, time_steps, features, predictions)

Why Not Just Use NumPy?

While we use NumPy internally, our Tensor class adds ML-specific functionality:

1. ML-Specific Operations

  • Gradient tracking: For automatic differentiation (coming in Module 7)
  • GPU support: For hardware acceleration (future extension)
  • Broadcasting semantics: ML-friendly dimension handling

2. Consistent API

  • Type safety: Predictable behavior across operations
  • Error checking: Clear error messages for debugging
  • Integration: Seamless work with other TinyTorch components

3. Educational Value

  • Conceptual clarity: Understand what tensors really are
  • Implementation insight: See how frameworks work internally
  • Debugging skills: Trace through tensor operations step by step

4. Extensibility

  • Future features: Ready for gradients, GPU, distributed computing
  • Customization: Add domain-specific operations
  • Optimization: Profile and optimize specific use cases

Performance Considerations: Building Efficient Tensors

Memory Layout

  • Contiguous arrays: Better cache locality and performance
  • Data types: float32 vs float64 trade-offs
  • Memory sharing: Avoid unnecessary copies

Vectorization

  • SIMD operations: Single Instruction, Multiple Data
  • Broadcasting: Efficient operations on different shapes
  • Batch operations: Process multiple samples simultaneously

Numerical Stability

  • Precision: Balancing speed and accuracy
  • Overflow/underflow: Handling extreme values
  • Gradient flow: Maintaining numerical stability for training

Let's start building our tensor foundation!

🧠 The Mathematical Foundation

Linear Algebra Refresher

Tensors are generalizations of scalars, vectors, and matrices:

Scalar (0D): 5
Vector (1D): [1, 2, 3]
Matrix (2D): [[1, 2], [3, 4]]
Tensor (3D): [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]

Why This Matters for Neural Networks

  • Forward Pass: Matrix multiplication between layers
  • Batch Processing: Multiple samples processed simultaneously
  • Convolutions: 3D operations on image data
  • Gradients: Derivatives computed across all dimensions

Connection to Real ML Systems

Every major ML framework uses tensors:

  • PyTorch: torch.Tensor
  • TensorFlow: tf.Tensor
  • JAX: jax.numpy.ndarray
  • TinyTorch: tinytorch.core.tensor.Tensor (what we're building!)

Performance Considerations

  • Memory Layout: Contiguous arrays for cache efficiency
  • Vectorization: SIMD operations for speed
  • Broadcasting: Efficient operations on different shapes
  • Type Consistency: Avoiding unnecessary conversions

Step 2: The Tensor Class Foundation

🔍 The Wrapper Pattern: Why Every ML Framework Uses It

Before we dive into implementation, let's understand a critical design pattern that ALL ML frameworks use:

The Two-Layer Architecture

┌─────────────────────────────────────┐
│   User-Facing API Layer (Python)    │  ← What you interact with
│   - Tensor class                    │
│   - Automatic differentiation       │
│   - High-level operations          │
├─────────────────────────────────────┤
│   Efficient Compute Layer (C/C++)   │  ← What does the actual work
│   - Optimized matrix operations     │
│   - SIMD/vectorization             │
│   - GPU kernels                    │
└─────────────────────────────────────┘

Why This Pattern?

  1. Performance: Low-level languages (C/C++/CUDA) for speed
  2. Usability: High-level language (Python) for ease of use
  3. Flexibility: Add ML-specific features without modifying core compute
  4. Portability: Swap compute backends (CPU/GPU/TPU) transparently

Core Concept: Wrapping NumPy with ML Intelligence

Our Tensor class wraps NumPy arrays with ML-specific functionality. This design pattern is used by all major ML frameworks:

  • PyTorch: torch.Tensor wraps ATen (C++ tensor library)
  • TensorFlow: tf.Tensor wraps Eigen (C++ linear algebra library)
  • JAX: jax.numpy.ndarray wraps XLA (Google's linear algebra compiler)
  • TinyTorch: Tensor wraps NumPy (Python's numerical computing library)

Design Requirements Analysis

1. Input Flexibility

Our tensor must handle diverse input types:

# Scalars (Python numbers)
t1 = Tensor(5)           # int → numpy array
t2 = Tensor(3.14)        # float → numpy array

# Lists (Python sequences)
t3 = Tensor([1, 2, 3])   # list → numpy array
t4 = Tensor([[1, 2], [3, 4]])  # nested list → 2D array

# NumPy arrays (existing arrays)
t5 = Tensor(np.array([1, 2, 3]))  # array → tensor wrapper

2. Type Management

ML systems need consistent, predictable types:

  • Default behavior: Auto-detect appropriate types
  • Explicit control: Allow manual type specification
  • Performance optimization: Prefer float32 over float64
  • Memory efficiency: Use appropriate precision

3. Property Access

Essential tensor properties for ML operations:

  • Shape: Dimensions for compatibility checking
  • Size: Total elements for memory estimation
  • Data type: For numerical computation planning
  • Data access: For integration with other libraries

4. Arithmetic Operations

Support for mathematical operations:

  • Element-wise: Addition, multiplication, subtraction, division
  • Broadcasting: Operations on different shapes
  • Type promotion: Consistent result types
  • Error handling: Clear messages for incompatible operations

Implementation Strategy

Memory Management

  • Copy vs. Reference: When to copy data vs. share memory
  • Type conversion: Efficient dtype changes
  • Contiguous layout: Ensure optimal memory access patterns

Error Handling

  • Input validation: Check for valid input types
  • Shape compatibility: Verify operations are mathematically valid
  • Informative messages: Help users debug issues quickly

Performance Optimization

  • Lazy evaluation: Defer expensive operations when possible
  • Vectorization: Use NumPy's optimized operations
  • Memory reuse: Minimize unnecessary allocations

Learning Objectives for Implementation

By implementing this Tensor class, you'll learn:

  1. Wrapper pattern: How to extend existing libraries
  2. Type system design: Managing data types in numerical computing
  3. API design: Creating intuitive, consistent interfaces
  4. Performance considerations: Balancing flexibility and speed
  5. Error handling: Providing helpful feedback to users

📊 Understanding NumPy Data Types Before We Start

Before implementing the constructor, let's understand NumPy's type system:

Common Data Types in ML

# Integer types (discrete values)
'int8', 'int16', 'int32', 'int64'    # Signed integers
'uint8', 'uint16', 'uint32', 'uint64' # Unsigned integers

# Float types (continuous values)
'float16' # Half precision (saves memory, less accurate)
'float32' # Single precision (ML standard - good balance)
'float64' # Double precision (high accuracy, more memory)

Why We Prefer float32 in ML

  • Memory: Uses half the memory of float64
  • Speed: Faster computation on GPUs
  • Sufficient precision: Good enough for most ML tasks
  • Industry standard: Used by PyTorch, TensorFlow by default

Let's implement our tensor foundation!

In [ ]:
#| export
class Tensor:
    """
    TinyTorch Tensor: N-dimensional array with ML operations.
    
    The fundamental data structure for all TinyTorch operations.
    Wraps NumPy arrays with ML-specific functionality.
    """
    
    def __init__(self, data: Union[int, float, List, np.ndarray], dtype: Optional[str] = None):
        """
        Create a new tensor from data.
        
        Args:
            data: Input data (scalar, list, or numpy array)
            dtype: Data type ('float32', 'int32', etc.). Defaults to auto-detect.
            
        TODO: Implement tensor creation with proper type handling.
        
        APPROACH:
        1. Check the input data type using isinstance()
           - If scalar (int/float): convert to numpy array
           - If list: convert to numpy array
           - If numpy array: use directly (but make a copy)
        2. Handle the dtype parameter:
           - If dtype is None: auto-detect the best type
           - If dtype is specified: convert to that type
        3. For auto-detection, prefer:
           - int32 for integers (not int64)
           - float32 for floats (not float64)
        4. Store the final array in self._data
        
        EXAMPLE:
        >>> t1 = Tensor(5)                    # Creates int32 array
        >>> t2 = Tensor(3.14)                 # Creates float32 array
        >>> t3 = Tensor([1, 2, 3])            # Creates int32 array
        >>> t4 = Tensor([1.0, 2.0], 'float64') # Forces float64
        >>> t5 = Tensor(np.array([1, 2]))     # Preserves numpy array
        
        HINTS:
        - Use isinstance(data, (int, float)) to check for scalars
        - Use isinstance(data, list) for lists
        - Use isinstance(data, np.ndarray) for numpy arrays
        - For dtype conversion: np.array(data, dtype=dtype)
        - Remember: we prefer float32 over float64 for ML efficiency
        """
        ### BEGIN SOLUTION
        # Convert input to numpy array
        if isinstance(data, (int, float, np.number)):
            # Handle Python and NumPy scalars
            if dtype is None:
                # Auto-detect type: int for integers, float32 for floats
                if isinstance(data, int) or (isinstance(data, np.number) and np.issubdtype(type(data), np.integer)):
                    dtype = 'int32'
                else:
                    dtype = 'float32'
            self._data = np.array(data, dtype=dtype)
        elif isinstance(data, list):
            # Let NumPy auto-detect type, then convert if needed
            temp_array = np.array(data)
            if dtype is None:
                # Use NumPy's auto-detected type, but prefer float32 for floats
                if temp_array.dtype == np.float64:
                    dtype = 'float32'
                else:
                    dtype = str(temp_array.dtype)
            self._data = np.array(data, dtype=dtype)
        elif isinstance(data, np.ndarray):
            # Already a numpy array
            if dtype is None:
                # Keep existing dtype, but prefer float32 for float64
                if data.dtype == np.float64:
                    dtype = 'float32'
                else:
                    dtype = str(data.dtype)
            self._data = data.astype(dtype) if dtype != data.dtype else data.copy()
        else:
            # Try to convert unknown types
            self._data = np.array(data, dtype=dtype)
        ### END SOLUTION

    @property
    def data(self) -> np.ndarray:
        """
        Access underlying numpy array.
        
        TODO: Return the stored numpy array.
        
        HINT: Return self._data (the array you stored in __init__)
        """
        ### BEGIN SOLUTION
        return self._data
        ### END SOLUTION
    
    @property
    def shape(self) -> Tuple[int, ...]:
        """
        Get tensor shape.
        
        TODO: Return the shape of the stored numpy array.
        
        HINT: Use .shape attribute of the numpy array
        EXAMPLE: Tensor([1, 2, 3]).shape should return (3,)
        """
        ### BEGIN SOLUTION
        return self._data.shape
        ### END SOLUTION
    
    @property
    def size(self) -> int:
        """
        Get total number of elements.
        
        TODO: Return the total number of elements in the tensor.
        
        HINT: Use .size attribute of the numpy array
        EXAMPLE: Tensor([1, 2, 3]).size should return 3
        """
        ### BEGIN SOLUTION
        return self._data.size
        ### END SOLUTION
    
    @property
    def dtype(self) -> np.dtype:
        """
        Get data type as numpy dtype.
        
        TODO: Return the data type of the stored numpy array.
        
        HINT: Use .dtype attribute of the numpy array
        EXAMPLE: Tensor([1, 2, 3]).dtype should return dtype('int32')
        """
        ### BEGIN SOLUTION
        return self._data.dtype
        ### END SOLUTION
    
    def __repr__(self) -> str:
        """
        String representation.
        
        TODO: Create a clear string representation of the tensor.
        
        APPROACH:
        1. Convert the numpy array to a list for readable output
        2. Include the shape and dtype information
        3. Format: "Tensor([data], shape=shape, dtype=dtype)"
        
        EXAMPLE:
        Tensor([1, 2, 3]) → "Tensor([1, 2, 3], shape=(3,), dtype=int32)"
        
        HINTS:
        - Use .tolist() to convert numpy array to list
        - Include shape and dtype information
        - Keep format consistent and readable
        """
        ### BEGIN SOLUTION
        return f"Tensor({self._data.tolist()}, shape={self.shape}, dtype={self.dtype})"
        ### END SOLUTION

    def add(self, other: 'Tensor') -> 'Tensor':
        """
        Add two tensors element-wise.
        
        TODO: Implement tensor addition.
        
        APPROACH:
        1. Add the numpy arrays using +
        2. Return a new Tensor with the result
        3. Handle broadcasting automatically
        
        EXAMPLE:
        Tensor([1, 2]) + Tensor([3, 4]) → Tensor([4, 6])
        
        HINTS:
        - Use self._data + other._data
        - Return Tensor(result)
        - NumPy handles broadcasting automatically
        """
        ### BEGIN SOLUTION
        result = self._data + other._data
        return Tensor(result)
        ### END SOLUTION

    def multiply(self, other: 'Tensor') -> 'Tensor':
        """
        Multiply two tensors element-wise.
        
        TODO: Implement tensor multiplication.
        
        APPROACH:
        1. Multiply the numpy arrays using *
        2. Return a new Tensor with the result
        3. Handle broadcasting automatically
        
        EXAMPLE:
        Tensor([1, 2]) * Tensor([3, 4]) → Tensor([3, 8])
        
        HINTS:
        - Use self._data * other._data
        - Return Tensor(result)
        - This is element-wise, not matrix multiplication
        """
        ### BEGIN SOLUTION
        result = self._data * other._data
        return Tensor(result)
        ### END SOLUTION

    def __add__(self, other: Union['Tensor', int, float]) -> 'Tensor':
        """
        Addition operator overload for natural syntax: tensor + other.
        
        TODO: Implement the + operator to enable natural addition syntax for tensors.
        
        APPROACH:
        1. Check if other is a Tensor or scalar (int/float)
        2. If scalar, convert to Tensor for uniform handling
        3. Delegate to the add() method for actual computation
        4. Return the resulting Tensor
        
        EXAMPLE:
        >>> a = Tensor([1, 2, 3])
        >>> b = Tensor([4, 5, 6])
        >>> c = a + b  # Element-wise addition
        >>> print(c.data)  # [5, 7, 9]
        >>> d = a + 10  # Broadcasting with scalar
        >>> print(d.data)  # [11, 12, 13]
        
        HINTS:
        - Use isinstance(other, Tensor) to check type
        - Convert scalars with Tensor(other)
        - Delegate to self.add() for consistency
        - Python automatically calls this for + operator
        
        LEARNING CONNECTIONS:
        - This is how torch.Tensor implements natural syntax
        - Operator overloading makes math operations intuitive
        - Broadcasting happens automatically in the add() method
        """
        ### BEGIN SOLUTION
        if isinstance(other, Tensor):
            return self.add(other)
        else:
            return self.add(Tensor(other))
        ### END SOLUTION

    def __mul__(self, other: Union['Tensor', int, float]) -> 'Tensor':
        """
        Multiplication operator overload for natural syntax: tensor * other.
        
        TODO: Implement the * operator for element-wise multiplication (Hadamard product).
        
        APPROACH:
        1. Check if other is a Tensor or scalar (int/float)
        2. If scalar, convert to Tensor for uniform handling
        3. Delegate to the multiply() method for computation
        4. Return the resulting Tensor
        
        EXAMPLE:
        >>> a = Tensor([2, 3, 4])
        >>> b = Tensor([5, 6, 7])
        >>> c = a * b  # Element-wise multiplication
        >>> print(c.data)  # [10, 18, 28]
        >>> d = a * 3  # Scalar multiplication
        >>> print(d.data)  # [6, 9, 12]
        
        HINTS:
        - This is element-wise, NOT matrix multiplication
        - For matrix multiplication, use @ operator (matmul)
        - Scalar multiplication broadcasts automatically
        - Delegate to self.multiply() for consistency
        
        LEARNING CONNECTIONS:
        - In PyTorch: * is element-wise, @ is matrix multiplication
        - Neural networks use this for element-wise activations
        - Scalar multiplication is used for learning rate scaling
        """
        ### BEGIN SOLUTION
        if isinstance(other, Tensor):
            return self.multiply(other)
        else:
            return self.multiply(Tensor(other))
        ### END SOLUTION

    def __sub__(self, other: Union['Tensor', int, float]) -> 'Tensor':
        """
        Subtraction operator overload for natural syntax: tensor - other.
        
        TODO: Implement the - operator for element-wise subtraction.
        
        APPROACH:
        1. Check if other is a Tensor or scalar
        2. Access the underlying NumPy arrays with ._data
        3. Perform subtraction using NumPy's broadcasting
        4. Wrap the result in a new Tensor and return
        
        EXAMPLE:
        >>> a = Tensor([10, 20, 30])
        >>> b = Tensor([3, 5, 7])
        >>> c = a - b  # Element-wise subtraction
        >>> print(c.data)  # [7, 15, 23]
        >>> d = a - 5  # Broadcasting with scalar
        >>> print(d.data)  # [5, 15, 25]
        
        HINTS:
        - Access data with self._data and other._data
        - NumPy handles broadcasting automatically
        - Always return a new Tensor (immutability)
        - Handle both Tensor and scalar cases
        
        LEARNING CONNECTIONS:
        - Used in gradient computation (loss - target)
        - Batch normalization uses mean subtraction
        - Residual connections compute (output - input)
        """
        ### BEGIN SOLUTION
        if isinstance(other, Tensor):
            result = self._data - other._data
        else:
            result = self._data - other
        return Tensor(result)
        ### END SOLUTION

    def __truediv__(self, other: Union['Tensor', int, float]) -> 'Tensor':
        """
        Division operator overload for natural syntax: tensor / other.
        
        TODO: Implement the / operator for element-wise division.
        
        APPROACH:
        1. Check if other is a Tensor or scalar
        2. Access the underlying NumPy arrays
        3. Perform division (watch for division by zero!)
        4. Wrap the result in a new Tensor and return
        
        EXAMPLE:
        >>> a = Tensor([10, 20, 30])
        >>> b = Tensor([2, 4, 5])
        >>> c = a / b  # Element-wise division
        >>> print(c.data)  # [5.0, 5.0, 6.0]
        >>> d = a / 10  # Scalar division
        >>> print(d.data)  # [1.0, 2.0, 3.0]
        
        HINTS:
        - NumPy handles division by zero → inf/nan
        - Results are always float type
        - Consider using safe_divide() for production
        - Broadcasting rules apply here too
        
        LEARNING CONNECTIONS:
        - Normalization often uses division (x / std)
        - Softmax involves division by sum
        - Learning rate scheduling uses division
        - Numerical stability is critical in ML
        """
        ### BEGIN SOLUTION
        if isinstance(other, Tensor):
            result = self._data / other._data
        else:
            result = self._data / other
        return Tensor(result)
        ### END SOLUTION

    def mean(self) -> 'Tensor':
        """Computes the mean of the tensor's elements."""
        return Tensor(np.mean(self.data))

    # --- Matmul ---
    def matmul(self, other: 'Tensor') -> 'Tensor':
        """
        Perform matrix multiplication between two tensors.
        
        TODO: Implement matrix multiplication.
        
        APPROACH:
        1. Use np.matmul() to perform matrix multiplication
        2. Return a new Tensor with the result
        3. Handle broadcasting automatically
        
        EXAMPLE:
        Tensor([[1, 2], [3, 4]]) @ Tensor([[5, 6], [7, 8]]) → Tensor([[19, 22], [43, 50]])
        
        HINTS:
        - Use np.matmul(self._data, other._data)
        - Return Tensor(result)
        - This is matrix multiplication, not element-wise multiplication
        """
        ### BEGIN SOLUTION
        result = np.matmul(self._data, other._data)
        return Tensor(result)
        ### END SOLUTION
    
    # --- Error Handling ---
    def validate_operation(self, other: 'Tensor', operation: str) -> None:
        """
        Validate tensor compatibility for operations with informative error messages.
        
        TODO: Implement validation for tensor operations that checks shape compatibility
        and provides educational error messages when operations would fail.
        
        APPROACH:
        1. Check if the operation is element-wise or matrix multiplication
        2. For element-wise ops, verify broadcasting compatibility
        3. For matmul, verify matrix dimension alignment
        4. Raise informative ValueError with hints if incompatible
        
        EXAMPLE:
        >>> a = Tensor([[1, 2, 3]])  # Shape: (1, 3)
        >>> b = Tensor([[1], [2]])   # Shape: (2, 1)
        >>> a.validate_operation(b, 'add')  # Should pass (broadcasting)
        >>> a.validate_operation(b, 'matmul')  # Should raise with helpful message
        
        HINTS:
        - Use np.broadcast_shapes() to check broadcasting compatibility
        - For matmul, check that self.shape[-1] == other.shape[-2]
        - Include shapes in error messages to help debugging
        - Suggest fixes in the error message
        
        LEARNING CONNECTIONS:
        - PyTorch raises similar errors with shape information
        - Good error messages accelerate learning and debugging
        - Understanding shape compatibility is crucial for ML
        """
        ### BEGIN SOLUTION
        if operation in ['add', 'mul', 'sub', 'div']:
            # Check broadcasting compatibility for element-wise operations
            try:
                np.broadcast_shapes(self.shape, other.shape)
            except ValueError:
                raise ValueError(
                    f"Cannot {operation} tensors with incompatible shapes: "
                    f"{self.shape} and {other.shape}. "
                    f"Hint: Shapes must be broadcastable. Either dimensions match "
                    f"or one of them is 1. Consider reshaping with tensor.reshape()."
                )
        elif operation == 'matmul':
            # Check matrix multiplication compatibility
            if len(self.shape) == 0 or len(other.shape) == 0:
                raise ValueError(
                    f"Cannot perform matrix multiplication with scalar tensors. "
                    f"Shapes: {self.shape} @ {other.shape}. "
                    f"Hint: Use regular multiplication (*) for scalar operations."
                )
            if self.shape[-1] != other.shape[-2 if len(other.shape) > 1 else 0]:
                raise ValueError(
                    f"Cannot perform matrix multiplication with incompatible shapes: "
                    f"{self.shape} @ {other.shape}. "
                    f"Hint: For A @ B, A's last dimension ({self.shape[-1]}) must match "
                    f"B's second-to-last dimension ({other.shape[-2 if len(other.shape) > 1 else 0]}). "
                    f"Consider transposing with tensor.T or reshaping."
                )
        ### END SOLUTION
    
    def safe_divide(self, other: Union['Tensor', int, float]) -> 'Tensor':
        """
        Perform division with zero-division protection and informative warnings.
        
        TODO: Implement safe division that handles division by zero gracefully
        and provides educational feedback about numerical stability.
        
        APPROACH:
        1. Check for zero values in the divisor
        2. Use np.where to handle zero divisions appropriately  
        3. Optionally warn about near-zero values that might cause instability
        4. Return result with inf/nan handled according to NumPy conventions
        
        EXAMPLE:
        >>> a = Tensor([1, 2, 3, 4])
        >>> b = Tensor([2, 0, 1, 0])
        >>> result = a.safe_divide(b)  # Handles zeros gracefully
        >>> print(result.data)  # [0.5, inf, 3.0, inf] with warning
        
        HINTS:
        - Use np.where(divisor == 0, np.inf, normal_division)
        - Consider np.errstate(divide='ignore') context manager
        - Check for small values with np.abs(divisor) < epsilon
        - Return informative warning messages for learning
        
        LEARNING CONNECTIONS:
        - Numerical stability is critical in ML (gradient explosions)
        - Many ML bugs come from division by zero in normalizations
        - Production systems need robust error handling
        """
        ### BEGIN SOLUTION
        other_data = other._data if isinstance(other, Tensor) else other
        
        # Check for exact zeros
        if np.any(other_data == 0):
            import warnings
            warnings.warn(
                "Division by zero detected! Results will contain inf values. "
                "In ML, this often happens in normalization or loss calculations. "
                "Consider adding a small epsilon (e.g., 1e-8) to prevent instability.",
                category=RuntimeWarning
            )
        
        # Check for near-zero values that might cause instability
        epsilon = 1e-10
        if np.any(np.abs(other_data) < epsilon):
            import warnings
            warnings.warn(
                f"Near-zero divisor detected (values < {epsilon}). "
                "This may cause numerical instability in gradients. "
                "Consider using stabilized versions of your operations.",
                category=RuntimeWarning
            )
        
        # Perform division with NumPy's default handling (inf for division by zero)
        with np.errstate(divide='ignore', invalid='ignore'):
            result = self._data / other_data
        
        return Tensor(result)
        ### END SOLUTION

🎯 Checkpoint: Basic Tensor Creation

Before moving on, make sure you can:

# Create different tensor types
scalar = Tensor(5)              # 0D tensor
vector = Tensor([1, 2, 3])      # 1D tensor  
matrix = Tensor([[1, 2], [3, 4]]) # 2D tensor

# Check their properties
print(f"Scalar shape: {scalar.shape}")  # ()
print(f"Vector shape: {vector.shape}")  # (3,)
print(f"Matrix shape: {matrix.shape}")  # (2, 2)

Step 3: Understanding Broadcasting for Arithmetic Operations

📊 Broadcasting: The Magic of Flexible Operations

Broadcasting allows operations between tensors of different shapes without explicit reshaping. It's what makes code like matrix + vector or tensor * scalar work seamlessly.

Broadcasting Rules (Simple to Complex)

Rule 1: Scalar Broadcasting

Scalar + Any Shape = Element-wise Operation
    5    +  [1,2,3]  =  [6,7,8]

Rule 2: Vector Broadcasting

Matrix + Vector (matching last dimension)
[[1,2,3]     [10,20,30]     [[11,22,33]
 [4,5,6]]  +             =    [14,25,36]]

Rule 3: Dimension Alignment

Shapes are aligned from the right:
(3, 4) and (4,)  → Compatible (4 matches)
(3, 4) and (3,)  → Not compatible (3 doesn't match 4)

Visual Examples

# Scalar broadcasting
[1, 2, 3] + 5 = [1+5, 2+5, 3+5] = [6, 7, 8]

# Row broadcasting  
[[1, 2]    [10, 20]   [[1+10, 2+20]     [[11, 22]
 [3, 4]] +          =  [3+10, 4+20]]  =  [13, 24]]

# Column broadcasting
[[1, 2]    [[10]    [[1+10, 2+10]     [[11, 12]
 [3, 4]] +  [20]] =  [3+20, 4+20]]  =  [23, 24]]

Why Broadcasting Matters in ML

  • Batch operations: Add bias to all samples in a batch
  • Normalization: Subtract mean from all features
  • Scaling: Multiply by weights across dimensions
  • Efficiency: No memory copying, just smart iteration

With this understanding, the arithmetic operations in our Tensor class will automatically support broadcasting through NumPy!

🧪 Unit Test: Tensor Creation

Let's test your tensor creation implementation right away! This gives you immediate feedback on whether your __init__ method works correctly.

This is a unit test - it tests one specific function (tensor creation) in isolation.

In [ ]:
# Test tensor creation immediately after implementation
print("🔬 Unit Test: Tensor Creation...")

# Test basic tensor creation
try:
    # Test scalar
    scalar = Tensor(5.0)
    assert hasattr(scalar, '_data'), "Tensor should have _data attribute"
    assert scalar._data.shape == (), f"Scalar should have shape (), got {scalar._data.shape}"
    print("✅ Scalar creation works")
    
    # Test vector
    vector = Tensor([1, 2, 3])
    assert vector._data.shape == (3,), f"Vector should have shape (3,), got {vector._data.shape}"
    print("✅ Vector creation works")
    
    # Test matrix
    matrix = Tensor([[1, 2], [3, 4]])
    assert matrix._data.shape == (2, 2), f"Matrix should have shape (2, 2), got {matrix._data.shape}"
    print("✅ Matrix creation works")
    
    print("📈 Progress: Tensor Creation ✓")
    
except Exception as e:
    print(f"❌ Tensor creation test failed: {e}")
    raise

print("🎯 Tensor creation behavior:")
print("   Converts data to NumPy arrays")
print("   Preserves shape and data type")
print("   Stores in _data attribute")

🧪 Unit Test: Tensor Properties

Now let's test that your tensor properties work correctly. This tests the @property methods you implemented.

This is a unit test - it tests specific properties (shape, size, dtype, data) in isolation.

In [ ]:
# Test tensor properties immediately after implementation
print("🔬 Unit Test: Tensor Properties...")

# Test properties with simple examples
try:
    # Test with a simple matrix
    tensor = Tensor([[1, 2, 3], [4, 5, 6]])
    
    # Test shape property
    assert tensor.shape == (2, 3), f"Shape should be (2, 3), got {tensor.shape}"
    print("✅ Shape property works")
    
    # Test size property
    assert tensor.size == 6, f"Size should be 6, got {tensor.size}"
    print("✅ Size property works")
    
    # Test data property
    assert np.array_equal(tensor.data, np.array([[1, 2, 3], [4, 5, 6]])), "Data property should return numpy array"
    print("✅ Data property works")
    
    # Test dtype property
    assert tensor.dtype in [np.int32, np.int64], f"Dtype should be int32 or int64, got {tensor.dtype}"
    print("✅ Dtype property works")
    
    print("📈 Progress: Tensor Properties ✓")
    
except Exception as e:
    print(f"❌ Tensor properties test failed: {e}")
    raise

print("🎯 Tensor properties behavior:")
print("   shape: Returns tuple of dimensions")
print("   size: Returns total number of elements")
print("   data: Returns underlying NumPy array")
print("   dtype: Returns NumPy data type")

🧪 Unit Test: Tensor Arithmetic

Let's test your tensor arithmetic operations. This tests the add, mul, sub, truediv methods.

This is a unit test - it tests specific arithmetic operations in isolation.

In [ ]:
# Test tensor arithmetic immediately after implementation
print("🔬 Unit Test: Tensor Arithmetic...")

# Test basic arithmetic with simple examples
try:
    # Test addition
    a = Tensor([1, 2, 3])
    b = Tensor([4, 5, 6])
    result = a + b
    expected = np.array([5, 7, 9])
    assert np.array_equal(result.data, expected), f"Addition failed: expected {expected}, got {result.data}"
    print("✅ Addition works")
    
    # Test scalar addition
    result_scalar = a + 10
    expected_scalar = np.array([11, 12, 13])
    assert np.array_equal(result_scalar.data, expected_scalar), f"Scalar addition failed: expected {expected_scalar}, got {result_scalar.data}"
    print("✅ Scalar addition works")
    
    # Test multiplication
    result_mul = a * b
    expected_mul = np.array([4, 10, 18])
    assert np.array_equal(result_mul.data, expected_mul), f"Multiplication failed: expected {expected_mul}, got {result_mul.data}"
    print("✅ Multiplication works")
    
    # Test scalar multiplication
    result_scalar_mul = a * 2
    expected_scalar_mul = np.array([2, 4, 6])
    assert np.array_equal(result_scalar_mul.data, expected_scalar_mul), f"Scalar multiplication failed: expected {expected_scalar_mul}, got {result_scalar_mul.data}"
    print("✅ Scalar multiplication works")
    
    print("📈 Progress: Tensor Arithmetic ✓")
    
except Exception as e:
    print(f"❌ Tensor arithmetic test failed: {e}")
    raise

print("🎯 Tensor arithmetic behavior:")
print("   Element-wise operations on tensors")
print("   Broadcasting with scalars")
print("   Returns new Tensor objects")

Congratulations! You've successfully implemented the core Tensor class for TinyTorch:

What You've Accomplished

Tensor Creation: Handle scalars, vectors, matrices, and higher-dimensional arrays
Data Types: Proper dtype handling with auto-detection and conversion
Properties: Shape, size, dtype, and data access
Arithmetic: Addition, multiplication, subtraction, division
Operators: Natural Python syntax with +, -, *, /
Broadcasting: Automatic shape compatibility like NumPy

Key Concepts You've Learned

  • Tensors are the fundamental data structure for ML systems
  • NumPy backend provides efficient computation with ML-friendly API
  • Operator overloading makes tensor operations feel natural
  • Broadcasting enables flexible operations between different shapes
  • Type safety ensures consistent behavior across operations

Next Steps

  1. Export your code: tito package nbdev --export 01_tensor
  2. Test your implementation: tito module test 01_tensor
  3. Use your tensors:
    from tinytorch.core.tensor import Tensor
    t = Tensor([1, 2, 3])
    print(t + 5)  # Your tensor in action!
    
  4. Move to Module 2: Start building activation functions!

Ready for the next challenge? Let's add the mathematical functions that make neural networks powerful!

🧪 Unit Test: Tensor Creation

This test validates your Tensor class constructor, ensuring it correctly handles scalars, vectors, matrices, and higher-dimensional arrays with proper shape detection.

In [ ]:
def test_unit_tensor_creation():
    """Comprehensive test of tensor creation with all data types and shapes."""
    print("🔬 Testing comprehensive tensor creation...")
    
    # Test scalar creation
    scalar_int = Tensor(42)
    assert scalar_int.shape == ()
    
    # Test vector creation
    vector_int = Tensor([1, 2, 3])
    assert vector_int.shape == (3,)

    # Test matrix creation
    matrix_2x2 = Tensor([[1, 2], [3, 4]])
    assert matrix_2x2.shape == (2, 2)
    print("✅ Tensor creation tests passed!")

# Run the test
test_unit_tensor_creation()

🧪 Unit Test: Tensor Properties

This test validates your tensor property methods (shape, size, dtype, data), ensuring they correctly reflect the tensor's dimensional structure and data characteristics.

In [ ]:
def test_unit_tensor_properties():
    """Comprehensive test of tensor properties (shape, size, dtype, data access)."""
    print("🔬 Testing comprehensive tensor properties...")

    tensor = Tensor([[1, 2, 3], [4, 5, 6]])
    
    # Test shape property
    assert tensor.shape == (2, 3)
    
    # Test size property
    assert tensor.size == 6
    
    # Test data property
    assert np.array_equal(tensor.data, np.array([[1, 2, 3], [4, 5, 6]]))
    
    # Test dtype property
    assert tensor.dtype in [np.int32, np.int64]
    print("✅ Tensor properties tests passed!")

# Run the test
test_unit_tensor_properties()

🧪 Unit Test: Tensor Arithmetic Operations

This test validates your tensor arithmetic implementation (addition, multiplication, subtraction, division) and operator overloading, ensuring mathematical operations work correctly with proper broadcasting.

In [ ]:
def test_unit_tensor_arithmetic():
    """Comprehensive test of tensor arithmetic operations."""
    print("🔬 Testing comprehensive tensor arithmetic...")
    
    a = Tensor([1, 2, 3])
    b = Tensor([4, 5, 6])
    
    # Test addition
    c = a + b
    expected = np.array([5, 7, 9])
    assert np.array_equal(c.data, expected)
    
    # Test multiplication
    d = a * b
    expected = np.array([4, 10, 18])
    assert np.array_equal(d.data, expected)

    # Test subtraction
    e = b - a
    expected = np.array([3, 3, 3])
    assert np.array_equal(e.data, expected)

    # Test division
    f = b / a
    expected = np.array([4.0, 2.5, 2.0])
    assert np.allclose(f.data, expected)
    print("✅ Tensor arithmetic tests passed!")

# Run the test
test_unit_tensor_arithmetic()
In [ ]:
def test_module_tensor_numpy_integration():
    """
    Integration test for tensor operations with NumPy arrays.
    
    Tests that tensors properly integrate with NumPy operations and maintain
    compatibility with the scientific Python ecosystem.
    """
    print("🔬 Running Integration Test: Tensor-NumPy Integration...")
    
    # Test 1: Tensor from NumPy array
    numpy_array = np.array([[1, 2, 3], [4, 5, 6]])
    tensor_from_numpy = Tensor(numpy_array)
    
    assert tensor_from_numpy.shape == (2, 3), "Tensor should preserve NumPy array shape"
    assert np.array_equal(tensor_from_numpy.data, numpy_array), "Tensor should preserve NumPy array data"
    
    # Test 2: Tensor arithmetic with NumPy-compatible operations
    a = Tensor([1.0, 2.0, 3.0])
    b = Tensor([4.0, 5.0, 6.0])
    
    # Test operations that would be used in neural networks
    dot_product_result = np.dot(a.data, b.data)  # Common in layers
    assert np.isclose(dot_product_result, 32.0), "Dot product should work with tensor data"
    
    # Test 3: Broadcasting compatibility
    matrix = Tensor([[1, 2], [3, 4]])
    scalar = Tensor(10)
    
    result = matrix + scalar
    expected = np.array([[11, 12], [13, 14]])
    assert np.array_equal(result.data, expected), "Broadcasting should work like NumPy"
    
    # Test 4: Integration with scientific computing patterns
    data = Tensor([1, 4, 9, 16, 25])
    sqrt_result = Tensor(np.sqrt(data.data))  # Using NumPy functions on tensor data
    expected_sqrt = np.array([1., 2., 3., 4., 5.])
    assert np.allclose(sqrt_result.data, expected_sqrt), "Should integrate with NumPy functions"
    
    print("✅ Integration Test Passed: Tensor-NumPy integration works correctly.")

# Run the integration test
test_module_tensor_numpy_integration()

🎯 MODULE SUMMARY: Tensor Foundation

Congratulations! You've successfully implemented the fundamental data structure that powers all machine learning:

What You've Built

  • Tensor Class: N-dimensional array wrapper with professional interfaces
  • Core Operations: Creation, property access, and arithmetic operations
  • Shape Management: Automatic shape tracking and validation
  • Data Types: Proper NumPy integration and type handling
  • Foundation: The building block for all subsequent TinyTorch modules

Key Learning Outcomes

  • Understanding: How tensors work as the foundation of machine learning
  • Implementation: Built tensor operations from scratch
  • Professional patterns: Clean APIs, proper error handling, comprehensive testing
  • Real-world connection: Understanding PyTorch/TensorFlow tensor foundations
  • Systems thinking: Building reliable, reusable components

Mathematical Foundations Mastered

  • N-dimensional arrays: Shape, size, and dimensionality concepts
  • Element-wise operations: Addition, subtraction, multiplication, division
  • Broadcasting: Understanding how operations work with different shapes
  • Memory management: Efficient data storage and access patterns

Professional Skills Developed

  • API design: Clean, intuitive interfaces for tensor operations
  • Error handling: Graceful handling of invalid operations and edge cases
  • Testing methodology: Comprehensive validation of tensor functionality
  • Documentation: Clear, educational documentation with examples

Ready for Advanced Applications

Your tensor implementation now enables:

  • Neural Networks: Foundation for all layer implementations
  • Automatic Differentiation: Gradient computation through computational graphs
  • Complex Models: CNNs, RNNs, Transformers - all built on tensors
  • Real Applications: Training models on real datasets

🔗 Connection to Real ML Systems

Your implementation mirrors production systems:

  • PyTorch: torch.Tensor provides identical functionality
  • TensorFlow: tf.Tensor implements similar concepts
  • NumPy: numpy.ndarray serves as the foundation
  • Industry Standard: Every major ML framework uses these exact principles

🎯 The Power of Tensors

You've built the fundamental data structure of modern AI:

  • Universality: Tensors represent all data: images, text, audio, video
  • Efficiency: Vectorized operations enable fast computation
  • Scalability: Handles everything from single numbers to massive matrices
  • Flexibility: Foundation for any mathematical operation

🚀 What's Next

Your tensor implementation is the foundation for:

  • Activations: Nonlinear functions that enable complex learning
  • Layers: Linear transformations and neural network building blocks
  • Networks: Composing layers into powerful architectures
  • Training: Optimizing networks to solve real problems

Next Module: Activation functions - adding the nonlinearity that makes neural networks powerful!

You've built the foundation of modern AI. Now let's add the mathematical functions that enable machines to learn complex patterns!