Toolmingo
Guides16 min read

NumPy Tutorial for Beginners (2025): Learn NumPy Step by Step

Complete NumPy tutorial for absolute beginners. Learn arrays, indexing, slicing, broadcasting, math operations, linear algebra, and data analysis. Free guide with real examples.

NumPy (Numerical Python) is the foundation of scientific computing in Python. It powers pandas, scikit-learn, TensorFlow, PyTorch, and virtually every data science and machine learning library. This tutorial takes you from zero to confident NumPy user — no prior experience required.

Why Learn NumPy?

NumPy is the single most important library for numerical computing in Python. Here's why it matters:

Reason Detail
Speed C-implemented operations — 50–100x faster than pure Python loops
Foundation Required by pandas, sklearn, TensorFlow, OpenCV, SciPy
Memory efficient Contiguous typed arrays vs. Python lists of objects
Vectorization Write math formulas directly — no loops needed
Broadcasting Operate on arrays of different shapes cleanly
Ecosystem Universal array protocol — every ML/data library speaks NumPy

Installation

# With pip
pip install numpy

# With conda (recommended for data science)
conda install numpy

# Verify
python -c "import numpy as np; print(np.__version__)"
# 1.26.4 (or newer)

NumPy is almost always pre-installed in Jupyter, Google Colab, and Anaconda distributions.


1. Your First NumPy Array

import numpy as np

# Create from a Python list
a = np.array([1, 2, 3, 4, 5])
print(a)        # [1 2 3 4 5]
print(type(a))  # <class 'numpy.ndarray'>

# 2D array (matrix)
matrix = np.array([[1, 2, 3],
                   [4, 5, 6],
                   [7, 8, 9]])
print(matrix.shape)  # (3, 3)
print(matrix.ndim)   # 2
print(matrix.size)   # 9
print(matrix.dtype)  # int64

ndarray vs Python list

Feature Python list NumPy ndarray
Type Mixed types OK One dtype (homogeneous)
Speed Slow (interpreted loops) Fast (C loops, SIMD)
Memory Object pointers + objects Contiguous typed buffer
Math No built-in math Full vectorized math
Dimensions Nested lists (jagged) True n-dimensional
Syntax [a + b for a, b in zip(x, y)] x + y

2. Creating Arrays

NumPy provides many functions to create arrays without manual typing:

import numpy as np

# Zeros and ones
np.zeros((3, 4))          # 3×4 matrix of 0.0
np.ones((2, 3))           # 2×3 matrix of 1.0
np.full((2, 2), 7)        # 2×2 matrix filled with 7

# Identity matrix
np.eye(3)                 # 3×3 identity matrix

# Ranges
np.arange(0, 10, 2)       # [0 2 4 6 8] — like range()
np.linspace(0, 1, 5)      # [0.   0.25 0.5  0.75 1. ] — evenly spaced

# Random
np.random.rand(3, 3)      # Uniform random [0, 1)
np.random.randn(3, 3)     # Standard normal (mean=0, std=1)
np.random.randint(0, 10, size=(3, 3))   # Random integers

# From existing data
np.array([1.5, 2.5, 3.5], dtype=np.float32)   # Explicit dtype
np.zeros_like(matrix)     # Same shape/dtype as matrix, filled with 0
np.ones_like(matrix)      # Same shape/dtype as matrix, filled with 1

Common dtypes

dtype Description Size
np.int32 32-bit integer 4 bytes
np.int64 64-bit integer (default) 8 bytes
np.float32 Single precision float 4 bytes
np.float64 Double precision float (default) 8 bytes
np.bool_ Boolean 1 byte
np.complex128 Complex number 16 bytes
np.str_ Unicode string variable
# Convert dtype
a = np.array([1, 2, 3])
a_float = a.astype(np.float64)
print(a_float.dtype)  # float64

3. Array Indexing and Slicing

1D Arrays

a = np.array([10, 20, 30, 40, 50])

a[0]      # 10   — first element
a[-1]     # 50   — last element
a[1:3]    # [20 30]  — slice (end exclusive)
a[:3]     # [10 20 30]
a[2:]     # [30 40 50]
a[::2]    # [10 30 50]  — every 2nd
a[::-1]   # [50 40 30 20 10]  — reverse

