This exercise implements a complete MapReduce pipeline in pure Python, then demonstrates how it maps to Hadoop Streaming for execution on an HDFS cluster. Rather than requiring a live Hadoop cluster, the exercise runs the full map-shuffle-reduce logic locally against a simulated IPL commentary dataset, producing the identical output that a Hadoop Streaming job would produce on the same data. The local simulation exercises every phase — mapper, combiner, shuffle, reducer — and validates correctness with explicit assertions before the Hadoop Streaming version is shown.
The dataset is IPL ball-by-ball commentary lines — one line per delivery — and the task is a multi-key frequency count: count total deliveries, boundaries, and wickets per bowler across all matches. This goes beyond the standard word count to demonstrate that the MapReduce model handles any grouping-and-aggregation problem, not just word frequency. The exercise ends with a data locality and block placement simulation that illustrates how YARN would schedule mappers across DataNodes for this input.
Step 1 — Data Preparation
Generate the synthetic IPL commentary dataset representing three HDFS input blocks — one per match — and write each block to a separate file simulating how HDFS would split the input. Each line is a tab-separated delivery record. Verify the total line count and distribution across blocks before running the MapReduce pipeline, since uneven block distribution is a common source of skew in real cluster jobs.
# exercise_mapreduce.py — Step 1: Data preparation
import csv
import io
import random
from pathlib import Path
random.seed(2024)
BOWLERS = ["Bumrah", "Shami", "Ashwin", "Jadeja", "Hardik", "Siraj", "Chahal"]
BATTERS = ["Rohit", "Kohli", "Gill", "Dhoni", "Warner", "Maxwell", "Stokes"]
TEAMS = ["Mumbai Indians", "CSK", "RCB", "KKR"]
def generate_match_block(match_id: int, n_deliveries: int = 120) -> list[str]:
"""
Generate n_deliveries commentary lines for one match.
Format: delivery_id\tmatch_id\tbowler\tbatter\truns\tis_wicket
"""
lines = []
for i in range(n_deliveries):
runs = random.choices([0,1,2,4,6], weights=[35,30,15,15,5])[0]
is_wicket = random.random() < 0.03
lines.append(
f"{match_id*1000+i}\t{match_id}\t{random.choice(BOWLERS)}\t"
f"{random.choice(BATTERS)}\t{runs}\t{1 if is_wicket else 0}\n"
)
return lines
# Generate three HDFS blocks (one per match file)
block_dir = Path("hdfs_blocks")
block_dir.mkdir(exist_ok=True)
total_lines = 0
for match_id in [10001, 10002, 10003]:
lines = generate_match_block(match_id, 120)
block_file = block_dir / f"match_{match_id}.tsv"
block_file.write_text("".join(lines))
total_lines += len(lines)
print(f"Block written: {block_file} ({len(lines)} deliveries)")
print(f"\nTotal input lines across all blocks: {total_lines}")
assert total_lines == 360, f"Expected 360 deliveries, got {total_lines}"
print("Step 1 complete — input blocks created")Step 2 — Mapper, Combiner, and Shuffle
Implement the mapper that reads delivery lines and emits `(bowler, stats_dict)` pairs, the combiner that pre-aggregates stats within each block to reduce shuffle volume, and the shuffle function that groups all values by bowler key. Measure the shuffle reduction factor — the ratio of combiner output pairs to mapper output pairs — to quantify the combiner's benefit. Verify that the shuffle groups every bowler's data together with no mixing of keys across groups.
# exercise_mapreduce.py — Step 2: Mapper, combiner, shuffle
from collections import defaultdict
from pathlib import Path
def mapper(line: str) -> tuple[str, dict] | None:
"""
Map one delivery line to (bowler, {deliveries, runs, wickets, boundaries}).
Returns None for malformed lines.
"""
parts = line.strip().split("\t")
if len(parts) < 6:
return None
try:
bowler = parts[2]
runs = int(parts[4])
is_wicket = int(parts[5])
except (ValueError, IndexError):
return None
return (
bowler,
{
"deliveries": 1,
"runs": runs,
"wickets": is_wicket,
"boundaries": 1 if runs >= 4 else 0,
}
)
def combiner(pairs: list[tuple]) -> list[tuple]:
"""Pre-aggregate within one mapper block to reduce shuffle volume."""
combined: dict[str, dict] = defaultdict(
lambda: {"deliveries": 0, "runs": 0, "wickets": 0, "boundaries": 0}
)
for key, val in pairs:
for field in ["deliveries", "runs", "wickets", "boundaries"]:
combined[key][field] += val[field]
return list(combined.items())
def shuffle(all_pairs: list[tuple]) -> dict[str, list]:
"""Sort and group all intermediate pairs by key — simulates YARN shuffle."""
grouped: dict[str, list] = defaultdict(list)
for key, val in sorted(all_pairs, key=lambda x: x[0]): # sort by key
grouped[key].append(val)
return dict(grouped)
# Run Map phase on all three blocks
all_map_output = []
for block_file in sorted(Path("hdfs_blocks").glob("*.tsv")):
lines = block_file.read_text().splitlines()
map_pairs = [mapper(line) for line in lines]
map_pairs = [p for p in map_pairs if p is not None] # filter bad lines
print(f"Block {block_file.name}: {len(map_pairs)} mapper output pairs")
# Apply combiner before adding to shuffle input
combined = combiner(map_pairs)
print(f" After combiner: {len(combined)} pairs "
f"(reduction factor: {len(map_pairs)/len(combined):.1f}x)")
all_map_output.extend(combined)
# Shuffle phase: group by bowler key
grouped = shuffle(all_map_output)
print(f"\nShuffle complete: {len(grouped)} distinct bowler keys")
for bowler, vals in grouped.items():
print(f" {bowler:<10}: {len(vals)} partial records to reduce")
# Verify: every bowler appears exactly once as a key
assert len(grouped) <= len(set(BOWLERS))
print("Step 2 complete — shuffle correct")Step 3 — Reducer and Output Validation
Implement the reducer that aggregates all partial stats for each bowler into final season totals and computes economy rate, then run it across all shuffled groups. Write the results to a TSV output file simulating HDFS output. Validate the results: total deliveries across all bowlers must equal 360 (the full input), and economy values must be between 0 and 36 (theoretical bounds for a bowler who concedes 36 runs per over). Assert both conditions before marking the step complete.
# exercise_mapreduce.py — Step 3: Reducer, output, validation
import csv
from pathlib import Path
def reducer(bowler: str, partial_stats: list[dict]) -> dict:
"""Aggregate all partial stats for one bowler into final season totals."""
total = {"deliveries": 0, "runs": 0, "wickets": 0, "boundaries": 0}
for s in partial_stats:
for field in total:
total[field] += s[field]
overs = total["deliveries"] / 6
economy = round(total["runs"] / overs, 2) if overs > 0 else 0.0
sr = round((total["boundaries"] / total["deliveries"]) * 100, 1) \
if total["deliveries"] > 0 else 0.0
return {
"bowler": bowler,
"deliveries": total["deliveries"],
"overs": round(overs, 1),
"runs": total["runs"],
"wickets": total["wickets"],
"boundaries": total["boundaries"],
"economy": economy,
"boundary_pct":sr,
}
# Run Reduce phase
results = [reducer(bowler, vals) for bowler, vals in grouped.items()]
results.sort(key=lambda x: x["economy"])
# Write to output file (simulates HDFS output part-00000)
output_path = Path("hdfs_output/part-00000.tsv")
output_path.parent.mkdir(exist_ok=True)
fieldnames = ["bowler","deliveries","overs","runs","wickets","economy","boundary_pct"]
with output_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter="\t",
extrasaction="ignore")
writer.writeheader()
writer.writerows(results)
print("MapReduce output:")
print(f" {'Bowler':<10} {'Deliveries':>10} {'Overs':>6} {'Runs':>6} "
f"{'Wickets':>8} {'Economy':>8} {'Boundary%':>10}")
print(" " + "-"*62)
for r in results:
print(f" {r['bowler']:<10} {r['deliveries']:>10} {r['overs']:>6} "
f"{r['runs']:>6} {r['wickets']:>8} {r['economy']:>8} {r['boundary_pct']:>10}")
# Validation assertions
total_deliveries = sum(r["deliveries"] for r in results)
assert total_deliveries == 360, f"Total deliveries {total_deliveries} != 360"
assert all(0 <= r["economy"] <= 36 for r in results), "Economy out of bounds"
assert all(r["deliveries"] > 0 for r in results), "Zero deliveries for a bowler"
assert output_path.exists(), "Output file not written"
print(f"\nValidation passed: {total_deliveries} total deliveries across all bowlers")
print(f"Output written to: {output_path}")
print("\nEquivalent Hadoop Streaming command:")
print("hadoop jar hadoop-streaming.jar \\")
print(" -input hdfs:///ipl/deliveries/*.tsv \\")
print(" -output hdfs:///ipl/bowler_stats/ \\")
print(" -mapper mapper.py \\")
print(" -combiner combiner.py \\")
print(" -reducer reducer.py")