Thursday, August 20, 2026

TENSORS: A GUIDE FOR SOFTWARE ENGINEERS



PART ONE: FOUNDATION AND MOTIVATION

Before we dive into the fascinating world of tensors, let me ask you a question that might seem simple but is actually profound. When you look at a photograph on your computer, what is it really? On the surface, it is just an image displayed on a screen. But underneath, in the digital realm where computers operate, that image is stored as numbers—lots of numbers. If that photograph is a color image with dimensions of 1920 pixels wide and 1080 pixels tall, then you are looking at approximately 1920 times 1080 times 3, or about 6 million individual numbers, where each set of three numbers represents the intensity of red, green, and blue light for each pixel location.

Now here is where tensors enter the picture. A tensor is simply a structured way to organize and work with collections of numbers that have multiple dimensions. Just as we can think of a single number as a zero-dimensional object, a list of numbers as a one-dimensional object, and a grid of numbers as a two-dimensional object, tensors extend this idea to any number of dimensions. This concept is not new to mathematics, but it has become fundamental to modern software engineering, particularly in the fields of machine learning, image processing, scientific computing, and data analysis.

The reason tensors have become so prominent in recent years is not because they are fundamentally new, but because computers have become powerful enough to efficiently store and manipulate very large tensors, and because we have discovered that many real-world problems can be elegantly expressed and solved using tensor operations. When you train a neural network, process video frames, analyze climate data, or perform any sophisticated numerical computation, you are almost certainly working with tensors, whether you realize it or not.

This tutorial will take you on a journey from the simplest concepts—scalars and vectors—all the way to understanding how to use tensors effectively in real software systems. By the end, you will not only understand what tensors are mathematically, but you will also have practical knowledge of how to create them, manipulate them, and apply them to solve problems.

PART TWO: BUILDING BLOCKS—SCALARS, VECTORS, AND MATRICES

To understand tensors, we must first understand the simpler mathematical structures that tensors generalize. Think of these as building blocks that we will stack upon each other to construct increasingly complex concepts.

The simplest building block is the scalar. A scalar is just a single number. When you say the temperature is 25 degrees Celsius, or the price of a stock is 150 dollars, or the distance to the nearest star is 4.37 light-years, you are expressing a scalar. In the context of tensors, we call a scalar a zero-dimensional tensor because it has no dimensions—it is just a value. In code, a scalar might look like this:

value = 42

This is a scalar. It is a single piece of information with no structure beyond the value itself.

The next level of complexity is the vector. A vector is an ordered list of scalars arranged in a line. If you think of a scalar as a point, then a vector is a line of points. For instance, if you wanted to represent the coordinates of a location in three-dimensional space, you might use a vector with three elements representing the x, y, and z coordinates. A vector has one dimension. We call it a one-dimensional tensor. In code, a vector might look like this:

vector = [10, 20, 30, 40, 50]

This vector has five elements, and we say it has a shape of five or a length of five. Each element in the vector occupies a position, and we can refer to elements by their position. The element at position zero is 10, the element at position one is 20, and so on.

The next level is the matrix. A matrix is a rectangular grid of scalars arranged in rows and columns. If a vector is one-dimensional, a matrix is two-dimensional. Imagine a spreadsheet with rows and columns filled with numbers—that is a matrix. For instance, consider a matrix with three rows and four columns:

matrix = [
    [1, 2, 3, 4],
    [5, 6, 7, 8],
    [9, 10, 11, 12]
]

This matrix has a shape of three by four. We have three rows and four columns. We can refer to individual elements by specifying both the row and column. The element at row zero, column one is the number 2.

Now here is where the generalization begins. Both vectors and matrices are special cases of tensors. A vector is a one-dimensional tensor, and a matrix is a two-dimensional tensor. But tensors do not stop at two dimensions. We can extend this concept to three dimensions, four dimensions, five dimensions, and beyond. A three-dimensional tensor might represent a collection of matrices stacked on top of each other, or more intuitively, it might represent the pixel values in a color photograph where we have width, height, and color channel dimensions.

PART THREE: INTRODUCING TENSORS

Now we arrive at the core concept. A tensor is a mathematical object that generalizes scalars, vectors, and matrices to an arbitrary number of dimensions. Another word for tensor that you might encounter is array, and in the context of numerical computing, these terms are often used interchangeably.

Let us think about what a tensor really is at its core. A tensor is a collection of numbers organized into a specific structure with a definite number of dimensions. Each dimension has a size, and we refer to the collection of sizes as the shape of the tensor. For example, if we have a tensor with shape four by five by three, that tensor has three dimensions, the first with size four, the second with size five, and the third with size three. The total number of scalars in this tensor would be four times five times three, or sixty scalars.

One key insight is that tensors are fundamentally about organizing data in a way that makes computation efficient and conceptually clear. When you have a collection of numbers that you need to process, you want to organize them in a way that reflects the structure of the problem you are solving. A color photograph naturally has three dimensions: width, height, and color channel. A video sequence naturally has four dimensions: width, height, color channel, and time. A batch of images in a machine learning pipeline naturally has four dimensions: batch size, height, width, and color channel.

Here is a concrete example to illustrate this. Imagine you are processing financial data. You have data for ten stocks, where each stock has five pieces of information recorded daily for thirty days: opening price, closing price, high price, low price, and trading volume. You could organize this data as a tensor with shape ten by thirty by five. The first dimension represents which stock, the second dimension represents which day, and the third dimension represents which piece of information. This organization makes it natural to ask questions like "What was the closing price of stock number three on day number seven?" You would access this as tensor[3, 7, 1] if the closing price is the element at index one in the final dimension.

To make this more concrete, let us now work with actual code. We will use the NumPy library, which is a fundamental library in Python for numerical computing and tensor manipulation. NumPy is the foundation upon which many other libraries are built, so understanding how to use NumPy is essential for anyone working with tensors.

First, let us create a simple tensor using NumPy:

import numpy as np

tensor = np.array([[[1, 2, 3],
                    [4, 5, 6]],
                   [[7, 8, 9],
                    [10, 11, 12]]])

print(tensor.shape)

This code creates a three-dimensional tensor. We use the np.array function to create the tensor from a nested list structure. The shape of this tensor is two by two by three, meaning we have two elements in the first dimension, two elements in the second dimension, and three elements in the third dimension. When you run this code, it will print: (2, 2, 3)

What the shape tells us is that this tensor can be understood as a collection of two matrices, each with two rows and three columns. The total number of scalar values stored in this tensor is two times two times three, which equals twelve.

Now let us think about what each dimension represents. The first dimension might represent different groups or batches. The second dimension might represent different samples within each group. The third dimension might represent different features or measurements for each sample. The specific meaning depends entirely on the context of your problem.

Another important concept related to tensors is the idea of indexing and slicing. Just as you can access individual elements in a vector or matrix, you can access elements in higher-dimensional tensors. Let us create a simple tensor and access some elements:

import numpy as np

tensor = np.arange(24).reshape(2, 3, 4)

print(tensor[0, 0, 0])
print(tensor[1, 2, 3])
print(tensor[0, :, :])

The first line prints a single scalar element. tensor[0, 0, 0] accesses the element at position zero in all three dimensions, which would be the value 0. The second line accesses tensor[1, 2, 3], which would be at position one in the first dimension, position two in the second dimension, and position three in the third dimension. The third line is more interesting. By using a colon in place of an index, we are saying "give me all elements in this dimension." So tensor[0, :, :] gives us all elements from the first dimension at index zero, which is a matrix with shape three by four.

PART FOUR: KEY PROPERTIES AND CHARACTERISTICS OF TENSORS

Now that we have introduced the basic concept of tensors, let us dive deeper into their important properties and characteristics. Understanding these properties is crucial for working with tensors effectively.

The shape of a tensor, which we have already mentioned, is the first fundamental property. The shape is a tuple of integers that specifies the size of each dimension. For instance, if a tensor has shape (5, 10, 3), it means the first dimension has size five, the second dimension has size ten, and the third dimension has size three. The number of dimensions is called the rank of the tensor. In this example, the rank is three.

Another fundamental property is the total number of elements, which we calculate by multiplying all the dimensions together. A tensor with shape (5, 10, 3) contains five times ten times three, which equals one hundred fifty elements. All these elements are stored in memory contiguously, and the multidimensional structure is simply a way of organizing how we view and access this linear memory.

The data type of a tensor is another crucial property. A tensor might contain integer values, floating-point values, complex numbers, or even boolean values. Different data types require different amounts of memory. An integer might use four or eight bytes depending on whether it is a 32-bit or 64-bit integer. A floating-point number typically uses four bytes for single precision or eight bytes for double precision. When you create a tensor, the library will store all elements using the same data type for efficiency.

Let us examine these properties in code:

import numpy as np

tensor = np.ones((3, 4, 5), dtype=np.float32)

print("Shape:", tensor.shape)
print("Rank:", len(tensor.shape))
print("Total elements:", tensor.size)
print("Data type:", tensor.dtype)
print("Memory used:", tensor.nbytes, "bytes")

This code creates a tensor of ones with shape three by four by five using 32-bit floating-point numbers. When you run this code, it will output:

Shape: (3, 4, 5)
Rank: 3
Total elements: 60
Data type: float32
Memory used: 240 bytes

Notice that the memory used is 240 bytes. This is because we have sixty elements, and each 32-bit floating-point number uses four bytes, so sixty times four equals 240.

An important concept related to tensor properties is broadcasting. Broadcasting is a mechanism that allows operations between tensors of different shapes under certain conditions. When you perform an operation between two tensors, NumPy will automatically expand the smaller tensor to match the shape of the larger tensor if certain conditions are met. This is an incredibly powerful feature that can save you from writing explicit loops and reshaping code.

The broadcasting rules work as follows. When performing an operation between two tensors, NumPy will compare their shapes element-wise starting from the rightmost dimension. The sizes must be either equal, or one of them must be one, or one of the dimensions must not exist. If either of these conditions is true for each dimension, the tensors are compatible for broadcasting.

Let us see broadcasting in action:

import numpy as np

tensor_a = np.array([[[1, 2, 3]],
                     [[4, 5, 6]]])

tensor_b = np.array([10])

result = tensor_a + tensor_b

print(result.shape)
print(result)

In this code, tensor_a has shape (2, 1, 3) and tensor_b has shape (1). When we add them, the smaller tensor is broadcast to match the shape of the larger tensor. The result has shape (2, 1, 3). The scalar value ten is added to every element of tensor_a.

Another operation we need to understand is reshaping. Reshaping a tensor means changing its dimensions without changing the underlying data. The total number of elements must remain the same. For instance, a tensor with shape (2, 3, 4) contains twenty-four elements. You can reshape it to shape (6, 4) or shape (24,) or shape (2, 2, 6), but you cannot reshape it to shape (2, 2, 5) because that would require a different number of elements.

Let us see reshaping in action:

import numpy as np

tensor = np.arange(24).reshape(2, 3, 4)

print("Original shape:", tensor.shape)

reshaped_tensor = tensor.reshape(6, 4)

print("Reshaped shape:", reshaped_tensor.shape)
print("Are they the same data?", np.shares_memory(tensor, reshaped_tensor))

When you reshape a tensor, you are not copying the data. You are simply changing how the data is viewed. The underlying memory is the same, and both the original tensor and the reshaped tensor refer to the same data. This is why reshaping is very efficient—it does not require any data movement.

Transposing is another important operation. Transposing a tensor means rearranging its dimensions. For a matrix, transposing swaps rows and columns. For higher-dimensional tensors, we can transpose any permutation of dimensions we want. This operation can be useful when you need to rearrange your data to match a certain format required by a function or algorithm.

Let us see transposing in action:

import numpy as np

tensor = np.arange(12).reshape(2, 3, 2)

print("Original shape:", tensor.shape)
print("Original tensor:")
print(tensor)

transposed = np.transpose(tensor, (2, 0, 1))

print("\nTransposed shape:", transposed.shape)
print("Transposed tensor:")
print(transposed)

In this code, we start with a tensor of shape (2, 3, 2). We then transpose it using the permutation (2, 0, 1). This means the new first dimension will be the old third dimension, the new second dimension will be the old first dimension, and the new third dimension will be the old second dimension. The result has shape (2, 2, 3).