2D Arrays

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

m[0, 0]   # 1    — row 0, col 0
m[1, 2]   # 6    — row 1, col 2
m[0]      # [1 2 3]  — entire row 0
m[:, 1]   # [2 5 8]  — entire column 1
m[0:2, 1:3]  # [[2 3] [5 6]]  — submatrix
m[::2, ::2]  # [[1 3] [7 9]]  — every 2nd row and col

Boolean Indexing (Very Important)

a = np.array([10, 25, 3, 47, 8])

mask = a > 10
print(mask)     # [False  True False  True False]
print(a[mask])  # [25 47]

# One-liner
print(a[a > 10])   # [25 47]
print(a[a % 2 == 0])  # [10 8]

# 2D boolean indexing
m = np.array([[1, 2, 3], [4, 5, 6]])
print(m[m > 3])  # [4 5 6]

Fancy Indexing

a = np.array([10, 20, 30, 40, 50])

# Index with an array of indices
indices = [0, 2, 4]
print(a[indices])   # [10 30 50]

# 2D fancy indexing
m = np.array([[1, 2], [3, 4], [5, 6]])
rows = [0, 2]
cols = [1, 0]
print(m[rows, cols])  # [2 5]  — (row 0, col 1) and (row 2, col 0)

4. Array Shape Manipulation

Understanding shapes is critical for ML/data work:

a = np.arange(12)
print(a.shape)  # (12,)

# Reshape
b = a.reshape(3, 4)
print(b.shape)   # (3, 4)

c = a.reshape(2, 2, 3)
print(c.shape)   # (2, 2, 3)

# -1 means "figure it out"
d = a.reshape(3, -1)   # (3, 4)
e = a.reshape(-1, 6)   # (2, 6)

# Flatten (always returns a copy)
flat = b.flatten()     # shape (12,)

# Ravel (returns a view when possible)
flat2 = b.ravel()      # shape (12,)

# Transpose
m = np.array([[1, 2, 3], [4, 5, 6]])
print(m.shape)       # (2, 3)
print(m.T.shape)     # (3, 2)

# Add dimension
a = np.array([1, 2, 3])        # shape (3,)
print(a[np.newaxis, :].shape)  # (1, 3)  — row vector
print(a[:, np.newaxis].shape)  # (3, 1)  — column vector

# Squeeze (remove dimensions of size 1)
x = np.zeros((1, 3, 1))
print(np.squeeze(x).shape)  # (3,)

Stack and Split

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

np.vstack([a, b])          # [[1 2 3] [4 5 6]] — stack rows
np.hstack([a, b])          # [1 2 3 4 5 6]     — concatenate along axis 1
np.stack([a, b], axis=0)   # [[1 2 3] [4 5 6]] — new axis
np.stack([a, b], axis=1)   # [[1 4] [2 5] [3 6]]
np.concatenate([a, b])     # [1 2 3 4 5 6]

# Split
x = np.arange(9)
np.split(x, 3)             # [array([0,1,2]), array([3,4,5]), array([6,7,8])]
np.array_split(x, 4)       # Unequal split OK

5. Math Operations

Element-wise Operations

NumPy performs operations element-by-element by default:

a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])

a + b      # [11 22 33 44]
a - b      # [-9 -18 -27 -36]
a * b      # [10 40 90 160]
b / a      # [10. 10. 10. 10.]
b // a     # [10 10 10 10]  — floor division
a ** 2     # [1 4 9 16]
b % 3      # [1 2 0 1]

# Scalar operations (broadcast to entire array)
a * 2      # [2 4 6 8]
a + 100    # [101 102 103 104]
a > 2      # [False False True True]

Universal Functions (ufuncs)

Ufuncs are fast element-wise functions implemented in C:

a = np.array([1.0, 4.0, 9.0, 16.0])

np.sqrt(a)          # [1. 2. 3. 4.]
np.exp(a)           # [e^1 e^4 e^9 e^16]
np.log(a)           # natural log
np.log2(a)          # base-2 log
np.log10(a)         # base-10 log
np.sin(a)           # sine
np.cos(a)           # cosine
np.abs(np.array([-1, -2, 3]))  # [1 2 3]
np.floor(np.array([1.7, 2.3]))  # [1. 2.]
np.ceil(np.array([1.7, 2.3]))   # [2. 3.]
np.round(np.array([1.456]), 2)  # [1.46]

