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 — Pandas: Series and DataFrame

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

Pandas — Python Data Analysis Library

Pandas (from "panel data") provides fast, flexible labelled data structures built on NumPy. It is the single most-used tool in a data analyst's day.

The Two Core Data Structures

StructureDimensionsDescription
Series1-DA labelled array — like a single column with an index
DataFrame2-DA labelled table — rows and named columns, like a spreadsheet or SQL table

Series

import pandas as pd
import numpy as np

# From a list — automatic integer index
s1 = pd.Series([10, 20, 30, 40])
print(s1)
# 0    10
# 1    20
# 2    30
# 3    40
# dtype: int64

# With a custom index
s2 = pd.Series([88, 76, 91, 54], index=["Riya", "Zoya", "Kabir", "Aarav"], name="marks")
print(s2)
print(s2["Kabir"])            # 91     label-based access
print(s2.iloc[2])             # 91     position-based access
print(s2[s2 > 70])            # boolean filter

# From a dictionary — keys become the index
s3 = pd.Series({"Delhi": 32000, "Noida": 28000, "Gurgaon": 35000})

print(s2.values)              # underlying NumPy array
print(s2.index)               # the index object
print(s2.mean(), s2.max(), s2.std().round(2))

DataFrame

# From a dictionary of lists — the most common construction
df = pd.DataFrame({
    "name":    ["Riya", "Zoya", "Kabir", "Aarav", "Meera"],
    "age":     [19, 21, 20, 22, 20],
    "marks":   [88, 76, 91, 54, 79],
    "city":    ["Delhi", "Noida", "Delhi", "Gurgaon", "Noida"],
    "attendance": [92, 78, 95, 61, 85],
})
print(df)

# From a list of dictionaries (e.g. an API response)
records = [{"name": "Riya", "marks": 88}, {"name": "Zoya", "marks": 76}]
print(pd.DataFrame(records))

# From a NumPy array
print(pd.DataFrame(np.random.randn(3, 3), columns=["A", "B", "C"]))

Inspecting a DataFrame

print(df.head(3))          # first 3 rows
print(df.tail(2))          # last 2 rows
print(df.shape)            # (5, 5)
print(df.columns.tolist()) # column names
print(df.index)            # row index
print(df.dtypes)           # data type of each column
print(df.info())           # dtypes + non-null counts + memory
print(df.describe())       # summary statistics for numeric columns
print(df.describe(include="all"))    # including categorical columns
print(df.nunique())        # distinct values per column
print(df.memory_usage(deep=True))

Selecting Data

# COLUMNS
print(df["marks"])                    # a Series
print(df[["name", "marks"]])          # a DataFrame (note the double brackets)
print(df.marks)                       # attribute access (only for valid identifiers)

# ROWS — .loc (label-based) and .iloc (position-based)
print(df.loc[0])                      # row with index label 0
print(df.iloc[0])                     # first row by position
print(df.loc[1:3])                    # labels 1,2,3 — INCLUSIVE of the end
print(df.iloc[1:3])                   # positions 1,2 — EXCLUSIVE of the end

# BOTH rows and columns
print(df.loc[0:2, ["name", "marks"]])
print(df.iloc[0:2, 1:3])
print(df.at[0, "marks"])              # fast scalar access by label
print(df.iat[0, 2])                   # fast scalar access by position
.loc vs .iloc is the most common Pandas exam question. .loc uses labels and its slice end is inclusive; .iloc uses integer positions and its slice end is exclusive (like normal Python slicing).

Filtering

print(df[df["marks"] > 80])
print(df[(df["marks"] > 75) & (df["age"] < 21)])       # AND — use &, not 'and'
print(df[(df["city"] == "Delhi") | (df["marks"] > 85)]) # OR — use |
print(df[~(df["city"] == "Delhi")])                     # NOT — use ~

print(df[df["city"].isin(["Delhi", "Noida"])])
print(df[df["name"].str.startswith("R")])
print(df[df["marks"].between(70, 90)])

# query() — SQL-like readable syntax
print(df.query("marks > 75 and age < 21"))
print(df.query("city in ['Delhi', 'Noida']"))

Adding, Modifying and Removing

df2 = df.copy()

# New columns
df2["percentage"] = df2["marks"]                      # simple copy
df2["grade"] = pd.cut(df2["marks"], bins=[0, 60, 75, 85, 100],
                      labels=["D", "C", "B", "A"])
df2["passed"] = df2["marks"] >= 60
df2["score_index"] = (df2["marks"] * 0.7 + df2["attendance"] * 0.3).round(2)

# assign() — chainable
df2 = df2.assign(age_group=lambda d: np.where(d["age"] >= 21, "Senior", "Junior"))

# Modify values
df2.loc[df2["marks"] < 60, "remark"] = "Needs improvement"
df2["city"] = df2["city"].replace({"Gurgaon": "Gurugram"})

# Remove
df2 = df2.drop(columns=["percentage"])
df2 = df2.drop(index=[4])
print(df2)

# Rename
df2 = df2.rename(columns={"marks": "total_marks", "attendance": "attendance_pct"})
print(df2.columns.tolist())

Sorting

print(df.sort_values("marks", ascending=False))
print(df.sort_values(["city", "marks"], ascending=[True, False]))
print(df.sort_index())
print(df.nlargest(3, "marks"))        # top 3 — faster than sort + head
print(df.nsmallest(2, "marks"))

Handling Missing Data

messy = pd.DataFrame({
    "name":  ["Riya", "Zoya", "Kabir", "Aarav"],
    "marks": [88, np.nan, 91, 54],
    "city":  ["Delhi", "Noida", None, "Delhi"],
})