PART FIVE: OPERATIONS ON TENSORS

Tensors are not just about storing data. They are about efficiently computing with data. There are many operations you can perform on tensors, and understanding these operations is essential for working with tensors effectively.

The most basic operations are element-wise operations. These are operations that apply the same operation to every element in the tensor independently. For instance, you can add two tensors of the same shape element-wise, subtract them, multiply them, divide them, and so on. You can also apply mathematical functions element-wise, such as taking the square root, the logarithm, the sine, or the exponential of each element.

Let us see element-wise operations in action:

import numpy as np

tensor_a = np.array([1.0, 2.0, 3.0, 4.0])
tensor_b = np.array([5.0, 6.0, 7.0, 8.0])

sum_result = tensor_a + tensor_b
difference = tensor_a - tensor_b
product = tensor_a * tensor_b
quotient = tensor_a / tensor_b

print("Sum:", sum_result)
print("Difference:", difference)
print("Product:", product)
print("Quotient:", quotient)

These operations are very fast because they can be heavily optimized by modern CPUs and especially by GPUs. When you add two tensors, the computer does not execute one addition at a time. Instead, it uses vectorized instructions that can perform multiple additions in parallel.

Moving beyond element-wise operations, we have reduction operations. A reduction operation takes a tensor with multiple elements and produces a result with fewer elements by combining elements along one or more dimensions. Examples include summing all elements in a tensor, finding the maximum or minimum element, computing the mean or standard deviation, or concatenating elements along a dimension.

Let us see some reduction operations:

import numpy as np

tensor = np.array([[1, 2, 3],
                   [4, 5, 6],
                   [7, 8, 9]])

print("Sum of all elements:", np.sum(tensor))
print("Sum along dimension 0:", np.sum(tensor, axis=0))
print("Sum along dimension 1:", np.sum(tensor, axis=1))
print("Mean:", np.mean(tensor))
print("Max:", np.max(tensor))
print("Min:", np.min(tensor))

When you sum all elements, you get a single scalar value—the sum of all thirty-five elements in the tensor. When you sum along dimension zero, you sum down the rows, collapsing the first dimension and producing a result with shape (3,). When you sum along dimension one, you sum across the columns, collapsing the second dimension and producing a result with shape (3,).

Another category of operations is matrix operations. Despite the name, these operations are not limited to matrices. They can be applied to tensors of any rank as long as certain shape conditions are met. The most important matrix operation is the matrix multiplication or dot product operation.

The dot product of two vectors is computed by multiplying corresponding elements and summing the results. If you have vector A with elements [a1, a2, a3] and vector B with elements [b1, b2, b3], the dot product is a1 times b1 plus a2 times b2 plus a3 times b3. In the context of matrices, matrix multiplication multiplies rows of the first matrix by columns of the second matrix.

For a simple case where we multiply two matrices, if matrix A has shape (m, n) and matrix B has shape (n, k), the result has shape (m, k). Each element (i, j) in the result is the dot product of row i from matrix A and column j from matrix B.

Let us see matrix multiplication in action:

import numpy as np

matrix_a = np.array([[1, 2, 3],
                     [4, 5, 6]])

matrix_b = np.array([[7, 8],
                     [9, 10],
                     [11, 12]])

result = np.matmul(matrix_a, matrix_b)

print("Shape of A:", matrix_a.shape)
print("Shape of B:", matrix_b.shape)
print("Shape of result:", result.shape)
print("Result:")
print(result)

Matrix A has shape (2, 3) and matrix B has shape (3, 2). When we multiply them, we get a result with shape (2, 2). The element at position (0, 0) in the result is the dot product of row 0 of A and column 0 of B, which is one times seven plus two times nine plus three times eleven, equals fifty-eight.

For higher-dimensional tensors, matrix multiplication becomes batch matrix multiplication. If you have two three-dimensional tensors where the last two dimensions match the matrix multiplication requirements, the operation is performed for each pair of matrices in the batch. This is incredibly useful for processing multiple data samples simultaneously.

Let us see batch matrix multiplication:

import numpy as np

tensor_a = np.random.randn(5, 3, 4)
tensor_b = np.random.randn(5, 4, 2)

result = np.matmul(tensor_a, tensor_b)

print("Shape of A:", tensor_a.shape)
print("Shape of B:", tensor_b.shape)
print("Shape of result:", result.shape)

Here, tensor_a has shape (5, 3, 4) and tensor_b has shape (5, 4, 2). The operation performs five matrix multiplications, one for each value in the batch dimension. Each multiplication multiplies a (3, 4) matrix by a (4, 2) matrix, producing a (3, 2) result. The final result has shape (5, 3, 2).

Another important set of operations involves changing the structure of tensors. One such operation is concatenation, which joins multiple tensors along an existing dimension. Another is stacking, which joins multiple tensors along a new dimension.

Let us see concatenation and stacking:

import numpy as np

tensor_a = np.array([[1, 2],
                     [3, 4]])

tensor_b = np.array([[5, 6],
                     [7, 8]])

concatenated = np.concatenate([tensor_a, tensor_b], axis=0)

print("Concatenated along axis 0:")
print(concatenated)
print("Shape:", concatenated.shape)

stacked = np.stack([tensor_a, tensor_b], axis=0)

print("\nStacked along axis 0:")
print(stacked)
print("Shape:", stacked.shape)

Concatenation along axis zero joins the tensors vertically, treating them as rows to be stacked. The result has shape (4, 2). Stacking along axis zero creates a new dimension and places the original tensors along that dimension. The result has shape (2, 2, 2).

PART SIX: UNDERSTANDING TENSOR RANK AND DIMENSIONS

To deepen your understanding of tensors, let us spend some time thinking carefully about rank and dimensions. These concepts are fundamental, but they can be confusing if not explained clearly.

The rank of a tensor is the number of dimensions it has. A scalar is a rank-zero tensor. A vector is a rank-one tensor. A matrix is a rank-two tensor. A three-dimensional array is a rank-three tensor. And so on. The rank tells you how many indices you need to specify to access a single element. To access an element in a rank-two tensor (matrix), you need two indices: the row and the column. To access an element in a rank-three tensor, you need three indices.

Each dimension has a size, which is the number of elements along that dimension. The shape of a tensor is the collection of all dimension sizes. When we say a tensor has shape (2, 3, 4), we mean it has three dimensions: the first dimension has size two, the second dimension has size three, and the third dimension has size four.

Now here is an important distinction that many people get confused about. The rank is not the same as the shape, and the rank is not the same as the total number of elements. The rank is the number of dimensions. The shape specifies the size of each dimension. The total number of elements is the product of all dimension sizes.

Let us clarify this with examples:

import numpy as np

tensor_1d = np.array([1, 2, 3, 4, 5])
print("1D array:")
print("Rank:", tensor_1d.ndim)
print("Shape:", tensor_1d.shape)
print("Total elements:", tensor_1d.size)

tensor_2d = np.array([[1, 2, 3],
                      [4, 5, 6]])
print("\n2D array:")
print("Rank:", tensor_2d.ndim)
print("Shape:", tensor_2d.shape)
print("Total elements:", tensor_2d.size)

tensor_3d = np.arange(24).reshape(2, 3, 4)
print("\n3D array:")
print("Rank:", tensor_3d.ndim)
print("Shape:", tensor_3d.shape)
print("Total elements:", tensor_3d.size)

The output will show that the one-dimensional array has rank one, shape (5,), and five total elements. The two-dimensional array has rank two, shape (2, 3), and six total elements. The three-dimensional array has rank three, shape (2, 3, 4), and twenty-four total elements.

Understanding the relationship between indexing and dimensions is also crucial. When you index into a tensor, you are essentially fixing values for certain dimensions, which reduces the rank. If you have a three-dimensional tensor and you specify an index for the first dimension, you get a two-dimensional tensor. If you specify indices for the first two dimensions, you get a one-dimensional tensor. If you specify indices for all three dimensions, you get a scalar.

Let us see this in action:

import numpy as np

tensor_3d = np.arange(24).reshape(2, 3, 4)

print("Original tensor shape:", tensor_3d.shape)

tensor_2d = tensor_3d[0]
print("After indexing first dimension:", tensor_2d.shape)

tensor_1d = tensor_3d[0, 1]
print("After indexing first two dimensions:", tensor_1d.shape)

scalar = tensor_3d[0, 1, 2]
print("After indexing all dimensions:", type(scalar), scalar)

When we index the first dimension with [0], we get a two-dimensional tensor with shape (3, 4). When we index the first two dimensions with [0, 1], we get a one-dimensional tensor with shape (4,). When we index all three dimensions with [0, 1, 2], we get a scalar value.

This ability to reduce rank through indexing is incredibly useful in practice. It allows you to extract specific subsets of data and work with them using tensor operations.

PART SEVEN: REAL-WORLD APPLICATIONS OF TENSORS

Now that we understand what tensors are and how to work with them, let us explore where and why tensors are used in real-world applications. Understanding the practical motivation behind tensor usage will deepen your appreciation for why this concept is so fundamental.

In image processing, tensors are the natural data structure. A color image is a three-dimensional tensor where the first dimension is the height (number of rows of pixels), the second dimension is the width (number of columns of pixels), and the third dimension is the color channels (typically three for red, green, and blue). If you have a batch of images to process, you add a fourth dimension for the batch. This four-dimensional tensor has shape (batch_size, height, width, channels) or sometimes (batch_size, channels, height, width) depending on the convention used by different libraries.

When you apply filters to images, such as blur, edge detection, or color correction, you are performing tensor operations. A blur filter, for instance, takes a neighborhood of pixels and averages them. An edge detection filter computes differences between neighboring pixels. All of these operations can be expressed as tensor operations applied to the image tensor.

In machine learning and deep learning, tensors are absolutely fundamental. A neural network is essentially a series of transformations applied to tensors. The input to a neural network is typically a tensor representing your data. If you are classifying images, the input is a four-dimensional tensor of images. If you are processing text, the input might be a two-dimensional or three-dimensional tensor where each element represents a word or character encoded as a number. As data flows through the network, it is transformed by matrix multiplications, element-wise operations, and other tensor operations. The output is another tensor representing the network's predictions.

In scientific computing, tensors are used extensively. If you are simulating a three-dimensional fluid flow, you might represent the velocity field as a four-dimensional tensor where the first three dimensions are spatial coordinates and the fourth dimension represents the three velocity components. If you are performing finite element analysis, you might use tensors to store stress and strain tensors at each point in the domain. Quantum mechanics relies heavily on tensor notation to describe quantum states and operations.

In natural language processing, tensors represent sequences of words or characters. Each word might be encoded as a vector of numbers representing its semantic meaning. A sentence is a two-dimensional tensor where each row is a word vector. A paragraph or document is a three-dimensional tensor where each matrix is a sentence. This representation allows you to perform operations on text data using tensor operations.

In time series analysis and data science, tensors represent data collected over time. If you are analyzing stock prices, you might have a three-dimensional tensor where the first dimension is different stocks, the second dimension is time, and the third dimension is different measurements (open, close, high, low, volume). This representation makes it natural to ask questions like "What was the pattern of returns for all stocks over the past month?" which can be answered using tensor slicing and reduction operations.

The power of tensors comes from the fact that many real-world data naturally have multiple dimensions, and organizing data as tensors allows us to work with this multi-dimensional structure efficiently using optimized tensor libraries. Instead of writing loops to iterate through data, we can express operations using tensor notation, which is both more readable and much more efficient because tensor libraries can optimize these operations for modern hardware like GPUs.

PART EIGHT: TENSOR LIBRARIES AND TOOLS

In practice, you do not work directly with the mathematical definition of tensors. Instead, you use libraries and tools that provide efficient implementations of tensors and tensor operations. Let us discuss the main libraries you will encounter.

NumPy is the foundation of numerical computing in Python. It provides the ndarray data structure, which is NumPy's implementation of a tensor, and it provides a comprehensive set of functions for creating, manipulating, and operating on tensors. NumPy is highly optimized for CPU computation and is the standard library for numerical computing in Python. If you are doing numerical computing, data analysis, or scientific computing, you will almost certainly use NumPy.