Aggregation Functions

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

np.sum(a)           # 21  — all elements
np.sum(a, axis=0)   # [5 7 9]  — sum each column
np.sum(a, axis=1)   # [6 15]   — sum each row

np.mean(a)          # 3.5
np.mean(a, axis=0)  # [2.5 3.5 4.5]

np.std(a)           # 1.707...
np.var(a)           # 2.916...

np.min(a)           # 1
np.max(a)           # 6
np.argmin(a)        # 0  — index of minimum (flattened)
np.argmax(a)        # 5  — index of maximum (flattened)

np.cumsum(a)        # [ 1  3  6 10 15 21] — cumulative sum
np.cumprod(a)       # [  1   2   6  24 120 720]

np.median(a)        # 3.5
np.percentile(a, 75)  # 5.25

6. Broadcasting

Broadcasting lets NumPy operate on arrays of different shapes — this is one of NumPy's most powerful features:

# Scalar broadcasts to entire array
a = np.array([1, 2, 3])
a + 10          # [11 12 13]

# 1D array broadcasts across 2D matrix rows
matrix = np.array([[1, 2, 3],
                   [4, 5, 6]])
row = np.array([10, 20, 30])

matrix + row    # [[11 22 33]
                #  [14 25 36]]

# Column vector broadcasts across columns
col = np.array([[100],
                [200]])

matrix + col    # [[101 102 103]
                #  [204 205 206]]

Broadcasting Rules

Two dimensions are compatible if:

  1. They are equal, OR
  2. One of them is 1
Shape A:  (3, 4)   →  (3, 4)
Shape B:     (4)   →  (1, 4)  [auto-padded]
Result:   (3, 4)

Shape A:  (3, 1)   →  (3, 1)
Shape B:  (1, 4)   →  (1, 4)
Result:   (3, 4)
# Practical: subtract mean from each column
data = np.array([[1, 2, 3],
                 [4, 5, 6],
                 [7, 8, 9]])
col_means = data.mean(axis=0)  # [4. 5. 6.]
centered = data - col_means     # [[-3,-3,-3], [0,0,0], [3,3,3]]

# Practical: add bias vector to batch of vectors
batch = np.random.randn(32, 128)  # 32 examples, 128 features
bias = np.random.randn(128)        # 128 biases
result = batch + bias               # (32, 128) — bias broadcast over batch

7. Linear Algebra

NumPy's linalg module covers all essential matrix math:

import numpy as np

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

# Matrix multiplication
np.dot(A, B)         # [[19 22] [43 50]]
A @ B                # Same — preferred in Python 3.5+

# Element-wise vs matrix multiply
A * B                # [[5 12] [21 32]]  — NOT matrix multiply

# Transpose
A.T                  # [[1 3] [2 4]]

# Determinant
np.linalg.det(A)     # -2.0

# Inverse
np.linalg.inv(A)     # [[-2. 1.] [1.5 -0.5]]

# Solve linear system Ax = b
b = np.array([1, 2])
x = np.linalg.solve(A, b)   # [-0.  0.5]

# Eigenvalues and eigenvectors
eigenvalues, eigenvectors = np.linalg.eig(A)

# Singular Value Decomposition
U, S, Vt = np.linalg.svd(A)

# Matrix norm
np.linalg.norm(A)           # Frobenius norm
np.linalg.norm(A, ord=2)    # Spectral norm (largest singular value)

Practical Linear Algebra: Least Squares

# Fit a line y = mx + c to noisy data
x = np.array([1, 2, 3, 4, 5], dtype=float)
y = 2 * x + 1 + np.random.randn(5) * 0.5

# Build design matrix
X = np.column_stack([x, np.ones_like(x)])  # [[1,1],[2,1],...]

# Least squares solution
coeffs, residuals, rank, sv = np.linalg.lstsq(X, y, rcond=None)
m, c = coeffs
print(f"y ≈ {m:.2f}x + {c:.2f}")

