NumPy, short for Numerical Python, is a fundamental package for scientific computing in Python. It provides powerful tools for working with large, multi-dimensional arrays and matrices, along with a vast collection of mathematical functions to operate on these arrays efficiently. Whether you're a data scientist, researcher, or developer working on numerical computations, NumPy is an essential library to master.

## Introduction to NumPy

NumPy is the foundation for many other Python libraries in the scientific computing ecosystem. It's designed to be fast and memory-efficient, making it ideal for handling large datasets and complex mathematical operations.

To get started with NumPy, you first need to install it. You can do this using pip:

``````pip install numpy
``````

Once installed, you can import NumPy in your Python script:

``````import numpy as np
``````

It's common practice to import NumPy with the alias `np` for brevity.

## NumPy Arrays: The Building Blocks

At the core of NumPy are its powerful array objects. Unlike Python lists, NumPy arrays are homogeneous, meaning all elements must be of the same data type. This constraint allows for more efficient storage and faster operations.

### Creating NumPy Arrays

There are several ways to create NumPy arrays:

1. From Python lists:
``````import numpy as np

# Create a 1D array
arr1d = np.array([1, 2, 3, 4, 5])
print(arr1d)  # Output: [1 2 3 4 5]

# Create a 2D array
arr2d = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(arr2d)
# Output:
# [[1 2 3]
#  [4 5 6]
#  [7 8 9]]
``````
1. Using NumPy functions:
``````# Create an array of zeros
zeros_arr = np.zeros((3, 4))
print(zeros_arr)
# Output:
# [[0. 0. 0. 0.]
#  [0. 0. 0. 0.]
#  [0. 0. 0. 0.]]

# Create an array of ones
ones_arr = np.ones((2, 3))
print(ones_arr)
# Output:
# [[1. 1. 1.]
#  [1. 1. 1.]]

# Create an array with a range of values
range_arr = np.arange(0, 10, 2)
print(range_arr)  # Output: [0 2 4 6 8]

# Create an array with evenly spaced values
linspace_arr = np.linspace(0, 1, 5)
print(linspace_arr)  # Output: [0.   0.25 0.5  0.75 1.  ]
``````

### Array Attributes and Methods

NumPy arrays come with useful attributes and methods:

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

print(arr.shape)  # Output: (2, 3)
print(arr.ndim)   # Output: 2
print(arr.size)   # Output: 6
print(arr.dtype)  # Output: int64

# Reshaping an array
reshaped_arr = arr.reshape(3, 2)
print(reshaped_arr)
# Output:
# [[1 2]
#  [3 4]
#  [5 6]]

# Flattening an array
flat_arr = arr.flatten()
print(flat_arr)  # Output: [1 2 3 4 5 6]
``````

## Array Indexing and Slicing

NumPy provides powerful indexing and slicing capabilities for accessing and modifying array elements.

### Basic Indexing

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

# Accessing elements
print(arr[0, 0])  # Output: 1
print(arr[1, 2])  # Output: 6

# Modifying elements
arr[2, 1] = 10
print(arr)
# Output:
# [[ 1  2  3]
#  [ 4  5  6]
#  [ 7 10  9]]
``````

### Slicing

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

# Slicing rows and columns
print(arr[0:2, 1:3])
# Output:
# [[2 3]
#  [6 7]]

# Using steps in slicing
print(arr[::2, ::2])
# Output:
# [[ 1  3]
#  [ 9 11]]
``````

### Boolean Indexing

Boolean indexing allows you to select elements based on conditions:

``````arr = np.array([1, 2, 3, 4, 5])

# Select elements greater than 2
print(arr[arr > 2])  # Output: [3 4 5]

mask = arr % 2 == 0
``````

## Array Operations

NumPy provides a wide range of operations that can be performed on arrays efficiently.

### Element-wise Operations

``````a = np.array([1, 2, 3])
b = np.array([4, 5, 6])

print(a + b)  # Output: [5 7 9]

# Multiplication
print(a * b)  # Output: [ 4 10 18]

# Exponentiation
print(a ** 2)  # Output: [1 4 9]
``````

Broadcasting allows NumPy to work with arrays of different shapes when performing arithmetic operations:

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

print(arr + scalar)
# Output:
# [[ 3  4  5]
#  [ 6  7  8]
#  [ 9 10 11]]

# Broadcasting 1D array to 2D array
col_vector = np.array([[1], [2], [3]])
print(arr + col_vector)
# Output:
# [[ 2  3  4]
#  [ 6  7  8]
#  [10 11 12]]
``````

### Universal Functions (ufuncs)

NumPy's universal functions operate element-wise on arrays, supporting broadcasting, type casting, and other features:

``````arr = np.array([0, 30, 45, 60, 90])