TensorFlow is a library developed by Google for machine learning and deep learning. It provides high-level APIs for building neural networks and other machine learning models, but under the hood, everything is based on tensors. TensorFlow can run computations on CPUs or GPUs, and it provides automatic differentiation, which is essential for training neural networks. TensorFlow is widely used in production systems and is particularly good for deploying models at scale.

PyTorch is a library developed by Meta (formerly Facebook) for machine learning and scientific computing. Like TensorFlow, it is built on tensors, but it has a more intuitive and dynamic interface that many researchers and developers prefer. PyTorch has become very popular in research environments and is increasingly used in production as well.

JAX is a library that provides numerical computing similar to NumPy, but with additional features like automatic differentiation and just-in-time compilation. JAX is designed to be functional and composable, and it can provide very high-performance computing, especially when combined with its JIT compilation capabilities.

CuPy is a NumPy-like library that runs on NVIDIA GPUs. If you have GPU hardware and want to accelerate NumPy-like operations, CuPy provides the same interface as NumPy but executes on the GPU.

Dask is a library for parallel and distributed computing with tensors and dataframes. If you have tensors that are too large to fit in memory on a single machine, Dask allows you to distribute the computation across multiple machines.

For specific domains, there are specialized libraries. OpenCV is used for computer vision and image processing. Scikit-image provides image processing functions. Scikit-learn provides machine learning algorithms that work with tensors represented as NumPy arrays. For deep learning research, many researchers use PyTorch or TensorFlow depending on their preference.

Understanding the basic concepts of tensors is essential because these concepts are shared across all these libraries. Once you understand what a tensor is and how tensor operations work, you can learn any of these libraries relatively easily because the underlying concepts are the same.

PART NINE: PRACTICAL TENSOR OPERATIONS IN DEPTH

Let us now work through some practical tensor operations in greater depth. These operations represent tasks you will frequently encounter when working with real data.

One common task is normalization. When you have a tensor containing raw data values, you often want to normalize them before using them in algorithms. Normalization typically means subtracting the mean and dividing by the standard deviation, which results in data with mean zero and standard deviation one. This is important because algorithms like neural networks often perform better when data is normalized.

Let us see normalization in action:

import numpy as np

data = np.array([[1.0, 2.0, 3.0],
                 [4.0, 5.0, 6.0],
                 [7.0, 8.0, 9.0]])

mean = np.mean(data, axis=0)
std = np.std(data, axis=0)

normalized_data = (data - mean) / std

print("Original data:")
print(data)
print("\nMean:", mean)
print("Std:", std)
print("\nNormalized data:")
print(normalized_data)

When we compute the mean and std along axis zero, we are computing statistics for each column separately. The result is a one-dimensional tensor with shape (3,). When we subtract the mean and divide by the std, broadcasting automatically expands these one-dimensional tensors to match the shape of the original data.

Another important operation is slicing and indexing with more complex patterns. While basic indexing allows you to access specific elements or ranges, advanced indexing allows you to select elements based on conditions or to select non-contiguous elements.

Let us see advanced indexing:

import numpy as np

data = np.array([[1, 2, 3, 4],
                 [5, 6, 7, 8],
                 [9, 10, 11, 12]])

mask = data > 5

selected = data[mask]

print("Original data:")
print(data)
print("\nMask (data > 5):")
print(mask)
print("\nSelected elements:")
print(selected)

In this code, we create a boolean mask where each element is True if the corresponding element in data is greater than five, and False otherwise. When we index the data tensor with this mask, we get a one-dimensional tensor containing only the elements where the mask is True. This is incredibly useful for filtering data based on conditions.

Another common operation is reshaping and rearranging data. Sometimes you need to combine multiple tensors into a single tensor, or split a tensor into multiple pieces, or rearrange the order of dimensions.

Let us see some rearrangement operations:

import numpy as np

tensor_a = np.arange(6).reshape(2, 3)
tensor_b = np.arange(6, 12).reshape(2, 3)

stacked = np.stack([tensor_a, tensor_b], axis=0)

print("Tensor A:")
print(tensor_a)
print("\nTensor B:")
print(tensor_b)
print("\nStacked shape:", stacked.shape)
print("Stacked:")
print(stacked)

squeezed = np.squeeze(stacked, axis=0)

print("\nAfter squeeze:")
print(squeezed.shape)

The stack operation creates a new dimension and places the tensors along that dimension. The squeeze operation removes dimensions of size one. In this case, after stacking along axis zero, we have a tensor with shape (2, 2, 3). Squeezing axis zero removes the first dimension if it has size one.

One more important operation is sorting and finding elements. These operations are useful when you want to find the maximum or minimum values, or when you want to arrange elements in order.

Let us see sorting operations:

import numpy as np

data = np.array([[3, 1, 4],
                 [1, 5, 9],
                 [2, 6, 5]])

sorted_indices = np.argsort(data, axis=1)

print("Original data:")
print(data)
print("\nIndices that would sort each row:")
print(sorted_indices)

max_values = np.max(data, axis=1)
max_indices = np.argmax(data, axis=1)

print("\nMax values in each row:", max_values)
print("Indices of max values:", max_indices)

The argsort function returns the indices that would sort the array along a given axis. The argmax function returns the index of the maximum value along an axis.

PART TEN: WORKING WITH MULTIDIMENSIONAL DATA

Let us now consider how to work effectively with tensors that have many dimensions. As the number of dimensions increases, it becomes harder to visualize what the tensor looks like, but the operations remain the same.

Consider a practical example. You are building a recommendation system that processes user-item interaction data over time. You have data for one thousand users, five thousand items, and thirty days of history. For each user-item pair on each day, you have ten different metrics (clicks, purchases, time spent, etc.). Your data naturally forms a five-dimensional tensor with shape (1000, 5000, 30, 10).

Wait, that is almost two billion elements if we store them all. In practice, you would not store zeros for all the missing interactions. Instead, you would use sparse tensor representations that only store non-zero elements. But for the purposes of understanding how to work with multidimensional data, let us think about how you would compute things on this tensor.

Suppose you want to compute the total number of clicks for each user. Clicks are at position zero in the last dimension. You would do something like this:

import numpy as np

data_shape = (1000, 5000, 30, 10)

data = np.random.randn(*data_shape)

clicks_metric_index = 0

user_clicks = np.sum(data[:, :, :, clicks_metric_index], axis=(1, 2))

print("Shape of user clicks:", user_clicks.shape)

Here, we first index the last dimension to extract only the clicks metric, which gives us a three-dimensional tensor with shape (1000, 5000, 30). Then we sum along dimensions one and two (items and days), leaving only the user dimension. The result has shape (1000), containing the total clicks for each user.

When working with very high-dimensional tensors, understanding which dimension is which becomes crucial. It is often helpful to document the meaning of each dimension explicitly in your code through comments or by using named indices.

Let us implement this with clearer structure:

import numpy as np

data = np.random.randn(1000, 5000, 30, 10)

USERS_DIM = 0
ITEMS_DIM = 1
DAYS_DIM = 2
METRICS_DIM = 3

CLICKS_METRIC = 0
PURCHASES_METRIC = 1
TIME_SPENT_METRIC = 2

clicks = data[:, :, :, CLICKS_METRIC]

user_total_clicks = np.sum(clicks, axis=(ITEMS_DIM, DAYS_DIM))

print("User total clicks shape:", user_total_clicks.shape)

By using named constants for dimensions and metrics, the code becomes much more readable and maintainable. Someone reading this code can immediately understand what dimensions are being manipulated.

Another important consideration when working with multidimensional data is memory layout and performance. While NumPy abstracts away many details, understanding how data is stored in memory can affect performance when you work with very large tensors.

Tensors are typically stored in row-major order (C order) or column-major order (Fortran order). In row-major order, rows are stored contiguously in memory. In column-major order, columns are stored contiguously. When you iterate through a tensor, accessing elements in the order they are stored in memory is much faster than jumping around randomly because of how modern computer caches work.

Let us see how memory layout affects performance:

import numpy as np

tensor = np.random.randn(10000, 10000)

import time

start = time.time()
result_1 = np.sum(tensor, axis=0)
time_1 = time.time() - start

start = time.time()
result_2 = np.sum(tensor, axis=1)
time_2 = time.time() - start

print(f"Sum along axis 0: {time_1:.4f} seconds")
print(f"Sum along axis 1: {time_2:.4f} seconds")

On most systems, summing along axis zero (which means accessing elements from different rows in the same column) will be slower than summing along axis one (which means accessing contiguous elements in the same row) because of memory layout.

In most cases, NumPy handles these details well, and you do not need to worry about memory layout. But in performance-critical code dealing with very large tensors, it is worth being aware of these considerations.

PART ELEVEN: TENSOR OPERATIONS APPLIED TO REAL PROBLEMS

Now let us work through a more complete example that demonstrates how tensor operations are used to solve a real problem. We will build a system that processes image data and applies transformations to it.

Imagine you are building an image preprocessing pipeline for a machine learning system. You receive a batch of images, and you need to perform several preprocessing steps: reading the images, converting them to a standard size, normalizing the pixel values, and augmenting the data by applying random transformations.

Let us start by creating some synthetic image data that represents what you would get from reading actual images:

import numpy as np

def create_sample_images(num_images, height, width):
    images = np.random.randint(0, 256, (num_images, height, width, 3), dtype=np.uint8)
    return images

images = create_sample_images(10, 256, 256)

print("Images shape:", images.shape)
print("Data type:", images.dtype)
print("Min pixel value:", np.min(images))
print("Max pixel value:", np.max(images))

Now we have ten images, each with dimensions 256 by 256 pixels and three color channels. The pixel values are stored as unsigned 8-bit integers ranging from 0 to 255.

The next step is to normalize the pixel values. Normalizing means converting them from the range 0 to 255 to a range more suitable for machine learning, typically 0 to 1 or -1 to 1.

def normalize_images(images):
    normalized = images.astype(np.float32) / 255.0
    return normalized

normalized_images = normalize_images(images)

print("Normalized images shape:", normalized_images.shape)
print("Data type:", normalized_images.dtype)
print("Min pixel value:", np.min(normalized_images))
print("Max pixel value:", np.max(normalized_images))

We convert the data type to float32 and divide by 255 to bring values into the range 0 to 1.

Next, let us apply a simple augmentation: random horizontal flips. This is a common technique in machine learning where you randomly flip images horizontally to increase the diversity of your training data.

def apply_random_horizontal_flip(images, probability=0.5):
    flipped_images = images.copy()
    for i in range(images.shape[0]):
        if np.random.random() < probability:
            flipped_images[i] = np.fliplr(flipped_images[i])
    return flipped_images

augmented_images = apply_random_horizontal_flip(normalized_images)

print("Augmented images shape:", augmented_images.shape)

The np.fliplr function flips an array left to right. We iterate through each image in the batch and apply the flip with a given probability.

Another common augmentation is adjusting the brightness. We can do this by adding or subtracting a random value to each pixel, being careful not to go outside the valid range.

def apply_random_brightness(images, max_adjustment=0.2):
    adjusted_images = images.copy()
    for i in range(images.shape[0]):
        adjustment = np.random.uniform(-max_adjustment, max_adjustment)
        adjusted_images[i] = np.clip(images[i] + adjustment, 0, 1)
    return adjusted_images

brightness_adjusted = apply_random_brightness(normalized_images)

print("Brightness adjusted shape:", brightness_adjusted.shape)

The np.clip function ensures values stay within the range 0 to 1.

Finally, let us compute some statistics about the images that might be useful for understanding the dataset.

def compute_image_statistics(images):
    mean = np.mean(images)
    std = np.std(images)
    mean_per_channel = np.mean(images, axis=(0, 1, 2))
    std_per_channel = np.std(images, axis=(0, 1, 2))

    return {
        'overall_mean': mean,
        'overall_std': std,
        'mean_per_channel': mean_per_channel,
        'std_per_channel': std_per_channel
    }

stats = compute_image_statistics(normalized_images)

print("Overall mean:", stats['overall_mean'])
print("Overall std:", stats['overall_std'])
print("Mean per channel:", stats['mean_per_channel'])
print("Std per channel:", stats['std_per_channel'])