8. Sorting and Searching

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

np.sort(a)              # [1 1 2 3 4 5 6 9]  — returns sorted copy
a.sort()                # sorts in-place

np.argsort(a)           # indices that would sort the array
np.argsort(a)[::-1]     # indices for descending order

# Top-k elements
top3_indices = np.argsort(a)[-3:][::-1]  # indices of 3 largest

# 2D sorting
m = np.array([[3, 1], [0, 2]])
np.sort(m, axis=1)      # sort each row: [[1 3] [0 2]]
np.sort(m, axis=0)      # sort each column: [[0 1] [3 2]]

# Search
np.where(a > 3)         # indices where condition is True
np.where(a > 3, a, 0)   # a where True, 0 where False (ternary)

np.searchsorted([1, 3, 5, 7], 4)   # 2 — where to insert 4 to keep sorted

np.nonzero(a)           # indices of non-zero elements

9. Set Operations

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

np.union1d(a, b)        # [1 2 3 4 5 6]
np.intersect1d(a, b)    # [3 4]
np.setdiff1d(a, b)      # [1 2]  — in a but not b
np.setxor1d(a, b)       # [1 2 5 6]  — in one but not both
np.in1d(a, b)           # [False False True True]  — a elements in b?
np.isin(a, b)           # Same as in1d, newer API
np.unique(np.array([1, 2, 2, 3, 1]))   # [1 2 3]

10. Random Module

rng = np.random.default_rng(seed=42)  # Modern API — reproducible

rng.random((3, 3))          # Uniform [0, 1)
rng.standard_normal((3, 3)) # Standard normal N(0,1)
rng.integers(0, 10, (3, 3)) # Random integers [0, 10)
rng.choice([1, 2, 3, 4], size=5)            # With replacement
rng.choice([1, 2, 3, 4], size=3, replace=False)  # Without replacement

# Shuffle (in-place)
a = np.arange(10)
rng.shuffle(a)

# Distributions
rng.normal(loc=0, scale=1, size=1000)     # Gaussian
rng.uniform(low=0, high=10, size=1000)    # Uniform
rng.binomial(n=10, p=0.5, size=1000)     # Binomial
rng.poisson(lam=5, size=1000)             # Poisson
rng.exponential(scale=1, size=1000)       # Exponential

11. File I/O

import numpy as np

# Save and load binary (fast, NumPy format)
a = np.array([1, 2, 3])
np.save('array.npy', a)           # Single array
b = np.load('array.npy')          # Load it back

# Multiple arrays
np.savez('arrays.npz', x=a, y=a*2)
data = np.load('arrays.npz')
print(data['x'], data['y'])

# Compressed (larger arrays)
np.savez_compressed('arrays_compressed.npz', x=a)

# Text files (CSV)
matrix = np.array([[1, 2, 3], [4, 5, 6]])
np.savetxt('matrix.csv', matrix, delimiter=',', fmt='%d')
loaded = np.loadtxt('matrix.csv', delimiter=',')

# Load CSV with header (skip first row)
loaded = np.loadtxt('matrix.csv', delimiter=',', skiprows=1)

# genfromtxt — handles missing values
data = np.genfromtxt('data.csv', delimiter=',', filling_values=0)

3 Practical Projects

Project 1: Image as a NumPy Array

Images are just 3D NumPy arrays — this is the foundation of computer vision:

import numpy as np

# Simulate an RGB image (height=100, width=100, channels=3)
image = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8)

print(image.shape)    # (100, 100, 3)
print(image.dtype)    # uint8

# Crop (rows 10-50, cols 20-60)
crop = image[10:50, 20:60]
print(crop.shape)  # (40, 40, 3)

# Convert to grayscale (average channels)
grayscale = image.mean(axis=2)  # (100, 100)

# Extract red channel only
red = image[:, :, 0]

# Flip horizontally
flipped = image[:, ::-1, :]

# Normalize to [0, 1]
normalized = image / 255.0

# Adjust brightness (clamp to [0, 255])
brighter = np.clip(image.astype(int) + 50, 0, 255).astype(np.uint8)