# Trigonometric functions
print(np.sin(arr * np.pi / 180))
# Output: [0.         0.5        0.70710678 0.8660254  1.        ]

# Exponential and logarithmic functions
print(np.exp(arr))
# Output: [1.00000000e+00 1.06864745e+13 2.55351295e+19 1.14200739e+26 1.22040329e+39]

print(np.log(np.array([1, np.e, np.e**2, np.e**3])))
# Output: [0. 1. 2. 3.]
``````

## Linear Algebra with NumPy

NumPy provides a comprehensive set of linear algebra operations:

### Matrix Multiplication

``````a = np.array([[1, 2], [3, 4]])
b = np.array([[5, 6], [7, 8]])

# Matrix multiplication
print(np.dot(a, b))
# Output:
# [[19 22]
#  [43 50]]

# Alternative syntax
print(a @ b)
# Output:
# [[19 22]
#  [43 50]]
``````

### Eigenvalues and Eigenvectors

``````matrix = np.array([[1, 2], [2, 3]])

eigenvalues, eigenvectors = np.linalg.eig(matrix)

print("Eigenvalues:", eigenvalues)
# Output: Eigenvalues: [-0.23606798  4.23606798]

print("Eigenvectors:")
print(eigenvectors)
# Output:
# Eigenvectors:
# [[-0.85065081 -0.52573111]
#  [ 0.52573111 -0.85065081]]
``````

### Solving Linear Systems

``````# Solve the system: 3x + 2y = 8, x + y = 3
A = np.array([[3, 2], [1, 1]])
b = np.array([8, 3])

x = np.linalg.solve(A, b)
print("Solution:", x)
# Output: Solution: [2. 1.]
``````

## Statistical Operations

NumPy offers a variety of statistical functions to analyze data:

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

print("Mean:", np.mean(data))        # Output: Mean: 5.5
print("Median:", np.median(data))    # Output: Median: 5.5
print("Standard deviation:", np.std(data))  # Output: Standard deviation: 2.8722813232690143

# Percentiles
print("25th percentile:", np.percentile(data, 25))  # Output: 25th percentile: 3.25
print("75th percentile:", np.percentile(data, 75))  # Output: 75th percentile: 7.75

# Correlation coefficient
x = np.array([1, 2, 3, 4, 5])
y = np.array([2, 4, 5, 4, 5])
correlation = np.corrcoef(x, y)
print("Correlation coefficient:")
print(correlation)
# Output:
# Correlation coefficient:
# [[1.         0.8164966 ]
#  [0.8164966  1.        ]]
``````

## Random Number Generation

NumPy's random module provides functions for generating random numbers and samples:

``````# Set a seed for reproducibility
np.random.seed(42)

# Generate random integers
random_ints = np.random.randint(1, 11, size=5)
print("Random integers:", random_ints)
# Output: Random integers: [7 7 4 9 2]

# Generate random floats
random_floats = np.random.random(5)
print("Random floats:", random_floats)
# Output: Random floats: [0.37454012 0.95071431 0.73199394 0.59865848 0.15601864]

# Generate samples from a normal distribution
normal_samples = np.random.normal(loc=0, scale=1, size=5)
print("Samples from normal distribution:", normal_samples)
# Output: Samples from normal distribution: [ 0.42628313 -0.89182855  0.14071984 -0.38710058 -0.92254099]

# Generate a random permutation
arr = np.arange(10)
np.random.shuffle(arr)
print("Shuffled array:", arr)
# Output: Shuffled array: [3 7 2 4 9 1 5 8 0 6]
``````

## File I/O with NumPy

NumPy provides functions to save and load array data to and from files:

### Saving Arrays

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

# Save as a text file
np.savetxt('array.txt', arr)

# Save as a binary file
np.save('array.npy', arr)
``````

``````# Load from a text file

# Load from a binary file
``````

### Structured Arrays

Structured arrays allow you to define complex data types:

``````# Define a structured array
dt = np.dtype([('name', 'U10'), ('age', 'i4'), ('weight', 'f4')])
people = np.array([('Alice', 25, 55.5), ('Bob', 30, 70.2), ('Charlie', 35, 65.7)], dtype=dt)

print(people['name'])    # Output: ['Alice' 'Bob' 'Charlie']
print(people['age'])     # Output: [25 30 35]
print(people['weight'])  # Output: [55.5 70.2 65.7]
``````

### Memory Views

Memory views provide a way to access array data without copying:

``````arr = np.array([1, 2, 3, 4], dtype=np.int32)
memview = memoryview(arr)

# Modify the array through the memory view
memview[2] = 10

print(arr)  # Output: [ 1  2 10  4]
``````

Masked arrays allow you to work with arrays that have missing or invalid data:

``````data = np.array([1, 2, -999, 4, 5])

print(masked_data)  # Output: [1 2 -- 4 5]