Skip to main content
Pandas advanced Lesson 10 of 11

Advanced Pandas Operations

Master method chaining, window functions, MultiIndex, pipe(), and production-ready DataFrame patterns.

Real-World Scenario

An analyst builds a monthly sales report from raw transaction data. Instead of 20 lines of assignments and intermediate DataFrames, they write a 10-step method chain that reads like English: load → validate → clean → enrich → aggregate → format. The next analyst can follow the logic without a word of documentation.

Method Chaining

import pandas as pd
import numpy as np

rng = np.random.default_rng(42)
n   = 10_000

raw_df = pd.DataFrame({
    "transaction_id": range(n),
    "customer_id":    rng.integers(1, 1000, n),
    "product":        rng.choice(["laptop", "phone", "tablet", "monitor", "keyboard"], n),
    "category":       rng.choice(["Electronics", "electronics", "ELECTRONICS"], n),
    "amount":         rng.lognormal(4, 1, n).round(2),
    "quantity":       rng.integers(1, 5, n),
    "date":           pd.date_range("2024-01-01", periods=n, freq="h"),
    "region":         rng.choice(["NA", "EU", "APAC", None], n, p=[0.4, 0.3, 0.25, 0.05]),
})

# Readable method chain — each step is a transformation
monthly_report = (
    raw_df
    # 1. Validate: remove invalid amounts
    .query("amount > 0 and quantity > 0")
    # 2. Clean: standardize text
    .assign(category=lambda df: df["category"].str.upper())
    # 3. Fill missing values
    .assign(region=lambda df: df["region"].fillna("Unknown"))
    # 4. Feature engineering
    .assign(
        revenue=lambda df: df["amount"] * df["quantity"],
        month=lambda df: df["date"].dt.to_period("M"),
    )
    # 5. Aggregate: monthly revenue per region
    .groupby(["month", "region"])
    .agg(
        total_revenue=("revenue", "sum"),
        n_transactions=("transaction_id", "count"),
        avg_order=("revenue", "mean"),
        unique_customers=("customer_id", "nunique"),
    )
    .round(2)
    # 6. Compute derived metrics
    .assign(revenue_per_customer=lambda df: df["total_revenue"] / df["unique_customers"])
    # 7. Sort
    .sort_values(["month", "total_revenue"], ascending=[True, False])
    .reset_index()
)

print(monthly_report.head(10).to_string(index=False))

pipe() for Custom Functions

import pandas as pd
import numpy as np

def remove_outliers(df: pd.DataFrame, col: str, n_std: float = 3.0) -> pd.DataFrame:
    """Remove rows where col is more than n_std standard deviations from the mean."""
    mean, std = df[col].mean(), df[col].std()
    return df[(df[col] - mean).abs() <= n_std * std]

def add_percentile_rank(df: pd.DataFrame, col: str) -> pd.DataFrame:
    """Add a percentile rank column."""
    return df.assign(**{f"{col}_pct": df[col].rank(pct=True)})

def log_shape(df: pd.DataFrame, label: str = "") -> pd.DataFrame:
    """Log DataFrame shape (useful for debugging pipelines)."""
    print(f"[{label}] shape: {df.shape}")
    return df

# Use pipe() to insert custom functions into a method chain
rng = np.random.default_rng(42)
df  = pd.DataFrame({
    "amount": np.concatenate([rng.normal(100, 20, 980), rng.normal(1000, 100, 20)]),
    "category": rng.choice(["A", "B", "C"], 1000),
})

result = (
    df
    .pipe(log_shape, "raw")
    .pipe(remove_outliers, "amount", n_std=2.5)
    .pipe(log_shape, "after outlier removal")
    .pipe(add_percentile_rank, "amount")
    .groupby("category")
    .agg(
        mean_amount=("amount", "mean"),
        mean_pct=("amount_pct", "mean"),
        count=("amount", "count"),
    )
    .round(3)
)
print(result)

MultiIndex

import pandas as pd
import numpy as np

rng = np.random.default_rng(42)

# Create hierarchical index
years      = [2022, 2023, 2024]
quarters   = ["Q1", "Q2", "Q3", "Q4"]
products   = ["laptop", "phone", "tablet"]

idx = pd.MultiIndex.from_product([years, quarters, products],
                                   names=["year", "quarter", "product"])
df  = pd.DataFrame({
    "revenue": rng.integers(50_000, 500_000, len(idx)),
    "units":   rng.integers(100, 5000, len(idx)),
}, index=idx)

# Access by level — much more powerful than boolean indexing
print("All 2024 data:")
print(df.loc[2024].head())

print("\n2023 Q2 laptop:")
print(df.loc[(2023, "Q2", "laptop")])