# Batch of images (4 images, 100×100, RGB)
batch = np.random.randint(0, 256, (4, 100, 100, 3), dtype=np.uint8)
print(batch.shape)  # (4, 100, 100, 3)

# Normalize entire batch
batch_norm = batch / 255.0

Project 2: Statistics on Student Scores

import numpy as np

# Student scores: 50 students × 5 subjects
rng = np.random.default_rng(42)
scores = rng.integers(40, 101, size=(50, 5))
subjects = ["Math", "Science", "English", "History", "CS"]

# --- Summary Statistics ---
print("=== Class Summary ===")
for i, subj in enumerate(subjects):
    col = scores[:, i]
    print(f"{subj:10s}: mean={col.mean():.1f}  std={col.std():.1f}  "
          f"min={col.min()}  max={col.max()}")

# --- Student Averages ---
student_avgs = scores.mean(axis=1)    # Average per student
top5_idx = np.argsort(student_avgs)[-5:][::-1]
print(f"\nTop 5 students (indices): {top5_idx}")
print(f"Their averages: {student_avgs[top5_idx].round(1)}")

# --- Grade Distribution ---
letter_grades = np.select(
    [scores >= 90, scores >= 80, scores >= 70, scores >= 60],
    ['A', 'B', 'C', 'D'],
    default='F'
)
for grade in ['A', 'B', 'C', 'D', 'F']:
    count = np.sum(letter_grades == grade)
    print(f"Grade {grade}: {count} ({count/scores.size*100:.1f}%)")

# --- Pass/Fail ---
passing_score = 60
passed = (scores >= passing_score).all(axis=1)  # Pass ALL subjects
print(f"\nStudents passing all subjects: {passed.sum()}/{len(passed)}")

# --- Correlation between subjects ---
correlation = np.corrcoef(scores.T)  # 5×5 correlation matrix
print(f"\nMath–CS correlation: {correlation[0, 4]:.3f}")

Project 3: Neural Network Forward Pass from Scratch

import numpy as np

# Simple 2-layer neural network (manual — no PyTorch/TF)
rng = np.random.default_rng(42)

# Dimensions
input_dim, hidden_dim, output_dim = 784, 128, 10
batch_size = 32

# Random weights and biases
W1 = rng.standard_normal((input_dim, hidden_dim)) * 0.01
b1 = np.zeros((1, hidden_dim))
W2 = rng.standard_normal((hidden_dim, output_dim)) * 0.01
b2 = np.zeros((1, output_dim))

# Activation functions
def relu(z):
    return np.maximum(0, z)

def softmax(z):
    e = np.exp(z - z.max(axis=1, keepdims=True))  # subtract max for stability
    return e / e.sum(axis=1, keepdims=True)

# Forward pass
X = rng.standard_normal((batch_size, input_dim))   # input batch
y = rng.integers(0, output_dim, batch_size)          # true labels

Z1 = X @ W1 + b1          # (32, 128)
A1 = relu(Z1)              # (32, 128)
Z2 = A1 @ W2 + b2         # (32, 10)
probs = softmax(Z2)        # (32, 10) — probability distribution

# Cross-entropy loss
log_probs = np.log(probs[np.arange(batch_size), y] + 1e-9)
loss = -log_probs.mean()
print(f"Loss: {loss:.4f}")

# Predictions
predictions = probs.argmax(axis=1)
accuracy = (predictions == y).mean()
print(f"Accuracy (random init): {accuracy:.2%}")

NumPy vs Python Lists: Performance

import numpy as np
import time

n = 1_000_000

# Python list
lst = list(range(n))
start = time.time()
result = [x * 2 for x in lst]
python_time = time.time() - start

# NumPy array
arr = np.arange(n)
start = time.time()
result = arr * 2
numpy_time = time.time() - start

print(f"Python list: {python_time*1000:.1f}ms")
print(f"NumPy array: {numpy_time*1000:.1f}ms")
print(f"Speedup: {python_time/numpy_time:.0f}x")
# Typical output:
# Python list: 45.2ms
# NumPy array: 0.9ms
# Speedup: 50x

Common Mistakes

