NumPy - np.ix_() for Cross-Indexing

When working with multidimensional arrays, you often need to select elements at specific positions along different axes. Consider a scenario where you have a 2D array and want to extract rows [0, 2,...

Key Insights

  • np.ix_() constructs open mesh grids from multiple sequences, enabling elegant cross-indexing operations that would otherwise require nested loops or broadcasting gymnastics
  • The function returns a tuple of arrays with shapes designed for broadcasting, allowing you to select arbitrary combinations of indices across multiple dimensions simultaneously
  • Cross-indexing with np.ix_() is particularly powerful for extracting submatrices, performing batch operations on non-contiguous data, and implementing complex slicing patterns with minimal code

Understanding the Cross-Indexing Problem

When working with multidimensional arrays, you often need to select elements at specific positions along different axes. Consider a scenario where you have a 2D array and want to extract rows [0, 2, 4] and columns [1, 3]. The naive approach requires explicit loops or awkward indexing:

import numpy as np

data = np.arange(25).reshape(5, 5)
print(data)
# [[ 0  1  2  3  4]
#  [ 5  6  7  8  9]
#  [10 11 12 13 14]
#  [15 16 17 18 19]
#  [20 21 22 23 24]]

# Incorrect attempt - this doesn't give cross-product
rows = np.array([0, 2, 4])
cols = np.array([1, 3])
result = data[rows, cols]  # This pairs elements, not cross-product
print(result)
# [ 1 13 24]  # Wrong! Only 3 elements

This is where np.ix_() shines. It creates index arrays that broadcast correctly to produce the full cross-product:

ix = np.ix_(rows, cols)
result = data[ix]
print(result)
# [[ 1  3]
#  [11 13]
#  [21 23]]  # Correct! All combinations

How np.ix_() Works Under the Hood

The function transforms 1D sequences into arrays with specific shapes that enable broadcasting. Each returned array has shape (n, 1, 1, ...) with the non-singleton dimension in a different position:

rows = [0, 2, 4]
cols = [1, 3]
ix = np.ix_(rows, cols)

print(f"First array shape: {ix[0].shape}")   # (3, 1)
print(f"Second array shape: {ix[1].shape}")  # (1, 2)

print("First array:\n", ix[0])
# [[0]
#  [2]
#  [4]]

print("Second array:\n", ix[1])
# [[1 3]]

When used together for indexing, NumPy broadcasts these arrays to create a 3×2 result. The first array selects rows, the second selects columns, and broadcasting handles the cross-product.

Practical Applications

Extracting Submatrices

One of the most common use cases is extracting arbitrary submatrices from larger arrays:

# Create a correlation matrix
np.random.seed(42)
correlation_matrix = np.random.rand(10, 10)
correlation_matrix = (correlation_matrix + correlation_matrix.T) / 2  # Make symmetric

# Extract correlations for specific variables
variables_of_interest = [1, 3, 7, 9]
submatrix = correlation_matrix[np.ix_(variables_of_interest, variables_of_interest)]

print(f"Original shape: {correlation_matrix.shape}")
print(f"Submatrix shape: {submatrix.shape}")  # (4, 4)

Batch Operations on Sparse Indices

When you need to perform operations on specific combinations of indices across multiple dimensions:

# 3D array: (samples, features, time_steps)
data_3d = np.random.randn(100, 50, 200)

# Select specific samples, features, and time windows
samples = [0, 5, 10, 15, 20]
features = [2, 7, 12, 18]
time_points = list(range(50, 150))  # Time window

subset = data_3d[np.ix_(samples, features, time_points)]
print(f"Subset shape: {subset.shape}")  # (5, 4, 100)

# Compute statistics on this specific subset
mean_activation = subset.mean(axis=2)  # Average over time
print(f"Mean activation shape: {mean_activation.shape}")  # (5, 4)

Reordering and Permutation

np.ix_() excels at reordering rows and columns simultaneously:

# Distance matrix between cities
cities = ['NYC', 'LA', 'Chicago', 'Houston', 'Phoenix']
distances = np.random.randint(500, 3000, size=(5, 5))
np.fill_diagonal(distances, 0)

# Reorder to group by region
west_coast = [1, 4]  # LA, Phoenix
central = [2, 3]     # Chicago, Houston
east_coast = [0]     # NYC

new_order = east_coast + central + west_coast
reordered = distances[np.ix_(new_order, new_order)]

print("Original order:", cities)
print("New order:", [cities[i] for i in new_order])
print(f"Reordered matrix shape: {reordered.shape}")  # (5, 5)

Advanced Patterns

Combining with Boolean Indexing

You can mix np.ix_() with boolean masks for sophisticated filtering:

data = np.random.randn(20, 30)

# Find rows and columns with specific properties
row_mask = data.mean(axis=1) > 0  # Rows with positive mean
col_mask = data.std(axis=0) > 0.5  # Columns with high variance

row_indices = np.where(row_mask)[0]
col_indices = np.where(col_mask)[0]

filtered_data = data[np.ix_(row_indices, col_indices)]
print(f"Filtered from {data.shape} to {filtered_data.shape}")

Building Block Matrices

Construct matrices from selected blocks of a larger array:

# Large matrix divided into conceptual blocks
matrix = np.arange(64).reshape(8, 8)

# Extract 2x2 blocks at specific positions
block_rows_1 = [0, 1]
block_cols_1 = [0, 1]
block_rows_2 = [4, 5]
block_cols_2 = [6, 7]

block_1 = matrix[np.ix_(block_rows_1, block_cols_1)]
block_2 = matrix[np.ix_(block_rows_2, block_cols_2)]

print("Block 1:\n", block_1)
# [[ 0  1]
#  [ 8  9]]

print("Block 2:\n", block_2)
# [[38 39]
#  [46 47]]

Assignment with Cross-Indexing

You can also use np.ix_() for assignment operations:

data = np.zeros((10, 10))

# Set specific rows and columns to a value
rows_to_modify = [1, 3, 5, 7]
cols_to_modify = [2, 4, 6]

data[np.ix_(rows_to_modify, cols_to_modify)] = 99

print(data[0:8, 0:8])
# Shows 99s at the intersection of specified rows and columns

Performance Considerations

np.ix_() creates views when possible, making it memory-efficient:

import sys

large_array = np.random.rand(1000, 1000)
indices_rows = list(range(0, 1000, 10))
indices_cols = list(range(0, 1000, 10))

# Using np.ix_()
subset_ix = large_array[np.ix_(indices_rows, indices_cols)]

# Manual approach with broadcasting
subset_manual = large_array[np.array(indices_rows)[:, None], 
                           np.array(indices_cols)]

print(f"Both methods produce same result: {np.allclose(subset_ix, subset_manual)}")
print(f"Result shape: {subset_ix.shape}")  # (100, 100)

For very large arrays with sparse index selections, np.ix_() is significantly cleaner and often faster than manual broadcasting or loop-based approaches.

Common Pitfalls

The most frequent mistake is confusing np.ix_() with direct fancy indexing:

data = np.arange(20).reshape(4, 5)
rows = [0, 2]
cols = [1, 3]

# Wrong: pairs elements
wrong = data[rows, cols]
print(f"Wrong shape: {wrong.shape}")  # (2,) - only 2 elements

# Correct: cross-product
correct = data[np.ix_(rows, cols)]
print(f"Correct shape: {correct.shape}")  # (2, 2) - all combinations

Another gotcha is forgetting that np.ix_() requires sequences, not single integers:

# This fails
try:
    result = data[np.ix_(0, [1, 2])]
except TypeError as e:
    print(f"Error: {e}")

# Use lists even for single indices
result = data[np.ix_([0], [1, 2])]
print(f"Correct shape: {result.shape}")  # (1, 2)

When to Use np.ix_()

Use np.ix_() when you need to:

  • Extract submatrices with non-contiguous rows and columns
  • Apply operations to specific cross-products of indices
  • Reorder multiple dimensions simultaneously
  • Write clean, readable code instead of complex broadcasting logic

Avoid it when working with simple slicing (data[2:5, 3:7]) or when you genuinely need element-wise pairing rather than cross-products. Understanding this distinction makes np.ix_() a powerful tool for complex array manipulations.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.