Siksha Sarovar

Siksha Sarovar (sikshasarovar.com) is a free educational web application that helps students in India learn programming and prepare for academic and competitive exams. The platform offers structured coding courses (C, C++, Python, Java, HTML, CSS, PHP, Power BI, AI, Machine Learning, Data Science), complete university curriculum notes for BCA/MCA students with previous year question papers, Class 10 and Class 12 CBSE/HBSE school notes, and dedicated preparation material for SSC, UPSC, Banking, Railway and other government exams. Browsing the site is completely free and requires no account. Users may optionally sign in with Google solely to save their learning progress, quiz scores and personal preferences across devices.

Privacy Policy | Terms of Service | Contact Siksha Sarovar | About Siksha Sarovar

v4.0.9 · PWA
Siksha Sarovar logo
Siksha Sarovar
Your Learning Universe

Siksha Sarovar is a free e-learning platform for coding courses, BCA university notes and competitive exam preparation. Optional Google sign-in saves your learning progress across devices.

Initializing knowledge base…
Compiling modules 0%

Unit 4 — Introduction to NumPy

Lesson 36 of 46 in the free Introduction to Data Analytics notes on Siksha Sarovar, written by Rohit Jangra.

NumPy — Numerical Python

NumPy is the foundational package for numerical computing in Python. Every other library in the analytics stack — Pandas, Matplotlib, scikit-learn, SciPy — is built on top of it.

Why NumPy Instead of Python Lists?

FeaturePython listNumPy array (ndarray)
Data typeHeterogeneous (mixed types)Homogeneous (one dtype)
MemoryStores pointers to objects — scatteredContiguous block — compact
SpeedSlow (Python-level loops)10–100× faster (vectorised C loops)
Element-wise operationsRequires a loop or comprehensionBuilt in: a + b, a * 2
Multi-dimensionalNested lists, awkwardNative n-dimensional support
Memory usageHighLow
import numpy as np
import time

size = 1_000_000
py_list1 = list(range(size))
py_list2 = list(range(size))
np_arr1 = np.arange(size)
np_arr2 = np.arange(size)

start = time.time()
result_list = [a + b for a, b in zip(py_list1, py_list2)]
list_time = time.time() - start

start = time.time()
result_arr = np_arr1 + np_arr2          # vectorised — no Python loop at all
np_time = time.time() - start

print(f"Python list: {list_time:.4f}s")
print(f"NumPy array: {np_time:.4f}s")
print(f"NumPy is {list_time / np_time:.0f}x faster")
# NumPy is typically 30-80x faster on this operation

Creating Arrays

import numpy as np

# From a Python list
a = np.array([1, 2, 3, 4, 5])
b = np.array([[1, 2, 3], [4, 5, 6]])              # 2-D array

# Built-in generators
print(np.zeros((2, 3)))                            # 2x3 of zeros
print(np.ones((3, 2)))                             # 3x2 of ones
print(np.full((2, 2), 7))                          # filled with 7
print(np.eye(3))                                   # 3x3 identity matrix
print(np.arange(0, 10, 2))                         # [0 2 4 6 8]
print(np.linspace(0, 1, 5))                        # [0.   0.25 0.5  0.75 1.  ]
print(np.random.rand(2, 3))                        # uniform [0,1)
print(np.random.randn(3))                          # standard normal
print(np.random.randint(1, 100, 5))                # random integers
print(np.random.seed(42))                          # reproducibility

Array Attributes

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

print("Array:\n", arr)
print("Shape:    ", arr.shape)        # (3, 4)  -> 3 rows, 4 columns
print("Dimensions:", arr.ndim)        # 2
print("Size:     ", arr.size)         # 12  (total elements)
print("Data type:", arr.dtype)        # int64
print("Item size:", arr.itemsize, "bytes")    # 8
print("Total bytes:", arr.nbytes)     # 96

Indexing and Slicing

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