Mistake Wrong Right
Copy vs view b = a[1:3] then modify b → modifies a b = a[1:3].copy()
Shape mismatch Adding (3,) + (3,1) expecting element-wise Understand broadcasting first
Integer division np.array([1,2]) / np.array([3,4]) returns floats (Python 3) Fine in Python 3; use // for floor div
in-place op surprise a += 1 modifies the original array Use a = a + 1 for a new array
dtype overflow np.array([200], dtype=np.int8) * 2 → overflow Use larger dtype
Slow loops for i in range(len(a)): a[i] *= 2 a *= 2
Legacy random API np.random.seed(42); np.random.rand() rng = np.random.default_rng(42); rng.random()
Comparing arrays a == b returns array, not bool Use np.array_equal(a, b) for scalar bool

NumPy vs Related Libraries

Library Relationship to NumPy Use When
pandas Built on NumPy arrays Tabular/labeled data, CSV files
SciPy Extends NumPy with science algorithms Optimization, stats, signal processing
scikit-learn Uses NumPy arrays as input/output Machine learning models
Matplotlib Accepts NumPy arrays for plotting Data visualization
TensorFlow/PyTorch Similar API, GPU acceleration Deep learning, autodiff
CuPy Drop-in NumPy replacement on GPU NumPy code on GPU (CUDA)
JAX NumPy-compatible + autodiff + JIT ML research, differentiable programming
Numba JIT-compiles Python+NumPy to LLVM Speed up NumPy-heavy loops
Dask Distributed NumPy for large data Arrays that don't fit in RAM

Learning Path

Stage Topics Timeline
1 — Basics Arrays, indexing, slicing, dtypes, shape Week 1
2 — Math Element-wise ops, ufuncs, aggregations Week 1–2
3 — Intermediate Broadcasting, reshape, stacking Week 2–3
4 — Advanced Linear algebra, FFT, structured arrays Week 3–4
5 — Applied Build with pandas, sklearn, Matplotlib Month 2+
6 — Performance Numba, memory layout, profiling Month 3+

Quick Reference

import numpy as np

# Creation
np.array([1,2,3])              # from list
np.zeros((m, n))               # m×n zeros
np.ones((m, n))                # m×n ones
np.eye(n)                      # n×n identity
np.arange(start, stop, step)   # range
np.linspace(start, stop, num)  # evenly spaced
np.random.default_rng(seed).random((m,n))  # random

# Info
a.shape    a.ndim    a.size    a.dtype

# Reshape
a.reshape(m, n)   a.flatten()   a.T   a[np.newaxis,:]

# Index
a[i, j]   a[i:j]   a[a > 0]   a[[0,2,4]]

# Math
a + b   a * b   a @ b   np.dot(a,b)
np.sum(a, axis=0)   np.mean(a)   np.std(a)
np.sort(a)   np.argsort(a)   np.where(cond, x, y)

# Linalg
np.linalg.det(A)   np.linalg.inv(A)
np.linalg.solve(A, b)   np.linalg.eig(A)

FAQ

Q: Is NumPy the same as MATLAB?
Similar concepts (matrices, vectorized ops, broadcasting), but NumPy is open source, Python-native, and widely used in industry. MATLAB is commercial and common in academia/engineering.

Q: When should I use NumPy vs pandas?
NumPy for homogeneous numerical data and matrix math. Pandas for tabular data with labeled columns, mixed types, and CSV/database workflows. Pandas is built on NumPy.

Q: Does NumPy use the GPU?
By default, no — NumPy uses CPU only. For GPU, use CuPy (drop-in NumPy replacement on CUDA), PyTorch tensors, or JAX arrays.

Q: What is a NumPy view vs copy?
A view shares memory with the original array — modifying the view modifies the original. Slices create views. Use .copy() to create an independent copy. a.base is None returns True if a owns its data.

Q: Why does NumPy use C order vs Fortran order?
C order (row-major, default) stores rows contiguously. Fortran order (column-major) stores columns. Operations along rows are faster in C order. Specify with order='C' or order='F'. Most NumPy operations default to C order.

Q: How do I check if two arrays are equal?
Use np.array_equal(a, b) for exact equality (returns a single bool). Use np.allclose(a, b, rtol=1e-5) for floating-point comparison with tolerance. The == operator returns an array of booleans.

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools