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

# NumPy: Numerical Computing Foundations for Data Science

> Master NumPy arrays, vectorization, broadcasting, indexing, reshaping, aggregation, and mathematical operations for data science and ML.

NumPy (Numerical Python) is the foundational library for numerical computing in Python. At its core is the **ndarray** — an N-dimensional array object stored in contiguous memory where every element shares the same data type. Because NumPy operations are executed by pre-compiled C code rather than the Python interpreter, array-wide calculations run orders of magnitude faster than equivalent Python loops. Every major data science library — Pandas, Matplotlib, SciPy, Scikit-learn, TensorFlow, PyTorch — builds directly on NumPy, making it an essential skill before you touch any of them.

## Installation and Import

```bash theme={null}
pip install numpy
```

```python theme={null}
import numpy as np
```

<Tip>
  The alias `np` is a universal convention. You'll see it in every tutorial, Stack Overflow answer, and official example — use it from the start so your code looks familiar to other developers.
</Tip>

## Why NumPy Arrays?

Standard Python lists can hold mixed types and require Python-level loops for math. NumPy arrays are **homogeneous** (one data type throughout) and operations are **vectorized** — executed in bulk by compiled C code.

| Python List                     | NumPy Array                        |
| ------------------------------- | ---------------------------------- |
| Heterogeneous (mixed types OK)  | Homogeneous (one type)             |
| Slow iteration via Python loops | Fast vectorized operations in C    |
| No built-in math operations     | Rich mathematical function library |
| Higher memory overhead          | Contiguous memory block            |

## Creating Arrays

### From Python lists

```python theme={null}
a = np.array([1, 2, 3])
print(a)  # [1 2 3]
```

### Zeros, Ones, and Empty

```python theme={null}
zeros_2d = np.zeros((2, 3))       # 2×3 array of 0.0
ones_3d  = np.ones((6, 3, 4))     # 6×3×4 array of 1.0
empty    = np.empty((3, 8))       # uninitialised — faster when you'll overwrite immediately
```

<Note>
  Always pass multi-dimensional shapes as a **tuple** to `np.zeros`, `np.ones`, and `np.empty`. Writing `np.zeros(2, 3)` raises a TypeError because NumPy interprets the second argument as a dtype parameter.
</Note>

### Ranges

```python theme={null}
a = np.arange(6)          # [0 1 2 3 4 5]
b = np.arange(2, 10, 2)   # [2 4 6 8]   (stop is exclusive)
c = np.arange(2, 4, 0.5)  # [2.0 2.5 3.0 3.5]
```

### Linearly Spaced Values

```python theme={null}
# 5 evenly spaced values from 1 to 10 (endpoints included)
linspace_arr = np.linspace(1, 10, 5)  # [1.  3.25 5.5  7.75 10.]

# As integers
linspace_int = np.linspace(1, 10, 5, dtype=np.int64)
```

### Random Numbers

```python theme={null}
rng = np.random.default_rng()                  # modern generator
random_ints = rng.integers(2, 9, size=(3, 2))  # 3×2 array of random ints in [2, 9)
```

## Array Properties

```python theme={null}
a = np.array([[1, 2, 3], [4, 5, 6]])

print(a.ndim)   # 2  — number of dimensions
print(a.shape)  # (2, 3)  — (rows, columns)
print(a.size)   # 6  — total elements
print(a.dtype)  # int64 — element data type
```

| Attribute | Returns                     |
| --------- | --------------------------- |
| `.ndim`   | Number of axes (dimensions) |
| `.shape`  | Tuple of elements per axis  |
| `.size`   | Total element count         |
| `.dtype`  | Data type of elements       |

## Indexing and Slicing

### 1D Arrays

```python theme={null}
arr = np.array([12, 89, 45, 67, 23])

print(arr[0])    # 12  — first element
print(arr[-1])   # 23  — last element
print(arr[:2])   # [12 89]
print(arr[3:])   # [67 23]
print(arr[:-2])  # [12 89 45]
```

### 2D Arrays

For 2D arrays, use `arr[row_slice, column_slice]`:

```python theme={null}
matrix = np.array([
    [10, 11, 12, 13],
    [20, 21, 22, 23],
    [30, 31, 32, 33],
    [40, 41, 42, 43]
])

print(matrix[1, :])      # Row 1:       [20 21 22 23]
print(matrix[:, 2])      # Column 2:    [12 22 32 42]
print(matrix[1:3, 1:3])  # Sub-grid:    [[21 22] [31 32]]
print(matrix[::2, ::2])  # Every 2nd:   [[10 12] [30 32]]
```

### Boolean (Conditional) Filtering

```python theme={null}
a = np.array([3, 75, 6, 84, 2])

# Single condition
filtered = a[a < 7]          # [3 6 2]

# Multiple conditions — wrap each in parentheses, use & or |
filtered_multi = a[(a < 7) | (a > 74)]  # [3 75 6 84 2]
```

<Warning>
  Use bitwise operators `&` (AND) and `|` (OR) — not Python's `and`/`or` — when combining NumPy boolean conditions. Also wrap each condition in its own parentheses to ensure correct operator precedence.
</Warning>

## Array Manipulation

### Reshaping

```python theme={null}
a = np.arange(6)          # [0 1 2 3 4 5]  — size 6

reshaped = a.reshape(3, 2)
# [[0 1]
#  [2 3]
#  [4 5]]
```

The total number of elements must remain the same after reshaping, otherwise NumPy raises a `ValueError`.

### Flattening

```python theme={null}
parent = np.array([[1, 2], [3, 4]])

flat  = parent.flatten()  # Deep copy — changes don't affect parent
rav   = parent.ravel()    # View — changes propagate back to parent
```

### Transposing

```python theme={null}
matrix = np.array([[1, 2, 3], [4, 5, 6]])
print(matrix.T)
# [[1 4]
#  [2 5]
#  [3 6]]
```

## Combining and Splitting Arrays

```python theme={null}
x = np.array([[1, 2], [3, 4]])
y = np.array([[5, 6]])

# Stack vertically (along axis 0)
z = np.concatenate((x, y), axis=0)

# Convenience functions
v_stacked = np.vstack((x, y))    # same as concatenate axis=0
h_stacked = np.hstack((x, x))   # side-by-side (axis=1)
```

```python theme={null}
# Split a 12-column array into 3 equal parts
part1, part2, part3 = np.hsplit(large_array, 3)
```

## Sorting and Copying

```python theme={null}
arr = np.array([30, 10, 20])

print(np.sort(arr))         # [10 20 30] — returns sorted copy
print(np.argsort(arr))      # [1  2  0]  — indices that would sort the array
```

**Views vs. copies:**

```python theme={null}
parent = np.array([10, 20, 30, 40])

view = parent[1:3]   # Points to same memory
view[0] = 99
print(parent)        # [10 99 30 40] — parent changed!

deep = parent.copy()
deep[0] = 0
print(parent)        # [10 99 30 40] — parent unchanged
```

## Aggregate Functions

```python theme={null}
a = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])

print(a.sum())           # 45    — entire array
print(a.sum(axis=0))     # [12 15 18] — sum down each column
print(a.sum(axis=1))     # [6 15 24]  — sum across each row

print(a.min(axis=1))     # [1 4 7]  — row minimums
print(a.max(axis=0))     # [7 8 9]  — column maximums
print(a.mean(axis=1))    # [2. 5. 8.] — row means
print(a.std())           # standard deviation of entire array
```

## Vector Operations (Vectorization)

Vectorization means applying an operation to an entire array in a single statement, with no explicit Python loop. NumPy dispatches the calculation to compiled C code, making it dramatically faster.

```python theme={null}
# Without NumPy — Python loop
numbers = [10, 20, 30, 40]
result = [num + 5 for num in numbers]   # [15, 25, 35, 45]

# With NumPy — vectorized
numbers = np.array([10, 20, 30, 40])
result = numbers + 5                    # [15 25 35 45]
```

**Common vector operations:**

```python theme={null}
a = np.array([10, 20, 30, 40])
b = np.array([1, 2, 3, 4])

print(a + b)        # [11 22 33 44]
print(a - b)        # [ 9 18 27 36]
print(a * b)        # [ 10  40  90 160]
print(a / b)        # [10. 10. 10. 10.]
print(a ** 2)       # [100 400 900 1600]
print(np.sqrt(a))   # [3.16 4.47 5.47 6.32]
```

**Real-world example — salary increment:**

```python theme={null}
salary = np.array([25000, 30000, 40000])
new_salary = salary + 5000
print(new_salary)   # [30000 35000 45000]
```

## Broadcasting

Broadcasting lets you perform arithmetic between arrays of *different shapes* without manually copying data. NumPy logically expands the smaller array to match the larger one.

**Broadcasting rules** (compared right to left):

* Dimensions are compatible if they are equal, or if one of them is 1.
* If incompatible, NumPy raises a `ValueError`.

```python theme={null}
# Scalar broadcast
a = np.array([10, 20, 30])
print(a + 5)   # [15 25 35]

# Row vector broadcast over a 2D matrix
matrix = np.array([[10, 20, 30],
                   [40, 50, 60]])
row = np.array([1, 2, 3])
print(matrix + row)
# [[11 22 33]
#  [41 52 63]]

# Column vector broadcast
col = np.array([[1], [2]])
print(matrix + col)
# [[11 21 31]
#  [42 52 62]]
```

**Broadcasting failure:**

```python theme={null}
a = np.array([[1, 2, 3], [4, 5, 6]])   # shape (2, 3)
b = np.array([1, 2])                    # shape (2,)

print(a + b)  # ValueError: shapes (2,3) and (2,) are not aligned
```

| Array Shape 1 | Array Shape 2 | Compatible? |
| ------------- | ------------- | ----------- |
| (3,)          | scalar        | ✔           |
| (2, 3)        | (3,)          | ✔           |
| (2, 3)        | (2, 1)        | ✔           |
| (2, 3)        | (2,)          | ✗           |

<Tip>
  **Easy rule to remember:** Broadcasting prepares the array shapes → Vectorization performs the element-wise computation. They work as a pair: broadcasting first, computation second.
</Tip>