When we compute statistics, we specify which axes to reduce. Summing or averaging over axes zero, one, and two leaves only the channel dimension, giving us per-channel statistics.

This example demonstrates how tensor operations are used in practice to preprocess data. Each operation transforms the tensor in a meaningful way, and by combining these operations, we build a complete preprocessing pipeline.

PART TWELVE: ADVANCED CONCEPTS AND PATTERNS

As you become more comfortable with tensors, you will encounter more advanced concepts and patterns. Let us discuss some of these.

One important concept is vectorization. Vectorization means writing code that operates on entire tensors at once rather than writing loops that process individual elements or small chunks. Vectorized code is not only more readable, but it is also much faster because tensor libraries can optimize vectorized operations much better than they can optimize loops.

Let us see an example. Suppose you want to compute the distance from each point in a set of points to a reference point. A naive approach might use a loop:

import numpy as np

def compute_distances_with_loop(points, reference):
    distances = np.zeros(points.shape[0])
    for i in range(points.shape[0]):
        distances[i] = np.linalg.norm(points[i] - reference)
    return distances

def compute_distances_vectorized(points, reference):
    return np.linalg.norm(points - reference, axis=1)

points = np.random.randn(10000, 3)
reference = np.array([0, 0, 0])

distances_1 = compute_distances_with_loop(points, reference)
distances_2 = compute_distances_vectorized(points, reference)

print("Distances are equal:", np.allclose(distances_1, distances_2))

The vectorized version is much simpler and much faster. It uses broadcasting to subtract the reference point from all points at once, then computes the norm along the second axis.

Another advanced concept is the use of einsum, which stands for Einstein summation. Einsum provides a concise notation for specifying tensor operations based on index notation. It allows you to express complex operations in a single line.

Let us see einsum in action:

import numpy as np

matrix_a = np.random.randn(3, 4)
matrix_b = np.random.randn(4, 5)

result_1 = np.matmul(matrix_a, matrix_b)

result_2 = np.einsum('ij,jk->ik', matrix_a, matrix_b)

print("Results are equal:", np.allclose(result_1, result_2))

The einsum notation 'ij,jk->ik' specifies that we are multiplying a tensor with indices i and j by a tensor with indices j and k, producing a result with indices i and k. The j index appears in both input tensors but not in the output, which means it is summed over.

One more advanced pattern is the use of apply_along_axis, which allows you to apply a function to slices of a tensor along a specified axis.

import numpy as np

data = np.random.randn(5, 10)

def custom_function(vector):
    return np.mean(vector) + np.std(vector)

results = np.apply_along_axis(custom_function, 1, data)

print("Results shape:", results.shape)
print("Results:", results)

The apply_along_axis function applies the custom function to each row of the data tensor. The result is a one-dimensional tensor with one element per row.

PART THIRTEEN: THE FULL WORKING EXAMPLE

Now, let us present a complete, production-ready implementation that demonstrates the comprehensive use of tensors in a realistic scenario. This example implements an image processing pipeline that can handle various types of image processing tasks.

The example includes code that can be run as-is, with all necessary imports, data handling, and error checking in place. The code follows clean code principles with clear function names, documentation, and logical organization.


COMPLETE IMAGE PROCESSING PIPELINE WITH TENSORS

import numpy as np
import os
from typing import Tuple, Dict, List, Optional

class ImageProcessor:
    """
    A comprehensive image processing system using tensors.

    This class provides methods for loading, preprocessing, augmenting,
    and analyzing image data using NumPy tensor operations. It demonstrates
    practical tensor manipulation patterns used in real image processing
    pipelines.
    """

    def __init__(self, default_dtype: np.dtype = np.float32):
        """
        Initialize the ImageProcessor with a default data type.

        Args:
            default_dtype: The default NumPy data type for processed images.
        """
        self.default_dtype = default_dtype
        self.statistics_cache = None

    def create_synthetic_images(self,
                               num_images: int,
                               height: int,
                               width: int) -> np.ndarray:
        """
        Create synthetic image data for testing and demonstration.

        Args:
            num_images: Number of images to create.
            height: Height of each image in pixels.
            width: Width of each image in pixels.

        Returns:
            A tensor of shape (num_images, height, width, 3) containing
            random image data with values in range 0-255.
        """
        images = np.random.randint(
            0, 256,
            size=(num_images, height, width, 3),
            dtype=np.uint8
        )
        return images

    def normalize_images(self,
                        images: np.ndarray,
                        method: str = 'minmax') -> np.ndarray:
        """
        Normalize image pixel values to a standard range.

        Args:
            images: Tensor of shape (N, H, W, C) with uint8 values.
            method: Normalization method ('minmax' for 0-1 or 'zscore'
                    for zero mean unit variance).

        Returns:
            Normalized images as float32 tensor.

        Raises:
            ValueError: If method is not recognized.
        """
        if images.dtype != np.uint8:
            images = images.astype(np.uint8)

        if method == 'minmax':
            normalized = images.astype(self.default_dtype) / 255.0
        elif method == 'zscore':
            images_float = images.astype(self.default_dtype)
            mean = np.mean(images_float)
            std = np.std(images_float)
            if std == 0:
                std = 1.0
            normalized = (images_float - mean) / std
        else:
            raise ValueError(f"Unknown normalization method: {method}")

        return normalized

    def apply_horizontal_flip(self,
                             images: np.ndarray,
                             probability: float = 0.5) -> np.ndarray:
        """
        Randomly flip images horizontally.

        Args:
            images: Tensor of shape (N, H, W, C).
            probability: Probability of flipping each image.

        Returns:
            Images with random horizontal flips applied.

        Raises:
            ValueError: If probability is not between 0 and 1.
        """
        if not 0 <= probability <= 1:
            raise ValueError("Probability must be between 0 and 1")

        flipped = images.copy()
        num_images = images.shape[0]

        for i in range(num_images):
            if np.random.random() < probability:
                flipped[i] = np.fliplr(images[i])

        return flipped

    def apply_brightness_adjustment(self,
                                   images: np.ndarray,
                                   max_delta: float = 0.2) -> np.ndarray:
        """
        Randomly adjust brightness of images.

        Args:
            images: Tensor of shape (N, H, W, C) with values in [0, 1].
            max_delta: Maximum brightness adjustment amount.

        Returns:
            Images with random brightness adjustments applied.

        Raises:
            ValueError: If max_delta is negative.
        """
        if max_delta < 0:
            raise ValueError("max_delta must be non-negative")

        adjusted = images.copy()
        num_images = images.shape[0]

        for i in range(num_images):
            delta = np.random.uniform(-max_delta, max_delta)
            adjusted[i] = np.clip(images[i] + delta, 0, 1)

        return adjusted

    def apply_contrast_adjustment(self,
                                 images: np.ndarray,
                                 contrast_factor: float = 0.2) -> np.ndarray:
        """
        Randomly adjust contrast of images.

        Args:
            images: Tensor of shape (N, H, W, C) with values in [0, 1].
            contrast_factor: Amount to vary contrast. Higher values mean
                            more variation.

        Returns:
            Images with random contrast adjustments applied.

        Raises:
            ValueError: If contrast_factor is negative.
        """
        if contrast_factor < 0:
            raise ValueError("contrast_factor must be non-negative")

        adjusted = images.copy()
        num_images = images.shape[0]

        for i in range(num_images):
            factor = np.random.uniform(
                1 - contrast_factor,
                1 + contrast_factor
            )
            mean = np.mean(images[i])
            adjusted[i] = np.clip(
                (images[i] - mean) * factor + mean,
                0, 1
            )

        return adjusted

    def apply_gaussian_blur(self,
                           images: np.ndarray,
                           sigma: float = 1.0) -> np.ndarray:
        """
        Apply Gaussian blur to images using a simple convolution approach.

        Args:
            images: Tensor of shape (N, H, W, C) with values in [0, 1].
            sigma: Standard deviation of the Gaussian kernel.

        Returns:
            Blurred images.

        Raises:
            ValueError: If sigma is negative.
        """
        if sigma < 0:
            raise ValueError("sigma must be non-negative")

        if sigma == 0:
            return images.copy()

        kernel_size = int(np.ceil(sigma * 4)) * 2 + 1
        kernel_range = np.arange(kernel_size) - (kernel_size // 2)
        kernel_1d = np.exp(-(kernel_range ** 2) / (2 * sigma ** 2))
        kernel_1d = kernel_1d / np.sum(kernel_1d)

        blurred = images.copy()
        num_images = images.shape[0]
        height, width, channels = images.shape[1:]

        for i in range(num_images):
            for c in range(channels):
                temp = blurred[i, :, :, c].astype(np.float64)

                for k in range(kernel_size):
                    shift = k - (kernel_size // 2)
                    if shift == 0:
                        continue
                    temp = temp + (
                        np.roll(images[i, :, :, c], shift, axis=0) *
                        kernel_1d[k]
                    )

                blurred[i, :, :, c] = np.clip(temp, 0, 1)

        return blurred

    def resize_images(self,
                     images: np.ndarray,
                     new_height: int,
                     new_width: int) -> np.ndarray:
        """
        Resize images to a new shape using nearest-neighbor interpolation.

        Args:
            images: Tensor of shape (N, H, W, C).
            new_height: Target height.
            new_width: Target width.

        Returns:
            Resized images with shape (N, new_height, new_width, C).

        Raises:
            ValueError: If dimensions are invalid.
        """
        if new_height <= 0 or new_width <= 0:
            raise ValueError("Dimensions must be positive")

        num_images, orig_height, orig_width, channels = images.shape
        resized = np.zeros(
            (num_images, new_height, new_width, channels),
            dtype=images.dtype
        )

        row_indices = (
            np.arange(new_height) * orig_height // new_height
        )
        col_indices = (
            np.arange(new_width) * orig_width // new_width
        )

        for i in range(num_images):
            for new_h in range(new_height):
                for new_w in range(new_width):
                    orig_h = row_indices[new_h]
                    orig_w = col_indices[new_w]
                    resized[i, new_h, new_w, :] = (
                        images[i, orig_h, orig_w, :]
                    )

        return resized

    def compute_statistics(self,
                          images: np.ndarray) -> Dict[str, np.ndarray]:
        """
        Compute comprehensive statistics about the image dataset.

        Args:
            images: Tensor of shape (N, H, W, C).

        Returns:
            Dictionary containing various statistics:
            - 'mean': Overall mean across all pixels
            - 'std': Overall standard deviation
            - 'min': Minimum value
            - 'max': Maximum value
            - 'mean_per_channel': Mean for each color channel
            - 'std_per_channel': Std for each color channel
        """
        stats = {
            'mean': np.mean(images),
            'std': np.std(images),
            'min': np.min(images),
            'max': np.max(images),
            'mean_per_channel': np.mean(images, axis=(0, 1, 2)),
            'std_per_channel': np.std(images, axis=(0, 1, 2)),
        }
        self.statistics_cache = stats
        return stats

    def standardize_images(self,
                          images: np.ndarray,
                          stats: Optional[Dict] = None) -> np.ndarray:
        """
        Standardize images using computed statistics.

        Args:
            images: Tensor of shape (N, H, W, C).
            stats: Dictionary with 'mean_per_channel' and
                   'std_per_channel'. If None, uses cached stats.

        Returns:
            Standardized images.

        Raises:
            ValueError: If statistics are not available.
        """
        if stats is None:
            if self.statistics_cache is None:
                raise ValueError(
                    "Statistics must be computed first or provided"
                )
            stats = self.statistics_cache

        images_float = images.astype(self.default_dtype)
        mean = stats['mean_per_channel'].reshape(1, 1, 1, -1)
        std = stats['std_per_channel'].reshape(1, 1, 1, -1)

        std = np.where(std == 0, 1.0, std)

        standardized = (images_float - mean) / std
        return standardized

    def apply_augmentation_pipeline(self,
                                   images: np.ndarray,
                                   normalize: bool = True,
                                   brightness_adjust: bool = True,
                                   contrast_adjust: bool = True,
                                   horizontal_flip: bool = True,
                                   blur: bool = False) -> np.ndarray:
        """
        Apply a full augmentation pipeline to images.

        Args:
            images: Input tensor of shape (N, H, W, C).
            normalize: Whether to normalize to [0, 1].
            brightness_adjust: Whether to apply brightness adjustment.
            contrast_adjust: Whether to apply contrast adjustment.
            horizontal_flip: Whether to apply horizontal flip.
            blur: Whether to apply Gaussian blur.

        Returns:
            Augmented images tensor.
        """
        result = images.copy()

        if normalize:
            result = self.normalize_images(result, method='minmax')

        if horizontal_flip:
            result = self.apply_horizontal_flip(result, probability=0.5)

        if brightness_adjust:
            result = self.apply_brightness_adjustment(
                result,
                max_delta=0.1
            )

        if contrast_adjust:
            result = self.apply_contrast_adjustment(
                result,
                contrast_factor=0.15
            )

        if blur:
            result = self.apply_gaussian_blur(result, sigma=0.5)

        return result

    def batch_process(self,
                     images: np.ndarray,
                     operations: List[Tuple[str, Dict]]) -> np.ndarray:
        """
        Apply a sequence of operations to images.

        Args:
            images: Input tensor of shape (N, H, W, C).
            operations: List of tuples (operation_name, operation_kwargs).

        Returns:
            Processed images tensor.

        Raises:
            ValueError: If operation is not recognized.
        """
        result = images.copy()

        for operation_name, kwargs in operations:
            if operation_name == 'normalize':
                result = self.normalize_images(result, **kwargs)
            elif operation_name == 'flip':
                result = self.apply_horizontal_flip(result, **kwargs)
            elif operation_name == 'brightness':
                result = self.apply_brightness_adjustment(
                    result, **kwargs
                )
            elif operation_name == 'contrast':
                result = self.apply_contrast_adjustment(result, **kwargs)
            elif operation_name == 'blur':
                result = self.apply_gaussian_blur(result, **kwargs)
            elif operation_name == 'resize':
                result = self.resize_images(result, **kwargs)
            else:
                raise ValueError(f"Unknown operation: {operation_name}")

        return result

    def extract_patches(self,
                       images: np.ndarray,
                       patch_height: int,
                       patch_width: int,
                       stride: int = 1) -> np.ndarray:
        """
        Extract patches from images to create a dataset of smaller images.

        Args:
            images: Tensor of shape (N, H, W, C).
            patch_height: Height of patches to extract.
            patch_width: Width of patches to extract.
            stride: Step size for patch extraction.

        Returns:
            Tensor of shape (M, patch_height, patch_width, C) containing
            all extracted patches.

        Raises:
            ValueError: If patch dimensions are invalid.
        """
        if patch_height <= 0 or patch_width <= 0:
            raise ValueError("Patch dimensions must be positive")
        if stride <= 0:
            raise ValueError("Stride must be positive")

        num_images, height, width, channels = images.shape

        patches_list = []

        for i in range(num_images):
            for h in range(0, height - patch_height + 1, stride):
                for w in range(0, width - patch_width + 1, stride):
                    patch = images[
                        i,
                        h:h+patch_height,
                        w:w+patch_width,
                        :
                    ]
                    patches_list.append(patch)

        patches = np.array(patches_list)
        return patches

def main():
    """
    Main function demonstrating the complete usage of ImageProcessor.
    """
    processor = ImageProcessor()

    print("Creating synthetic images...")
    images = processor.create_synthetic_images(
        num_images=16,
        height=128,
        width=128
    )
    print(f"Created images with shape: {images.shape}")

    print("\nNormalizing images...")
    normalized = processor.normalize_images(images, method='minmax')
    print(f"Normalized images range: [{normalized.min():.4f}, "
          f"{normalized.max():.4f}]")

    print("\nComputing statistics...")
    stats = processor.compute_statistics(normalized)
    print(f"Overall mean: {stats['mean']:.4f}")
    print(f"Overall std: {stats['std']:.4f}")
    print(f"Mean per channel: {stats['mean_per_channel']}")
    print(f"Std per channel: {stats['std_per_channel']}")

    print("\nApplying augmentation pipeline...")
    augmented = processor.apply_augmentation_pipeline(
        normalized,
        normalize=False,
        brightness_adjust=True,
        contrast_adjust=True,
        horizontal_flip=True,
        blur=False
    )
    print(f"Augmented images shape: {augmented.shape}")

    print("\nApplying batch operations...")
    operations = [
        ('flip', {'probability': 0.5}),
        ('brightness', {'max_delta': 0.15}),
        ('contrast', {'contrast_factor': 0.2}),
    ]
    batch_processed = processor.batch_process(normalized, operations)
    print(f"Batch processed images shape: {batch_processed.shape}")

    print("\nResizing images...")
    resized = processor.resize_images(
        augmented,
        new_height=64,
        new_width=64
    )
    print(f"Resized images shape: {resized.shape}")

    print("\nExtracting patches...")
    patches = processor.extract_patches(
        normalized[:4],
        patch_height=32,
        patch_width=32,
        stride=16
    )
    print(f"Extracted patches shape: {patches.shape}")

    print("\nStandardizing images...")
    standardized = processor.standardize_images(normalized)
    standardized_stats = processor.compute_statistics(standardized)
    print(f"Standardized mean: {standardized_stats['mean']:.6f}")
    print(f"Standardized std: {standardized_stats['std']:.6f}")

    print("\nDemonstration complete!")

if __name__ == "__main__":
    main()

This complete implementation demonstrates all the key concepts discussed throughout this tutorial. The ImageProcessor class provides a comprehensive set of methods for working with image tensors. Each method is thoroughly documented with docstrings explaining parameters, return values, and potential errors. The code follows clean code principles with meaningful variable names, proper error handling, and a logical structure.

The example demonstrates how to create tensors, normalize them, apply various transformations using tensor operations, compute statistics using reductions, and combine operations into pipelines. All code is production-ready and can be run directly without modification. The main function shows how to use the ImageProcessor for a complete image processing workflow.

This practical example illustrates the power of thinking about data as tensors and using tensor operations to process that data efficiently and clearly.

CONCLUSION

Throughout this tutorial, we have journeyed from the simplest concepts of scalars and vectors to the sophisticated use of tensors in real-world applications. We have learned that tensors are simply structured collections of numbers with multiple dimensions, but that this simple concept is incredibly powerful when combined with optimized tensor operations.

The key insight to take away is that many real-world problems have naturally multidimensional data. Instead of trying to force that data into one-dimensional structures or manipulating it with explicit loops, we can organize it as tensors and use tensor operations to work with it efficiently and elegantly.

Tensors are fundamental to modern computing because they allow us to express complex operations on multidimensional data in a clear and concise way. Whether you are working on machine learning, image processing, scientific computing, or data analysis, you will be working with tensors.

As you continue your journey with tensors, remember that the core concepts remain the same across different libraries and applications. Once you understand what tensors are and how tensor operations work, you can pick up any tensor library and use it effectively. The concepts are universal, even if the specific syntax and implementation details vary.

The best way to become proficient with tensors is to practice. Create tensors, manipulate them, perform operations on them, and build real systems that use tensor operations. Start with simple operations and gradually work toward more complex applications. Each operation you master will give you confidence and understanding for the next one.

The field of numerical computing and machine learning is built on tensors. As you deepen your understanding of tensors, you are building a foundation that will serve you well in whatever domain you work in. Happy tensor computing!

Wednesday, August 19, 2026

TUTORIAL ON CURRICULUM LEARNING


 


WHAT IS CURRICULUM LEARNING?

Curriculum learning is a training strategy in machine learning where you teach a model by presenting training examples in a meaningful order, starting from easier examples and gradually progressing to more difficult ones. This approach mimics how humans learn, where we typically master simple concepts before tackling complex ones.

Imagine teaching a child mathematics. You would not start with calculus. Instead, you would begin with counting, then addition, then subtraction, and slowly build up to more advanced topics. Curriculum learning applies this same principle to training artificial intelligence systems.

The fundamental idea is that the order in which a machine learning model sees training data can significantly impact how well and how quickly it learns. By carefully organizing training examples from simple to complex, we can often achieve better final performance, faster convergence, and improved generalization compared to randomly shuffling all training data.

WHY USE CURRICULUM LEARNING?

Traditional machine learning training typically shuffles all available training data and presents it randomly to the model. While this works, it has limitations. When a model encounters extremely difficult examples early in training, it may struggle to make progress because it has not yet developed the foundational patterns needed to understand these complex cases.

Curriculum learning addresses several important challenges. First, it can help models converge faster by building knowledge incrementally. Second, it can lead to better final performance by ensuring the model develops robust foundational representations before tackling edge cases. Third, it can improve training stability by avoiding overwhelming the model with complexity too early.

Consider training a computer vision model to recognize objects in cluttered scenes. If we start with clear, well-lit images of single objects, the model can learn basic shape and color patterns. Once it masters these fundamentals, we can introduce images with multiple objects, then images with occlusions, and finally images with poor lighting and heavy clutter. This progression allows the model to build capabilities systematically.

THE CORE COMPONENTS OF CURRICULUM LEARNING

A curriculum learning system consists of several essential components that work together to implement the progressive training strategy.

The first component is the difficulty measurer. This component assigns a difficulty score to each training example. The difficulty measure can be based on various factors such as the complexity of the input, the rarity of the label, or even the model's current performance on similar examples.

The second component is the pacing function. This determines how quickly the curriculum should progress from easy to hard examples. Some curricula use a fixed schedule, while others adapt based on the model's learning progress.

The third component is the data scheduler. This takes the difficulty scores and pacing function to decide which examples should be presented to the model at each training step. The scheduler might use strategies like filtering out examples above a certain difficulty threshold or adjusting the sampling probability based on difficulty.

The fourth component is the training loop itself, which integrates these elements with the standard model training process. The training loop must coordinate between curriculum progression and model optimization.

MEASURING EXAMPLE DIFFICULTY

Determining which examples are easy and which are hard is a crucial challenge in curriculum learning. There are several approaches to measuring difficulty, each with different strengths and use cases.

One straightforward approach is to use predefined heuristics based on domain knowledge. For instance, in language learning, sentence length might serve as a difficulty proxy. Shorter sentences are generally easier to process than longer ones. In image classification, images with clear backgrounds might be considered easier than those with cluttered scenes.

Here is a simple example of a heuristic-based difficulty scorer for text data:

class TextDifficultyScorer:
    def __init__(self, vocab_frequency):
        # vocab_frequency is a dictionary mapping words to their corpus frequency
        self.vocab_frequency = vocab_frequency
    
    def score(self, text):
        # Tokenize the text into words
        words = text.lower().split()
        
        # Calculate difficulty based on length and rare words
        length_score = len(words) / 100.0  # Normalize by expected max length
        
        # Calculate rarity score (inverse of average word frequency)
        rarity_scores = []
        for word in words:
            freq = self.vocab_frequency.get(word, 0.0001)  # Default for unknown words
            rarity_scores.append(1.0 / (freq + 0.0001))
        
        avg_rarity = sum(rarity_scores) / max(len(rarity_scores), 1)
        
        # Combine scores (you can adjust weights)
        difficulty = 0.3 * length_score + 0.7 * avg_rarity
        
        return difficulty

This scorer combines two factors: the length of the text and the rarity of words it contains. Longer texts with rare words receive higher difficulty scores.

Another approach is to use model-based difficulty estimation. In this method, we train a separate model or use the current model's predictions to estimate difficulty. Examples where the model makes confident correct predictions are considered easy, while examples where the model is uncertain or incorrect are considered hard.

A third approach is to use loss-based difficulty. We can measure how much loss the model incurs on each example. Examples with high loss are difficult, while those with low loss are easy. This approach has the advantage of being adaptive: as the model learns, what was once difficult may become easy.

Now let us learn an example of a loss-based difficulty tracker:

import numpy as np

class LossBasedDifficultyTracker:
    def __init__(self, smoothing_factor=0.9):
        # Store moving average of loss for each example
        self.example_losses = {}
        self.smoothing_factor = smoothing_factor
    
    def update(self, example_id, loss_value):
        # Update the moving average loss for this example
        if example_id not in self.example_losses:
            self.example_losses[example_id] = loss_value
        else:
            # Exponential moving average
            old_loss = self.example_losses[example_id]
            new_loss = self.smoothing_factor * old_loss + (1 - self.smoothing_factor) * loss_value
            self.example_losses[example_id] = new_loss
    
    def get_difficulty(self, example_id):
        # Return the current difficulty estimate
        return self.example_losses.get(example_id, float('inf'))
    
    def get_difficulty_percentile(self, example_id):
        # Return what percentile this example falls into
        if example_id not in self.example_losses:
            return 1.0  # Treat unseen examples as hardest
        
        all_losses = list(self.example_losses.values())
        example_loss = self.example_losses[example_id]
        
        # Calculate percentile
        percentile = sum(1 for loss in all_losses if loss <= example_loss) / len(all_losses)
        return percentile

This tracker maintains a moving average of the loss for each training example. As training progresses, it updates these estimates, allowing the curriculum to adapt to the model's changing capabilities.

PACING STRATEGIES FOR CURRICULUM PROGRESSION

Once we have difficulty scores for our training examples, we need to decide how to pace the curriculum. The pacing strategy determines how quickly we transition from easy to hard examples.

The simplest approach is a fixed linear schedule. We might start by only showing examples in the easiest twenty percent, then after a certain number of training steps, expand to the easiest forty percent, and so on until we are using all examples.

An implementation of a fixed linear pacing function:

class LinearPacingFunction:
    def __init__(self, total_steps, start_percentile=0.2, end_percentile=1.0):
        # total_steps: how many training steps until we use all data
        # start_percentile: what fraction of easiest data to start with
        # end_percentile: what fraction to end with (usually 1.0 for all data)
        self.total_steps = total_steps
        self.start_percentile = start_percentile
        self.end_percentile = end_percentile
    
    def get_difficulty_threshold(self, current_step):
        # Calculate what percentile of data we should include at this step
        if current_step >= self.total_steps:
            return self.end_percentile
        
        progress = current_step / self.total_steps
        threshold = self.start_percentile + progress * (self.end_percentile - self.start_percentile)
        
        return threshold

This pacing function starts by allowing only the easiest twenty percent of examples and linearly increases this threshold until all examples are included after the specified number of training steps.

A more sophisticated approach is self-paced learning, where the curriculum adapts based on the model's performance. If the model is learning quickly and achieving low loss, the curriculum might accelerate and introduce harder examples sooner. If the model struggles, the curriculum might slow down and spend more time on easier examples.

Let us view a self-paced learning implementation:

class SelfPacedCurriculum:
    def __init__(self, initial_threshold=0.2, growth_rate=0.01, performance_window=100):
        # initial_threshold: starting difficulty percentile
        # growth_rate: how much to increase threshold when performing well
        # performance_window: how many recent steps to consider for performance
        self.current_threshold = initial_threshold
        self.growth_rate = growth_rate
        self.performance_window = performance_window
        self.recent_losses = []
        self.loss_trend = None
    
    def update(self, current_loss):
        # Track recent losses to determine if model is improving
        self.recent_losses.append(current_loss)
        
        # Keep only the most recent losses
        if len(self.recent_losses) > self.performance_window:
            self.recent_losses.pop(0)
        
        # Calculate loss trend (negative means improving)
        if len(self.recent_losses) >= 2:
            recent_avg = np.mean(self.recent_losses[-20:]) if len(self.recent_losses) >= 20 else self.recent_losses[-1]
            older_avg = np.mean(self.recent_losses[:20]) if len(self.recent_losses) >= 40 else self.recent_losses[0]
            self.loss_trend = recent_avg - older_avg
    
    def get_difficulty_threshold(self):
        # Adjust threshold based on learning progress
        if self.loss_trend is not None and self.loss_trend < 0:
            # Model is improving, increase difficulty
            self.current_threshold = min(1.0, self.current_threshold + self.growth_rate)
        elif self.loss_trend is not None and self.loss_trend > 0:
            # Model is struggling, slow down or maintain current difficulty
            self.current_threshold = max(0.1, self.current_threshold - self.growth_rate * 0.5)
        
        return self.current_threshold

This self-paced curriculum monitors the model's recent loss trend. When the model is improving, it increases the difficulty threshold to introduce harder examples. When the model struggles, it reduces the threshold or maintains the current difficulty level.

IMPLEMENTING THE DATA SCHEDULER

The data scheduler is responsible for selecting which training examples to present at each step based on the difficulty scores and pacing function. There are several strategies for implementing this selection.

One approach is hard filtering, where we completely exclude examples above the current difficulty threshold. This ensures the model only sees examples it is ready for, but it can be wasteful because we discard potentially useful data.

Another approach is soft filtering or importance sampling, where we adjust the probability of sampling each example based on its difficulty. Easier examples get higher sampling probability, but harder examples are not completely excluded. This allows the model to occasionally encounter challenging examples while focusing primarily on appropriate difficulty levels.

Here comes an implementation of a curriculum data scheduler with both hard and soft filtering options:

import random

class CurriculumDataScheduler:
    def __init__(self, dataset, difficulty_scorer, pacing_function, mode='soft'):
        # dataset: list of training examples with unique IDs
        # difficulty_scorer: object that can score example difficulty
        # pacing_function: object that determines current difficulty threshold
        # mode: 'hard' for filtering, 'soft' for importance sampling
        self.dataset = dataset
        self.difficulty_scorer = difficulty_scorer
        self.pacing_function = pacing_function
        self.mode = mode
        
        # Pre-compute difficulty scores for all examples
        self.difficulty_scores = {}
        for example in dataset:
            example_id = example['id']
            self.difficulty_scores[example_id] = difficulty_scorer.score(example)
        
        # Normalize scores to [0, 1] range
        max_score = max(self.difficulty_scores.values())
        min_score = min(self.difficulty_scores.values())
        score_range = max_score - min_score
        
        if score_range > 0:
            for example_id in self.difficulty_scores:
                normalized = (self.difficulty_scores[example_id] - min_score) / score_range
                self.difficulty_scores[example_id] = normalized
    
    def get_batch(self, batch_size, current_step):
        # Get current difficulty threshold from pacing function
        threshold = self.pacing_function.get_difficulty_threshold(current_step)
        
        if self.mode == 'hard':
            # Hard filtering: only include examples below threshold
            eligible_examples = [
                ex for ex in self.dataset 
                if self.difficulty_scores[ex['id']] <= threshold
            ]
            
            if len(eligible_examples) < batch_size:
                # Not enough easy examples, use what we have
                batch = eligible_examples
            else:
                # Randomly sample from eligible examples
                batch = random.sample(eligible_examples, batch_size)
        
        else:  # soft mode
            # Soft filtering: sample with probability inversely proportional to difficulty
            sampling_weights = []
            for example in self.dataset:
                difficulty = self.difficulty_scores[example['id']]
                # Examples at or below threshold get full weight
                # Examples above threshold get reduced weight
                if difficulty <= threshold:
                    weight = 1.0
                else:
                    # Exponentially decay weight for harder examples
                    excess_difficulty = difficulty - threshold
                    weight = np.exp(-5.0 * excess_difficulty)
                
                sampling_weights.append(weight)
            
            # Normalize weights to probabilities
            total_weight = sum(sampling_weights)
            probabilities = [w / total_weight for w in sampling_weights]
            
            # Sample according to these probabilities
            indices = np.random.choice(
                len(self.dataset), 
                size=batch_size, 
                replace=False,
                p=probabilities
            )
            batch = [self.dataset[i] for i in indices]
        
        return batch

This scheduler can operate in two modes. In hard filtering mode, it completely excludes examples above the difficulty threshold. In soft filtering mode, it uses importance sampling to preferentially select easier examples while still occasionally including harder ones.

INTEGRATING CURRICULUM LEARNING INTO TRAINING

Now that we have all the components, we need to integrate them into the actual training loop. The training loop must coordinate between curriculum progression, batch selection, model updates, and difficulty tracking.

Here is a skeleton of a curriculum learning training loop:

def train_with_curriculum(model, optimizer, dataset, difficulty_scorer, 
                         pacing_function, num_epochs, batch_size):
    # Initialize the curriculum scheduler
    scheduler = CurriculumDataScheduler(
        dataset, 
        difficulty_scorer, 
        pacing_function, 
        mode='soft'
    )
    
    # Track training progress
    global_step = 0
    
    for epoch in range(num_epochs):
        epoch_loss = 0.0
        num_batches = len(dataset) // batch_size
        
        for batch_idx in range(num_batches):
            # Get a curriculum-based batch
            batch = scheduler.get_batch(batch_size, global_step)
            
            # Forward pass
            predictions = model(batch)
            loss = compute_loss(predictions, batch)
            
            # Backward pass and optimization
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            
            # Update difficulty scores if using adaptive scoring
            if hasattr(difficulty_scorer, 'update'):
                for example in batch:
                    example_loss = compute_example_loss(model, example)
                    difficulty_scorer.update(example['id'], example_loss)
            
            # Update pacing function if using self-paced learning
            if hasattr(pacing_function, 'update'):
                pacing_function.update(loss.item())
            
            epoch_loss += loss.item()
            global_step += 1
        
        avg_epoch_loss = epoch_loss / num_batches
        print(f"Epoch {epoch + 1}/{num_epochs}, Loss: {avg_epoch_loss:.4f}")
    
    return model

This training loop integrates all curriculum learning components. It uses the scheduler to get appropriately difficult batches, performs standard model training, and updates both the difficulty scorer and pacing function based on training progress.

PRACTICAL CONSIDERATIONS AND BEST PRACTICES

When implementing curriculum learning in practice, several important considerations can affect success.

First, the choice of difficulty measure is crucial and domain-dependent. For some tasks, simple heuristics work well. For others, adaptive loss-based measures are necessary. It is often beneficial to experiment with multiple difficulty measures and compare their effectiveness.

Second, the pacing schedule requires careful tuning. If the curriculum progresses too quickly, the model may not have time to master easier concepts before encountering harder ones. If it progresses too slowly, training time increases without corresponding benefits. Self-paced learning can help automate this tuning but introduces its own hyperparameters.

Third, curriculum learning interacts with other training techniques. When using techniques like data augmentation, learning rate schedules, or regularization, these must be coordinated with the curriculum. For example, you might want to increase data augmentation as the curriculum introduces harder examples.

Fourth, not all tasks benefit equally from curriculum learning. Tasks with clear difficulty hierarchies and where foundational concepts enable learning of advanced concepts tend to benefit most. Tasks where examples are relatively uniform in difficulty may see little benefit.

Fifth, evaluation is important. You should compare curriculum learning against standard random shuffling on your specific task. Measure not just final performance but also convergence speed and training stability.

FULL PRODUCTION-READY RUNNING EXAMPLE

Now I will present a complete, production-ready implementation of curriculum learning for a text classification task. This implementation includes all necessary components and can be adapted to various use cases.

import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from collections import defaultdict
import random
from typing import List, Dict, Tuple, Optional, Callable
import json


class TextDataset(Dataset):
    """
    A dataset class for text classification that supports curriculum learning.
    Each example has a unique ID for tracking difficulty scores.
    """
    
    def __init__(self, texts: List[str], labels: List[int], vocab: Dict[str, int]):
        """
        Initialize the dataset.
        
        Args:
            texts: List of text strings
            labels: List of integer labels
            vocab: Dictionary mapping words to integer indices
        """
        self.texts = texts
        self.labels = labels
        self.vocab = vocab
        self.max_length = 100
        
    def __len__(self):
        return len(self.texts)
    
    def __getitem__(self, idx):
        text = self.texts[idx]
        label = self.labels[idx]
        
        # Tokenize and convert to indices
        tokens = text.lower().split()
        indices = [self.vocab.get(token, self.vocab['<UNK>']) for token in tokens]
        
        # Pad or truncate to max_length
        if len(indices) < self.max_length:
            indices = indices + [self.vocab['<PAD>']] * (self.max_length - len(indices))
        else:
            indices = indices[:self.max_length]
        
        return {
            'id': idx,
            'text': text,
            'indices': torch.tensor(indices, dtype=torch.long),
            'label': torch.tensor(label, dtype=torch.long)
        }


class TextClassifier(nn.Module):
    """
    A simple LSTM-based text classifier.
    """
    
    def __init__(self, vocab_size: int, embedding_dim: int, hidden_dim: int, 
                 num_classes: int, dropout: float = 0.3):
        """
        Initialize the classifier.
        
        Args:
            vocab_size: Size of the vocabulary
            embedding_dim: Dimension of word embeddings
            hidden_dim: Dimension of LSTM hidden state
            num_classes: Number of output classes
            dropout: Dropout probability
        """
        super(TextClassifier, self).__init__()
        
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True, 
                           num_layers=2, dropout=dropout, bidirectional=True)
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_dim * 2, num_classes)
        
    def forward(self, indices):
        """
        Forward pass.
        
        Args:
            indices: Tensor of shape (batch_size, max_length) containing word indices
            
        Returns:
            Tensor of shape (batch_size, num_classes) containing class logits
        """
        # Embed the input
        embedded = self.embedding(indices)  # (batch_size, max_length, embedding_dim)
        
        # Pass through LSTM
        lstm_out, (hidden, cell) = self.lstm(embedded)
        
        # Use the final hidden states from both directions
        # hidden shape: (num_layers * num_directions, batch_size, hidden_dim)
        forward_hidden = hidden[-2, :, :]
        backward_hidden = hidden[-1, :, :]
        combined_hidden = torch.cat([forward_hidden, backward_hidden], dim=1)
        
        # Apply dropout and final linear layer
        dropped = self.dropout(combined_hidden)
        logits = self.fc(dropped)
        
        return logits


class HeuristicDifficultyScorer:
    """
    Scores text difficulty based on heuristics like length and word rarity.
    """
    
    def __init__(self, vocab_frequency: Dict[str, float]):
        """
        Initialize the scorer.
        
        Args:
            vocab_frequency: Dictionary mapping words to their frequency in corpus
        """
        self.vocab_frequency = vocab_frequency
        
    def score(self, example: Dict) -> float:
        """
        Compute difficulty score for an example.
        
        Args:
            example: Dictionary containing 'text' key
            
        Returns:
            Float difficulty score (higher = more difficult)
        """
        text = example['text']
        words = text.lower().split()
        
        if len(words) == 0:
            return 0.0
        
        # Length component (normalized)
        length_score = min(len(words) / 50.0, 1.0)
        
        # Rarity component (average inverse frequency)
        rarity_scores = []
        for word in words:
            freq = self.vocab_frequency.get(word, 0.00001)
            rarity_scores.append(1.0 / (freq + 0.00001))
        
        avg_rarity = np.mean(rarity_scores)
        # Normalize rarity score
        rarity_score = min(avg_rarity / 1000.0, 1.0)
        
        # Combine scores
        difficulty = 0.4 * length_score + 0.6 * rarity_score
        
        return difficulty


class AdaptiveDifficultyScorer:
    """
    Scores difficulty based on model's current loss on each example.
    Adapts as the model learns.
    """
    
    def __init__(self, smoothing_factor: float = 0.9, initial_difficulty: float = 0.5):
        """
        Initialize the scorer.
        
        Args:
            smoothing_factor: Factor for exponential moving average (0-1)
            initial_difficulty: Initial difficulty for unseen examples
        """
        self.smoothing_factor = smoothing_factor
        self.initial_difficulty = initial_difficulty
        self.example_losses = {}
        
    def update(self, example_id: int, loss_value: float):
        """
        Update the difficulty estimate for an example.
        
        Args:
            example_id: Unique identifier for the example
            loss_value: Current loss value on this example
        """
        if example_id not in self.example_losses:
            self.example_losses[example_id] = loss_value
        else:
            old_loss = self.example_losses[example_id]
            new_loss = (self.smoothing_factor * old_loss + 
                       (1 - self.smoothing_factor) * loss_value)
            self.example_losses[example_id] = new_loss
    
    def score(self, example: Dict) -> float:
        """
        Get difficulty score for an example.
        
        Args:
            example: Dictionary containing 'id' key
            
        Returns:
            Float difficulty score (higher = more difficult)
        """
        example_id = example['id']
        
        if example_id not in self.example_losses:
            return self.initial_difficulty
        
        # Normalize loss to [0, 1] range
        all_losses = list(self.example_losses.values())
        if len(all_losses) == 0:
            return self.initial_difficulty
        
        max_loss = max(all_losses)
        min_loss = min(all_losses)
        
        if max_loss == min_loss:
            return 0.5
        
        example_loss = self.example_losses[example_id]
        normalized_difficulty = (example_loss - min_loss) / (max_loss - min_loss)
        
        return normalized_difficulty


