---
description: This guide provides definitive, actionable best practices for writing high-performance, maintainable, and correct NumPy code, emphasizing vectorization, explicit dtypes, and modern GPU acceleration.
globs: **/*.py
---
# numpy Best Practices

NumPy is the bedrock of numerical computing in Python. To fully leverage its power for AI/ML and data science in 2025, we must write code that is not just functional but also fast, readable, and robust. This guide outlines our team's definitive best practices for NumPy.

## Core Principles

1.  **Vectorize Everything**: Eliminate Python loops over arrays. NumPy operations are implemented in C and are orders of magnitude faster.
2.  **Be Explicit with `dtype`**: Always specify array data types to prevent unexpected casting and optimize memory/performance.
3.  **Embrace GPU Acceleration**: Utilize CuPy-compatible calls for critical, large-scale computations.

## Code Organization and Structure

Organize NumPy-heavy logic into dedicated, well-named functions and modules.

### 1. Modular Design

Isolate numerical operations into functions that accept and return NumPy arrays. This improves testability and reusability.

❌ BAD: Monolithic script
```python
import numpy as np

def process_data_script(data_path):
    data = np.loadtxt(data_path)
    # ... many lines of processing ...
    mean_val = np.mean(data)
    std_val = np.std(data)
    normalized_data = (data - mean_val) / std_val
    # ... more processing ...
    return normalized_data
```

✅ GOOD: Modular functions
```python
import numpy as np

def load_numerical_data(file_path: str) -> np.ndarray:
    """Loads numerical data from a file."""
    return np.loadtxt(file_path)

def normalize_array(arr: np.ndarray) -> np.ndarray:
    """Normalizes a NumPy array to have zero mean and unit variance."""
    if arr.size == 0:
        return arr
    mean_val = np.mean(arr)
    std_val = np.std(arr)
    # Avoid division by zero for constant arrays
    if std_val == 0:
        return np.zeros_like(arr)
    return (arr - mean_val) / std_val

def process_pipeline(data_path: str) -> np.ndarray:
    """Orchestrates data loading and processing."""
    data = load_numerical_data(data_path)
    processed_data = normalize_array(data)
    return processed_data
```

### 2. Clear Naming and Docstrings

Adhere to PEP 8 for variable and function names (`snake_case`). Use comprehensive docstrings (PEP 257) for all public functions, classes, and modules, especially for complex numerical logic.

❌ BAD: Ambiguous names, no docs
```python
def calc(a, b):
    # What does 'a' mean? What does 'b' mean?
    # What does this function do?
    return np.dot(a, b.T)
```

✅ GOOD: Descriptive names, clear docstrings
```python
import numpy as np

def calculate_covariance_matrix(data_matrix: np.ndarray) -> np.ndarray:
    """
    Calculates the covariance matrix for a given data matrix.

    Args:
        data_matrix: A 2D NumPy array where rows are observations
                     and columns are features.

    Returns:
        A 2D NumPy array representing the covariance matrix.
    """
    if data_matrix.ndim != 2:
        raise ValueError("Input data_matrix must be 2-dimensional.")

    # Center the data by subtracting the mean of each feature
    centered_data = data_matrix - np.mean(data_matrix, axis=0)

    # Calculate covariance matrix: (X^T * X) / (n - 1)
    covariance_matrix = np.cov(data_matrix, rowvar=False)
    return covariance_matrix
```

## Common Patterns and Anti-patterns

### 1. Vectorization over Python Loops

**Always** prefer NumPy's vectorized operations and broadcasting over explicit Python `for` loops when working with arrays.

❌ BAD: Element-wise loop
```python
import numpy as np

data = np.random.rand(1_000_000)
result = np.empty_like(data)
for i in range(len(data)):
    result[i] = data[i] * 2 + 5 # Slow for large arrays
```

✅ GOOD: Vectorized operation
```python
import numpy as np

data = np.random.rand(1_000_000)
result = data * 2 + 5 # Fast and concise
```

### 2. Broadcasting

Leverage broadcasting for operations between arrays of different shapes. It's efficient and avoids unnecessary memory allocation.

❌ BAD: Manual tiling or explicit loops for scalar/vector operations
```python
import numpy as np

matrix = np.array([[1, 2, 3], [4, 5, 6]])
scalar = 10
result = np.empty_like(matrix)
for i in range(matrix.shape[0]):
    for j in range(matrix.shape[1]):
        result[i, j] = matrix[i, j] + scalar # Avoid
```

✅ GOOD: Broadcasting
```python
import numpy as np

matrix = np.array([[1, 2, 3], [4, 5, 6]])
scalar = 10
result = matrix + scalar # NumPy handles this efficiently
```

## Performance Considerations

### 1. Explicit `dtype` Specification

**Always** specify `dtype` when creating arrays or performing operations that might change types. This prevents unexpected memory usage and performance penalties from implicit type conversions.

❌ BAD: Implicit `dtype`
```python
import numpy as np

# Python integers become int64 by default, even if int32 is sufficient
arr = np.array([1, 2, 3])
# float operations might implicitly upcast, consuming more memory
result = arr / 2.0
```

✅ GOOD: Explicit `dtype`
```python
import numpy as np

# Use the smallest appropriate dtype
arr_int = np.array([1, 2, 3], dtype=np.int32)
arr_float = np.array([1.0, 2.0, 3.0], dtype=np.float32)

# Ensure consistent dtypes in operations
result = arr_int.astype(np.float32) / 2.0
```

### 2. Leverage GPU Acceleration (CuPy-compatible)

For computationally intensive tasks on large arrays, especially in AI/ML, integrate GPU-accelerated libraries like CuPy. Write functions that can seamlessly switch between NumPy and CuPy.

```python
import numpy as np
try:
    import cupy as cp
    _has_cupy = True
except ImportError:
    _has_cupy = False

def matrix_multiply(a: np.ndarray, b: np.ndarray, use_gpu: bool = False) -> np.ndarray:
    """
    Performs matrix multiplication, optionally using GPU if available.
    """
    xp = cp if use_gpu and _has_cupy else np

    a_xp = xp.asarray(a)
    b_xp = xp.asarray(b)

    result = xp.dot(a_xp, b_xp)

    return result.get() if use_gpu and _has_cupy else result

# Example usage
matrix_a = np.random.rand(1000, 500)
matrix_b = np.random.rand(500, 2000)

# CPU computation
cpu_result = matrix_multiply(matrix_a, matrix_b, use_gpu=False)

# GPU computation (if CuPy installed)
if _has_cupy:
    gpu_result = matrix_multiply(matrix_a, matrix_b, use_gpu=True)
    assert np.allclose(cpu_result, gpu_result)
```

### 3. Pre-allocate Arrays

When filling an array iteratively (e.g., in a loop where vectorization isn't fully possible), pre-allocate the array with `np.empty` or `np.zeros` to avoid costly reallocations.

❌ BAD: Appending to a list then converting
```python
import numpy as np

results = []
for i in range(1000):
    results.append(np.random.rand(10))
final_array = np.array(results) # Inefficient for many appends
```

✅ GOOD: Pre-allocation
```python
import numpy as np

num_iterations = 1000
array_shape = (10,)
final_array = np.empty((num_iterations,) + array_shape, dtype=np.float32)
for i in range(num_iterations):
    final_array[i] = np.random.rand(*array_shape) # Efficient
```

## Common Pitfalls and Gotchas

### 1. Shape Mismatches

NumPy operations are strict about array shapes. Understand broadcasting rules and use `reshape`, `transpose`, `newaxis`, or `squeeze` explicitly.

❌ BAD: Implicit shape assumptions
```python
import numpy as np

vec = np.array([1, 2, 3]) # (3,)
matrix = np.array([[10, 20, 30], [40, 50, 60]]) # (2, 3)
# This will raise a ValueError if not careful, e.g., matrix + vec.T
# Or produce unexpected results due to broadcasting rules
```

✅ GOOD: Explicit shape handling
```python
import numpy as np

vec_row = np.array([1, 2, 3]) # (3,)
vec_col = np.array([1, 2, 3])[:, np.newaxis] # (3, 1)
matrix = np.array([[10, 20, 30], [40, 50, 60]]) # (2, 3)

# Correctly add a row vector to each row of a matrix
result = matrix + vec_row # (2,3) + (3,) -> (2,3)

# Correctly add a column vector to each column of a matrix
result = matrix + vec_col # (2,3) + (3,1) -> (2,3)
```

### 2. View vs. Copy

Understand when NumPy returns a view (modifying it changes the original array) versus a copy. Operations like slicing often return views. Use `.copy()` explicitly when you need an independent array.

❌ BAD: Unintended modification
```python
import numpy as np

original_data = np.arange(10)
subset = original_data[2:5] # This is a view!
subset[:] = 99 # Modifies original_data
print(original_data) # Output: [ 0  1 99 99 99  5  6  7  8  9] - DANGER!
```

✅ GOOD: Explicitly create a copy when needed
```python
import numpy as np

original_data = np.arange(10)
subset_copy = original_data[2:5].copy() # This is a copy!
subset_copy[:] = 99 # Only modifies subset_copy
print(original_data) # Output: [0 1 2 3 4 5 6 7 8 9] - SAFE!
```

## Type Hints

**Always** use type hints for function signatures involving NumPy arrays. This improves code readability, enables static analysis, and catches errors early. Use `np.ndarray` and specify `dtype` where type precision is critical.

```python
from typing import Tuple
import numpy as np

def process_sensor_data(
    readings: np.ndarray,
    threshold: float = 0.5
) -> Tuple[np.ndarray, np.ndarray]:
    """
    Processes sensor readings, separating values above/below a threshold.

    Args:
        readings: A 1D NumPy array of sensor readings (expected float32).
        threshold: The threshold value.

    Returns:
        A tuple containing two 1D NumPy arrays: (above_threshold, below_threshold).
    """
    if readings.dtype != np.float32:
        raise TypeError("Input 'readings' must be of dtype np.float32")

    above = readings[readings >= threshold]
    below = readings[readings < threshold]
    return above, below

# Example usage
data = np.random.rand(100).astype(np.float32)
high_values, low_values = process_sensor_data(data, threshold=0.7)
```

## Virtual Environments

**Always** use virtual environments (`venv` or `conda`) for every project. This isolates dependencies and prevents version conflicts.

❌ BAD: Installing packages globally
```bash
pip install numpy scipy pandas # Pollutes global Python environment
```

✅ GOOD: Using a virtual environment
```bash
# For venv
python -m venv .venv
source .venv/bin/activate
pip install numpy scipy pandas

# For conda
conda create -n my_project_env python=3.10
conda activate my_project_env
conda install numpy scipy pandas
```

## Packaging

For any reusable NumPy code, package it as a standard Python library. Use `pyproject.toml` for modern packaging.

```toml
# pyproject.toml
[project]
name = "my_numpy_utils"
version = "0.1.0"
dependencies = [
    "numpy>=1.26.0",
    "scipy>=1.12.0",
]

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
```

## Testing Approaches

**Always** write unit tests for your NumPy code. Numerical stability and correctness are paramount.

### 1. `numpy.testing` for Numerical Comparisons

Use `numpy.testing` functions (e.g., `assert_allclose`, `assert_array_equal`) for comparing arrays, as standard equality checks can fail due to floating-point inaccuracies.

❌ BAD: Direct equality for floats
```python
import numpy as np

a = np.array([0.1 + 0.2])
b = np.array([0.3])
assert a == b # Fails due to floating point precision
```

✅ GOOD: `numpy.testing.assert_allclose`
```python
import numpy as np
from numpy.testing import assert_allclose

a = np.array([0.1 + 0.2])
b = np.array([0.3])
assert_allclose(a, b, rtol=1e-5, atol=1e-8) # Passes
```

### 2. `pytest` Integration

Integrate `numpy.testing` with `pytest` for a robust testing framework.

```python
# tests/test_my_module.py
import numpy as np
from numpy.testing import assert_allclose, assert_array_equal
from my_package.my_module import normalize_array, calculate_covariance_matrix

def test_normalize_array_basic():
    arr = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32)
    expected = np.array([-1.34164079, -0.4472136 ,  0.4472136 ,  1.34164079], dtype=np.float32)
    result = normalize_array(arr)
    assert_allclose(result, expected, rtol=1e-6)

def test_normalize_array_constant():
    arr = np.array([5.0, 5.0, 5.0], dtype=np.float32)
    expected = np.array([0.0, 0.0, 0.0], dtype=np.float32)
    result = normalize_array(arr)
    assert_allclose(result, expected)

def test_calculate_covariance_matrix():
    data = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.float64)
    expected_cov = np.array([[1.0, 1.0], [1.0, 1.0]], dtype=np.float64)
    result_cov = calculate_covariance_matrix(data)
    assert_allclose(result_cov, expected_cov, rtol=1e-9)

def test_calculate_covariance_matrix_raises_error_for_1d():
    data = np.array([1, 2, 3], dtype=np.float64)
    with np.testing.assert_raises(ValueError):
        calculate_covariance_matrix(data)
```