print(arr[0, 2])          # 3        element at row 0, column 2
print(arr[1])             # [5 6 7 8]        entire row 1
print(arr[:, 1])          # [2 6 10]         entire column 1
print(arr[0:2, 1:3])      # [[2 3] [6 7]]    sub-matrix
print(arr[-1, -1])        # 12       last element
print(arr[::2])           # every other row

# BOOLEAN INDEXING — the most useful feature for data analysis
print(arr[arr > 6])                    # [ 7  8  9 10 11 12]
print(arr[(arr > 3) & (arr < 9)])      # [4 5 6 7 8]   note & not 'and'

# FANCY INDEXING — select by a list of indices
row_picks = np.array([0, 2])
print(arr[row_picks])                  # rows 0 and 2

# Conditional replacement
arr_copy = arr.copy()
arr_copy[arr_copy > 8] = 0
print(arr_copy)

print(np.where(arr > 6, "High", "Low"))     # element-wise if-else
View vs copy: basic slicing returns a view that shares memory with the original — modifying the slice modifies the original. Use .copy() when you need an independent array. Boolean and fancy indexing always return copies.

Array Operations — Vectorisation

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

print(a + b)          # [11 22 33 44]
print(b - a)          # [ 9 18 27 36]
print(a * b)          # [ 10  40  90 160]   ELEMENT-WISE, not matrix multiply
print(b / a)          # [10. 10. 10. 10.]
print(a ** 2)         # [ 1  4  9 16]
print(a + 100)        # [101 102 103 104]   broadcasting a scalar

# Universal functions (ufuncs)
print(np.sqrt(np.array([4, 9, 16])))        # [2. 3. 4.]
print(np.exp(np.array([0, 1, 2])).round(3)) # [1.    2.718 7.389]
print(np.log(np.array([1, np.e])).round(3)) # [0. 1.]
print(np.abs(np.array([-3, -1, 2])))        # [3 1 2]
print(np.round(np.array([1.234, 5.678]), 1))# [1.2 5.7]

Broadcasting

Broadcasting lets NumPy operate on arrays of different shapes by virtually stretching the smaller one.

Rules (compared from the trailing dimension backwards):
  1. Dimensions are compatible if they are EQUAL, or one of them is 1
  2. A missing dimension is treated as 1

   (3, 4)  +  (4,)      ->  the (4,) is stretched across all 3 rows   ✓
   (3, 1)  +  (1, 4)    ->  both stretched  ->  result (3, 4)          ✓
   (3, 4)  +  (3,)      ->  INCOMPATIBLE (4 vs 3)                      ✗
matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
row = np.array([10, 20, 30])

print(matrix + row)      # row is added to EVERY row
# [[11 22 33]
#  [14 25 36]
#  [17 28 39]]

col = np.array([[100], [200], [300]])
print(matrix + col)      # col is added to every column

# Practical use: standardising each column (z-score, from Unit 1)
data = np.random.randn(100, 3) * [10, 5, 2] + [50, 20, 5]
standardised = (data - data.mean(axis=0)) / data.std(axis=0)
print("Column means after standardising:", standardised.mean(axis=0).round(10))
print("Column SDs after standardising:  ", standardised.std(axis=0).round(10))
# means ≈ 0, SDs = 1

Statistical Functions — Unit 2 in One Line Each

data = np.array([[85, 90, 78], [92, 88, 95], [70, 75, 82], [88, 91, 79]])

print("Sum:      ", data.sum())                    # all elements
print("Col sums: ", data.sum(axis=0))              # down the columns
print("Row sums: ", data.sum(axis=1))              # across the rows

print("Mean:     ", data.mean().round(2))          # 84.42
print("Col means:", data.mean(axis=0).round(2))    # [83.75 86.   83.5 ]
print("Median:   ", np.median(data))               # 86.5
print("Std dev:  ", data.std().round(3))           # population SD
print("Std (n-1):", data.std(ddof=1).round(3))     # sample SD
print("Variance: ", data.var().round(3))
print("Min/Max:  ", data.min(), data.max())
print("Argmax:   ", data.argmax())                 # index of the maximum
print("Percentiles:", np.percentile(data, [25, 50, 75]))
print("Correlation:\n", np.corrcoef(data[:, 0], data[:, 1]).round(4))
print("Cumulative sum:", np.cumsum([1, 2, 3, 4]))  # [ 1  3  6 10]
The axis argument is the most common source of confusion. axis=0 collapses rows (giving one result per column); axis=1 collapses columns (one result per row).

Reshaping and Combining

arr = np.arange(12)
print(arr.reshape(3, 4))          # 3 rows x 4 columns
print(arr.reshape(4, -1))         # -1 means "infer this dimension" -> 4x3
print(arr.reshape(3, 4).T)        # transpose -> 4x3
print(arr.reshape(3, 4).flatten())# back to 1-D (a copy)
print(arr.reshape(3, 4).ravel())  # back to 1-D (a view where possible)

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

print(np.concatenate([a, b], axis=0))    # stack vertically -> 4x2
print(np.concatenate([a, b], axis=1))    # stack horizontally -> 2x4
print(np.vstack([a, b]))                 # same as axis=0
print(np.hstack([a, b]))                 # same as axis=1
print(np.split(np.arange(9), 3))         # split into 3 equal parts

Linear Algebra

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

print(A * B)             # ELEMENT-WISE multiplication
print(A @ B)             # MATRIX multiplication  (same as np.dot(A, B))
print(np.dot(A, B))

print("Determinant:", round(np.linalg.det(A), 4))     # -2.0
print("Inverse:\n", np.linalg.inv(A).round(4))
print("Rank:", np.linalg.matrix_rank(A))

eigenvalues, eigenvectors = np.linalg.eig(A)
print("Eigenvalues:", eigenvalues.round(4))

# Solving a system:  2x + y = 11,  x + 3y = 18
coeffs = np.array([[2, 1], [1, 3]])
constants = np.array([11, 18])
print("Solution [x, y]:", np.linalg.solve(coeffs, constants))    # [3. 5.]

Handling Missing Values

arr = np.array([1.0, 2.0, np.nan, 4.0, np.nan, 6.0])

print("Has NaN:", np.isnan(arr))
print("Count of NaN:", np.isnan(arr).sum())        # 2

print("mean():   ", arr.mean())                    # nan — NaN poisons the result
print("nanmean():", np.nanmean(arr))               # 3.25 — NaN-aware version
print("nansum(): ", np.nansum(arr))                # 13.0
print("nanstd(): ", round(np.nanstd(arr), 4))

# Impute with the mean (Unit 1 cleaning, vectorised)
filled = np.where(np.isnan(arr), np.nanmean(arr), arr)
print("Imputed:", filled)

A Practical Analytics Example

np.random.seed(42)

# Marks of 50 students across 4 subjects
marks = np.random.randint(35, 100, size=(50, 4))
subjects = ["DA", "DBMS", "OS", "NLP"]

student_totals = marks.sum(axis=1)
student_avgs = marks.mean(axis=1)
subject_avgs = marks.mean(axis=0)

print("Subject averages:", dict(zip(subjects, subject_avgs.round(2))))
print("Class average:", round(marks.mean(), 2))
print("Topper index:", student_totals.argmax(), "with", student_totals.max(), "/400")

# Grade assignment — fully vectorised, no loop
grades = np.select(
    [student_avgs >= 85, student_avgs >= 70, student_avgs >= 55, student_avgs >= 40],
    ["A", "B", "C", "D"],
    default="F",
)
unique, counts = np.unique(grades, return_counts=True)
print("Grade distribution:", dict(zip(unique, counts)))

# Students failing in any subject
failing = np.any(marks < 40, axis=1)
print(f"Students failing at least one subject: {failing.sum()}")

# Percentile rank of each student
percentiles = np.array([(student_totals < t).mean() * 100 for t in student_totals])
print("Highest percentile:", percentiles.max().round(1))

NumPy provides the fast numeric engine. Pandas, the next lesson, wraps it in labelled, table-like structures that match how analysts actually think about data.