print(messy.isnull())                  # element-wise boolean mask
print(messy.isnull().sum())            # count per column
print(messy.notnull().sum())

print(messy.dropna())                  # drop rows with ANY missing value
print(messy.dropna(subset=["marks"]))  # only if 'marks' is missing
print(messy.dropna(axis=1))            # drop COLUMNS with missing values
print(messy.dropna(thresh=2))          # keep rows with at least 2 non-null values

print(messy.fillna({"marks": messy["marks"].median(), "city": "Unknown"}))
print(messy.ffill())                   # forward fill
print(messy["marks"].interpolate())    # linear interpolation

GroupBy — Split-Apply-Combine

sales = pd.DataFrame({
    "region":  ["North","South","North","East","South","East","North","South"],
    "product": ["A","A","B","B","B","A","A","B"],
    "revenue": [1200, 950, 1400, 800, 1100, 700, 1350, 1050],
    "units":   [30, 25, 35, 20, 28, 18, 34, 27],
})

print(sales.groupby("region")["revenue"].sum())
print(sales.groupby("region")["revenue"].agg(["count", "sum", "mean", "max"]).round(2))

# Multiple keys
print(sales.groupby(["region", "product"])["revenue"].sum())

# Different aggregations per column
print(sales.groupby("region").agg(
    total_revenue=("revenue", "sum"),
    avg_revenue=("revenue", "mean"),
    total_units=("units", "sum"),
    n_orders=("revenue", "count"),
).round(2))

# transform() — result has the SAME shape as the input (great for feature engineering)
sales["region_avg"] = sales.groupby("region")["revenue"].transform("mean").round(2)
sales["pct_of_region"] = (sales["revenue"] / sales["region_avg"] * 100).round(1)
print(sales)

# filter() — keep only groups meeting a condition
print(sales.groupby("region").filter(lambda g: g["revenue"].sum() > 2500))

Pivot Tables and Cross-Tabs

print(sales.pivot_table(values="revenue", index="region", columns="product",
                        aggfunc="sum", margins=True, fill_value=0))
#          A       B     All
# region
# East     700     800   1500
# North   2550    1400   3950
# South    950    2150   3100
# All     4200    4350   8550

print(pd.crosstab(sales["region"], sales["product"]))
print(pd.crosstab(sales["region"], sales["product"], normalize="index").round(3))

# melt() — wide to long format (the inverse of pivot)
wide = sales.pivot_table(values="revenue", index="region", columns="product", aggfunc="sum")
print(wide.reset_index().melt(id_vars="region", var_name="product", value_name="revenue"))

Merging and Joining

students = pd.DataFrame({"roll": [1, 2, 3, 4], "name": ["Riya","Zoya","Kabir","Aarav"]})
scores   = pd.DataFrame({"roll": [1, 2, 3, 5], "marks": [88, 76, 91, 65]})

print(pd.merge(students, scores, on="roll", how="inner"))   # only matching rolls
print(pd.merge(students, scores, on="roll", how="left"))    # all students
print(pd.merge(students, scores, on="roll", how="right"))   # all scores
print(pd.merge(students, scores, on="roll", how="outer"))   # everything

# concat — stacking
print(pd.concat([students.head(2), students.tail(2)]))                 # rows
print(pd.concat([students, scores.drop(columns="roll")], axis=1))      # columns
Join typeKeeps
innerOnly keys present in both DataFrames
leftAll rows from the left; NaN where the right has no match
rightAll rows from the right
outerAll rows from both; NaN wherever either side is missing

Applying Functions

df3 = df.copy()

df3["marks_scaled"] = df3["marks"].apply(lambda x: x / 100)          # element-wise
df3["name_upper"] = df3["name"].str.upper()                          # vectorised string
df3["initials"] = df3["name"].str[0]

# Row-wise apply (axis=1) — flexible but slow; prefer vectorised operations
df3["summary"] = df3.apply(lambda r: f"{r['name']} ({r['city']}): {r['marks']}", axis=1)

# map() on a Series with a dictionary
df3["city_tier"] = df3["city"].map({"Delhi": 1, "Noida": 2, "Gurgaon": 2})

print(df3[["name", "marks_scaled", "initials", "city_tier", "summary"]])

Working with Dates

ts = pd.DataFrame({
    "date": pd.date_range("2026-01-01", periods=90, freq="D"),
    "sales": np.random.randint(800, 2200, 90),
})

ts["year"] = ts["date"].dt.year
ts["month"] = ts["date"].dt.month
ts["weekday"] = ts["date"].dt.day_name()
ts["is_weekend"] = ts["date"].dt.dayofweek >= 5
ts["quarter"] = ts["date"].dt.quarter

print(ts.groupby("weekday")["sales"].mean().round(0))
print(ts.set_index("date").resample("ME")["sales"].sum())      # monthly totals
print(ts.set_index("date")["sales"].rolling(7).mean().tail())  # 7-day moving average

String Operations

text_df = pd.DataFrame({"email": ["Riya@Gmail.com ", " zoya@YAHOO.in", "kabir@outlook.com"]})

text_df["clean"] = text_df["email"].str.strip().str.lower()
text_df["username"] = text_df["clean"].str.split("@").str[0]
text_df["domain"] = text_df["clean"].str.split("@").str[1]
text_df["provider"] = text_df["domain"].str.extract(r"^([a-z]+)\.")
text_df["is_gmail"] = text_df["clean"].str.contains("gmail")
print(text_df)

The next lesson covers getting data into and out of Pandas — the import/export step of every real project.