# Cross-section: all products for a given year/quarter
print("\nAll products in 2024 Q4:")
print(df.xs((2024, "Q4"), level=["year", "quarter"]))

# Aggregate at a level
annual_revenue = df["revenue"].groupby(level="year").sum()
print(f"\nAnnual revenue:\n{annual_revenue}")

# Stack / unstack: pivot MultiIndex axes
pivot = df["revenue"].unstack(level="product")  # products become columns
print(f"\nPivoted (products as columns):\n{pivot.head()}")

# Reset a MultiIndex to flat columns
flat_df = df.reset_index()
print(f"\nFlat DataFrame:\n{flat_df.head()}")

Window Functions

import pandas as pd
import numpy as np

rng = np.random.default_rng(42)
dates = pd.date_range("2023-01-01", periods=365, freq="D")
df = pd.DataFrame({
    "date":    dates,
    "sales":   rng.lognormal(6, 0.5, 365).round(2),
    "visitors": rng.integers(100, 1000, 365),
}).set_index("date")

# Rolling: fixed-size window
df["sales_7d_avg"]  = df["sales"].rolling(7,  min_periods=1).mean()
df["sales_30d_avg"] = df["sales"].rolling(30, min_periods=1).mean()
df["sales_7d_std"]  = df["sales"].rolling(7,  min_periods=1).std()

# Expanding: cumulative (grows from start)
df["cumulative_sales"]   = df["sales"].expanding().sum()
df["running_avg_sales"]  = df["sales"].expanding().mean()

# Exponential weighted: more weight on recent values
df["ewm_sales"] = df["sales"].ewm(span=7, adjust=False).mean()

# Detect anomalies: z-score using 30-day rolling stats
df["zscore"] = (
    (df["sales"] - df["sales_30d_avg"]) /
    df["sales"].rolling(30, min_periods=1).std()
)
anomalies = df[df["zscore"].abs() > 2.5]
print(f"Anomaly days detected: {len(anomalies)}")

# Rank within rolling window
df["sales_rank_30d"] = df["sales"].rolling(30, min_periods=1).rank(pct=True)

# Percentage change
df["sales_wow"] = df["sales"].pct_change(7).round(4)   # week-over-week
df["sales_mom"] = df["sales"].pct_change(30).round(4)  # month-over-month

print(df[["sales", "sales_7d_avg", "sales_30d_avg", "zscore", "sales_wow"]].tail(10).round(3))

eval() and query() for Performance

import pandas as pd
import numpy as np
import timeit

rng = np.random.default_rng(42)
n   = 1_000_000

df = pd.DataFrame({
    "a": rng.uniform(0, 100, n),
    "b": rng.uniform(0, 100, n),
    "c": rng.uniform(0, 100, n),
    "d": rng.integers(0, 5, n),
})

# eval(): compute new column expression without temp arrays
# ~2x faster than df["a"] + df["b"] on large DataFrames (avoids intermediate copies)
df_eval = df.eval("result = a * b + c ** 2 - a / (b + 1)")
t_eval  = timeit.timeit(lambda: df.eval("result = a * b + c ** 2"), number=10) / 10

df_py   = df.copy()
df_py["result"] = df["a"] * df["b"] + df["c"] ** 2
t_py    = timeit.timeit(
    lambda: df["a"] * df["b"] + df["c"] ** 2, number=10
) / 10

print(f"Python expression: {t_py*1000:.1f}ms")
print(f"eval():            {t_eval*1000:.1f}ms")

# query(): readable boolean filtering with variable injection
threshold = 50.0
result = df.query("a > @threshold and b < 30 and d in [1, 2, 3]")
print(f"\nFiltered rows: {len(result):,} / {len(df):,}")

# Multi-condition with query() vs traditional boolean indexing
t_query = timeit.timeit(
    lambda: df.query("a > 50 and b < 30 and c > 20"), number=20
) / 20
t_bool  = timeit.timeit(
    lambda: df[(df["a"] > 50) & (df["b"] < 30) & (df["c"] > 20)], number=20
) / 20
print(f"Boolean index: {t_bool*1000:.1f}ms")
print(f"query():       {t_query*1000:.1f}ms")

Frequently Asked Questions

What is method chaining and why is it better than intermediate variables?
Method chaining calls pandas operations sequentially on a single expression — df.query(...).assign(...).groupby(...).agg(...). No intermediate variables means no memory wasted on temporary DataFrames, the code reads as a single transformation pipeline, and it's easy to add or remove steps. Use assign() instead of direct column assignment to keep chains clean.
When should I use a MultiIndex?
MultiIndex is useful when your data is naturally hierarchical — country/region, year/month, user/session. It enables powerful xs() lookups, stack/unstack for reshaping, and efficient group operations. The downside is complexity. If you find yourself constantly resetting the index, a flat DataFrame with groupby is often simpler.