class FixedPacingFunction:
    """
    Fixed linear pacing schedule that increases difficulty threshold over time.
    """
    
    def __init__(self, total_steps: int, start_percentile: float = 0.2, 
                 end_percentile: float = 1.0):
        """
        Initialize the pacing function.
        
        Args:
            total_steps: Number of steps to reach end_percentile
            start_percentile: Initial difficulty threshold (0-1)
            end_percentile: Final difficulty threshold (0-1)
        """
        self.total_steps = total_steps
        self.start_percentile = start_percentile
        self.end_percentile = end_percentile
        
    def get_difficulty_threshold(self, current_step: int) -> float:
        """
        Get the current difficulty threshold.
        
        Args:
            current_step: Current training step
            
        Returns:
            Float threshold value (0-1)
        """
        if current_step >= self.total_steps:
            return self.end_percentile
        
        progress = current_step / self.total_steps
        threshold = (self.start_percentile + 
                    progress * (self.end_percentile - self.start_percentile))
        
        return threshold


class AdaptivePacingFunction:
    """
    Self-paced learning that adjusts difficulty based on model performance.
    """
    
    def __init__(self, initial_threshold: float = 0.2, min_threshold: float = 0.1,
                 max_threshold: float = 1.0, growth_rate: float = 0.005,
                 performance_window: int = 100):
        """
        Initialize the pacing function.
        
        Args:
            initial_threshold: Starting difficulty threshold
            min_threshold: Minimum allowed threshold
            max_threshold: Maximum allowed threshold
            growth_rate: How much to adjust threshold each step
            performance_window: Number of recent losses to track
        """
        self.current_threshold = initial_threshold
        self.min_threshold = min_threshold
        self.max_threshold = max_threshold
        self.growth_rate = growth_rate
        self.performance_window = performance_window
        self.recent_losses = []
        
    def update(self, current_loss: float):
        """
        Update the pacing based on current performance.
        
        Args:
            current_loss: Current training loss
        """
        self.recent_losses.append(current_loss)
        
        if len(self.recent_losses) > self.performance_window:
            self.recent_losses.pop(0)
        
        # Calculate if model is improving
        if len(self.recent_losses) >= 20:
            recent_avg = np.mean(self.recent_losses[-10:])
            older_avg = np.mean(self.recent_losses[:10])
            
            if recent_avg < older_avg:
                # Model improving, increase difficulty
                self.current_threshold = min(
                    self.max_threshold,
                    self.current_threshold + self.growth_rate
                )
            else:
                # Model struggling, decrease difficulty slightly
                self.current_threshold = max(
                    self.min_threshold,
                    self.current_threshold - self.growth_rate * 0.3
                )
    
    def get_difficulty_threshold(self, current_step: int = None) -> float:
        """
        Get the current difficulty threshold.
        
        Args:
            current_step: Not used in adaptive pacing, kept for interface compatibility
            
        Returns:
            Float threshold value (0-1)
        """
        return self.current_threshold


class CurriculumScheduler:
    """
    Schedules training batches according to curriculum learning strategy.
    """
    
    def __init__(self, dataset: Dataset, difficulty_scorer, 
                 pacing_function, mode: str = 'soft'):
        """
        Initialize the scheduler.
        
        Args:
            dataset: Dataset to schedule
            difficulty_scorer: Object with score(example) method
            pacing_function: Object with get_difficulty_threshold(step) method
            mode: 'hard' for filtering, 'soft' for importance sampling
        """
        self.dataset = dataset
        self.difficulty_scorer = difficulty_scorer
        self.pacing_function = pacing_function
        self.mode = mode
        
        # Pre-compute initial difficulty scores
        self.difficulty_scores = {}
        self._update_all_difficulty_scores()
        
    def _update_all_difficulty_scores(self):
        """
        Recompute difficulty scores for all examples.
        """
        for idx in range(len(self.dataset)):
            example = self.dataset[idx]
            self.difficulty_scores[idx] = self.difficulty_scorer.score(example)
    
    def get_batch_indices(self, batch_size: int, current_step: int) -> List[int]:
        """
        Get indices for the next batch according to curriculum.
        
        Args:
            batch_size: Number of examples to return
            current_step: Current training step
            
        Returns:
            List of dataset indices
        """
        threshold = self.pacing_function.get_difficulty_threshold(current_step)
        
        if self.mode == 'hard':
            # Hard filtering: only include examples below threshold
            eligible_indices = [
                idx for idx in range(len(self.dataset))
                if self.difficulty_scores[idx] <= threshold
            ]
            
            if len(eligible_indices) == 0:
                # Fallback: use all examples if none are eligible
                eligible_indices = list(range(len(self.dataset)))
            
            if len(eligible_indices) <= batch_size:
                return eligible_indices
            else:
                return random.sample(eligible_indices, batch_size)
        
        else:  # soft mode
            # Importance sampling based on difficulty
            weights = []
            for idx in range(len(self.dataset)):
                difficulty = self.difficulty_scores[idx]
                
                if difficulty <= threshold:
                    weight = 1.0
                else:
                    # Exponentially decay weight for harder examples
                    excess = difficulty - threshold
                    weight = np.exp(-5.0 * excess)
                
                weights.append(weight)
            
            # Normalize to probabilities
            total_weight = sum(weights)
            if total_weight == 0:
                # Fallback to uniform sampling
                probabilities = [1.0 / len(weights)] * len(weights)
            else:
                probabilities = [w / total_weight for w in weights]
            
            # Sample indices
            indices = np.random.choice(
                len(self.dataset),
                size=min(batch_size, len(self.dataset)),
                replace=False,
                p=probabilities
            )
            
            return indices.tolist()


class CurriculumLearningTrainer:
    """
    Main trainer class that orchestrates curriculum learning.
    """
    
    def __init__(self, model: nn.Module, dataset: Dataset, 
                 difficulty_scorer, pacing_function,
                 device: str = 'cpu', scheduler_mode: str = 'soft'):
        """
        Initialize the trainer.
        
        Args:
            model: PyTorch model to train
            dataset: Training dataset
            difficulty_scorer: Difficulty scoring object
            pacing_function: Pacing function object
            device: Device to train on ('cpu' or 'cuda')
            scheduler_mode: 'hard' or 'soft' filtering
        """
        self.model = model.to(device)
        self.dataset = dataset
        self.difficulty_scorer = difficulty_scorer
        self.pacing_function = pacing_function
        self.device = device
        
        self.scheduler = CurriculumScheduler(
            dataset, difficulty_scorer, pacing_function, scheduler_mode
        )
        
        self.criterion = nn.CrossEntropyLoss(reduction='none')
        self.global_step = 0
        
    def train_epoch(self, optimizer: optim.Optimizer, batch_size: int,
                   update_difficulty: bool = True) -> Tuple[float, float]:
        """
        Train for one epoch.
        
        Args:
            optimizer: PyTorch optimizer
            batch_size: Batch size
            update_difficulty: Whether to update difficulty scores
            
        Returns:
            Tuple of (average_loss, accuracy)
        """
        self.model.train()
        
        total_loss = 0.0
        total_correct = 0
        total_examples = 0
        
        num_batches = len(self.dataset) // batch_size
        
        for batch_idx in range(num_batches):
            # Get curriculum-based batch
            indices = self.scheduler.get_batch_indices(batch_size, self.global_step)
            
            # Gather batch data
            batch_data = [self.dataset[idx] for idx in indices]
            
            # Stack tensors
            input_indices = torch.stack([item['indices'] for item in batch_data]).to(self.device)
            labels = torch.stack([item['label'] for item in batch_data]).to(self.device)
            
            # Forward pass
            optimizer.zero_grad()
            logits = self.model(input_indices)
            
            # Compute loss
            losses = self.criterion(logits, labels)
            loss = losses.mean()
            
            # Backward pass
            loss.backward()
            optimizer.step()
            
            # Update difficulty scores if using adaptive scoring
            if update_difficulty and hasattr(self.difficulty_scorer, 'update'):
                for i, idx in enumerate(indices):
                    example_loss = losses[i].item()
                    self.difficulty_scorer.update(idx, example_loss)
            
            # Update pacing function if adaptive
            if hasattr(self.pacing_function, 'update'):
                self.pacing_function.update(loss.item())
            
            # Track metrics
            total_loss += loss.item()
            predictions = torch.argmax(logits, dim=1)
            total_correct += (predictions == labels).sum().item()
            total_examples += len(labels)
            
            self.global_step += 1
        
        avg_loss = total_loss / num_batches
        accuracy = total_correct / total_examples
        
        return avg_loss, accuracy
    
    def evaluate(self, eval_dataset: Dataset, batch_size: int) -> Tuple[float, float]:
        """
        Evaluate the model on a dataset.
        
        Args:
            eval_dataset: Dataset to evaluate on
            batch_size: Batch size for evaluation
            
        Returns:
            Tuple of (average_loss, accuracy)
        """
        self.model.eval()
        
        total_loss = 0.0
        total_correct = 0
        total_examples = 0
        
        with torch.no_grad():
            for start_idx in range(0, len(eval_dataset), batch_size):
                end_idx = min(start_idx + batch_size, len(eval_dataset))
                batch_data = [eval_dataset[idx] for idx in range(start_idx, end_idx)]
                
                input_indices = torch.stack([item['indices'] for item in batch_data]).to(self.device)
                labels = torch.stack([item['label'] for item in batch_data]).to(self.device)
                
                logits = self.model(input_indices)
                losses = self.criterion(logits, labels)
                
                total_loss += losses.sum().item()
                predictions = torch.argmax(logits, dim=1)
                total_correct += (predictions == labels).sum().item()
                total_examples += len(labels)
        
        avg_loss = total_loss / total_examples
        accuracy = total_correct / total_examples
        
        return avg_loss, accuracy
    
    def train(self, num_epochs: int, batch_size: int, learning_rate: float,
             eval_dataset: Optional[Dataset] = None, 
             eval_interval: int = 1) -> Dict[str, List[float]]:
        """
        Full training loop.
        
        Args:
            num_epochs: Number of epochs to train
            batch_size: Batch size
            learning_rate: Learning rate
            eval_dataset: Optional evaluation dataset
            eval_interval: How often to evaluate (in epochs)
            
        Returns:
            Dictionary containing training history
        """
        optimizer = optim.Adam(self.model.parameters(), lr=learning_rate)
        
        history = {
            'train_loss': [],
            'train_accuracy': [],
            'eval_loss': [],
            'eval_accuracy': [],
            'difficulty_threshold': []
        }
        
        for epoch in range(num_epochs):
            # Train for one epoch
            train_loss, train_acc = self.train_epoch(optimizer, batch_size)
            
            history['train_loss'].append(train_loss)
            history['train_accuracy'].append(train_acc)
            
            # Track current difficulty threshold
            current_threshold = self.pacing_function.get_difficulty_threshold(self.global_step)
            history['difficulty_threshold'].append(current_threshold)
            
            print(f"Epoch {epoch + 1}/{num_epochs}")
            print(f"  Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f}")
            print(f"  Difficulty Threshold: {current_threshold:.4f}")
            
            # Evaluate if requested
            if eval_dataset is not None and (epoch + 1) % eval_interval == 0:
                eval_loss, eval_acc = self.evaluate(eval_dataset, batch_size)
                history['eval_loss'].append(eval_loss)
                history['eval_accuracy'].append(eval_acc)
                print(f"  Eval Loss: {eval_loss:.4f}, Eval Acc: {eval_acc:.4f}")
            
            print()
        
        return history


def create_synthetic_dataset(num_examples: int, num_classes: int,
                            vocab_size: int = 1000) -> Tuple[List[str], List[int], Dict[str, int], Dict[str, float]]:
    """
    Create a synthetic text classification dataset for demonstration.
    
    Args:
        num_examples: Number of examples to generate
        num_classes: Number of classes
        vocab_size: Size of vocabulary
        
    Returns:
        Tuple of (texts, labels, vocab, vocab_frequency)
    """
    # Create vocabulary
    vocab = {'<PAD>': 0, '<UNK>': 1}
    for i in range(vocab_size):
        vocab[f'word_{i}'] = i + 2
    
    # Create word frequency distribution (Zipf-like)
    vocab_frequency = {}
    for word, idx in vocab.items():
        if word not in ['<PAD>', '<UNK>']:
            # Zipf distribution: frequency inversely proportional to rank
            rank = idx
            frequency = 1.0 / (rank ** 0.8)
            vocab_frequency[word] = frequency
    
    # Generate texts and labels
    texts = []
    labels = []
    
    for i in range(num_examples):
        # Label determines text characteristics
        label = i % num_classes
        
        # Easy examples: short, common words
        # Hard examples: long, rare words
        if i < num_examples * 0.3:
            # Easy examples
            length = random.randint(5, 15)
            word_indices = random.choices(range(2, 100), k=length)
        elif i < num_examples * 0.6:
            # Medium examples
            length = random.randint(15, 30)
            word_indices = random.choices(range(2, 500), k=length)
        else:
            # Hard examples
            length = random.randint(30, 60)
            word_indices = random.choices(range(2, vocab_size + 2), k=length)
        
        # Create text from word indices
        words = [f'word_{idx - 2}' for idx in word_indices]
        text = ' '.join(words)
        
        texts.append(text)
        labels.append(label)
    
    return texts, labels, vocab, vocab_frequency


def main():
    """
    Main function demonstrating curriculum learning usage.
    """
    print("=" * 80)
    print("CURRICULUM LEARNING DEMONSTRATION")
    print("=" * 80)
    print()
    
    # Set random seeds for reproducibility
    random.seed(42)
    np.random.seed(42)
    torch.manual_seed(42)
    
    # Create synthetic dataset
    print("Creating synthetic dataset...")
    num_train = 1000
    num_eval = 200
    num_classes = 5
    
    train_texts, train_labels, vocab, vocab_freq = create_synthetic_dataset(
        num_train, num_classes
    )
    eval_texts, eval_labels, _, _ = create_synthetic_dataset(
        num_eval, num_classes
    )
    
    print(f"  Training examples: {num_train}")
    print(f"  Evaluation examples: {num_eval}")
    print(f"  Vocabulary size: {len(vocab)}")
    print(f"  Number of classes: {num_classes}")
    print()
    
    # Create datasets
    train_dataset = TextDataset(train_texts, train_labels, vocab)
    eval_dataset = TextDataset(eval_texts, eval_labels, vocab)
    
    # Initialize model
    print("Initializing model...")
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    print(f"  Using device: {device}")
    
    model = TextClassifier(
        vocab_size=len(vocab),
        embedding_dim=128,
        hidden_dim=256,
        num_classes=num_classes,
        dropout=0.3
    )
    print()
    
    # Demonstrate different curriculum learning configurations
    
    # Configuration 1: Heuristic difficulty with fixed pacing
    print("-" * 80)
    print("CONFIGURATION 1: Heuristic Difficulty + Fixed Pacing")
    print("-" * 80)
    
    difficulty_scorer_1 = HeuristicDifficultyScorer(vocab_freq)
    pacing_function_1 = FixedPacingFunction(
        total_steps=500,
        start_percentile=0.2,
        end_percentile=1.0
    )
    
    trainer_1 = CurriculumLearningTrainer(
        model=model,
        dataset=train_dataset,
        difficulty_scorer=difficulty_scorer_1,
        pacing_function=pacing_function_1,
        device=device,
        scheduler_mode='soft'
    )
    
    print("Training with heuristic difficulty and fixed pacing...")
    history_1 = trainer_1.train(
        num_epochs=5,
        batch_size=32,
        learning_rate=0.001,
        eval_dataset=eval_dataset,
        eval_interval=1
    )
    print()
    
    # Configuration 2: Adaptive difficulty with self-paced learning
    print("-" * 80)
    print("CONFIGURATION 2: Adaptive Difficulty + Self-Paced Learning")
    print("-" * 80)
    
    # Reinitialize model for fair comparison
    model_2 = TextClassifier(
        vocab_size=len(vocab),
        embedding_dim=128,
        hidden_dim=256,
        num_classes=num_classes,
        dropout=0.3
    )
    
    difficulty_scorer_2 = AdaptiveDifficultyScorer(
        smoothing_factor=0.9,
        initial_difficulty=0.5
    )
    pacing_function_2 = AdaptivePacingFunction(
        initial_threshold=0.2,
        growth_rate=0.01,
        performance_window=100
    )
    
    trainer_2 = CurriculumLearningTrainer(
        model=model_2,
        dataset=train_dataset,
        difficulty_scorer=difficulty_scorer_2,
        pacing_function=pacing_function_2,
        device=device,
        scheduler_mode='soft'
    )
    
    print("Training with adaptive difficulty and self-paced learning...")
    history_2 = trainer_2.train(
        num_epochs=5,
        batch_size=32,
        learning_rate=0.001,
        eval_dataset=eval_dataset,
        eval_interval=1
    )
    print()
    
    # Configuration 3: Baseline (no curriculum, for comparison)
    print("-" * 80)
    print("CONFIGURATION 3: Baseline (No Curriculum)")
    print("-" * 80)
    
    # Reinitialize model
    model_3 = TextClassifier(
        vocab_size=len(vocab),
        embedding_dim=128,
        hidden_dim=256,
        num_classes=num_classes,
        dropout=0.3
    )
    
    # Use fixed pacing that immediately uses all data
    difficulty_scorer_3 = HeuristicDifficultyScorer(vocab_freq)
    pacing_function_3 = FixedPacingFunction(
        total_steps=1,  # Immediately use all data
        start_percentile=1.0,
        end_percentile=1.0
    )
    
    trainer_3 = CurriculumLearningTrainer(
        model=model_3,
        dataset=train_dataset,
        difficulty_scorer=difficulty_scorer_3,
        pacing_function=pacing_function_3,
        device=device,
        scheduler_mode='hard'
    )
    
    print("Training without curriculum (baseline)...")
    history_3 = trainer_3.train(
        num_epochs=5,
        batch_size=32,
        learning_rate=0.001,
        eval_dataset=eval_dataset,
        eval_interval=1
    )
    print()
    
    # Compare results
    print("=" * 80)
    print("COMPARISON OF RESULTS")
    print("=" * 80)
    print()
    
    print("Final Training Accuracy:")
    print(f"  Config 1 (Heuristic + Fixed):     {history_1['train_accuracy'][-1]:.4f}")
    print(f"  Config 2 (Adaptive + Self-Paced): {history_2['train_accuracy'][-1]:.4f}")
    print(f"  Config 3 (Baseline):               {history_3['train_accuracy'][-1]:.4f}")
    print()
    
    print("Final Evaluation Accuracy:")
    print(f"  Config 1 (Heuristic + Fixed):     {history_1['eval_accuracy'][-1]:.4f}")
    print(f"  Config 2 (Adaptive + Self-Paced): {history_2['eval_accuracy'][-1]:.4f}")
    print(f"  Config 3 (Baseline):               {history_3['eval_accuracy'][-1]:.4f}")
    print()
    
    print("Training completed successfully!")
    print("=" * 80)


if __name__ == "__main__":
    main()

This complete implementation provides a production-ready curriculum learning system for text classification. The code includes multiple difficulty scoring strategies, both fixed and adaptive pacing functions, flexible scheduling modes, and a comprehensive training framework. The main function demonstrates three different configurations, allowing you to compare curriculum learning approaches against a baseline without curriculum learning.

The implementation follows clean code principles with clear separation of concerns. Each class has a single well-defined responsibility. The difficulty scorers are interchangeable, as are the pacing functions, allowing easy experimentation with different curriculum strategies. The code includes extensive documentation and handles edge cases properly.

You can adapt this implementation to other domains by replacing the TextDataset and TextClassifier with appropriate classes for your task. The curriculum learning components remain the same regardless of the specific machine learning problem you are solving.