LAPORAN UJIAN TENGAH SEMESTER PENGANTAR BIG DATA
Nama: Nabila Anggita Putri
NIM: 52240002 | Kelas: Sains Data 24
Program Studi: S1 - Sains Data
Dosen Pengampu: Mr. Bakti Siregar, M.Sc., CDS.,
Institusi: Institut Teknologi Sains Bandung
Judul Video: UTS Pengantar Big Data – 52240002 – Nabila Anggita Putri
Link Video YouTube:
Klik di sini untuk menonton video presentasi
Pendahuluan
Ujian Tengah Semester (UTS) mata kuliah Pengantar Big Data ini membahas penerapan konsep dan teknologi Big Data dalam proses pengolahan data. Pengerjaan UTS dilakukan melalui studi kasus aplikasi dompet digital BayarYuk, yang melibatkan pengelolaan data merchant, log transaksi, dan data streaming.
Dalam pengerjaannya, dilakukan simulasi penggunaan MongoDB untuk pengelolaan data, pemrosesan data menggunakan Spark RDD, Spark DataFrame dan SQL, serta penerapan Structured Streaming. Selain itu, dilakukan analisis data (EDA) untuk mengidentifikasi pola transaksi dan memperoleh insight berdasarkan data yang telah diolah.
Melalui kegiatan ini, mahasiswa diharapkan mampu memahami dan menerapkan teknologi Big Data dalam mengelola, memproses, serta menganalisis data secara terdistribusi menggunakan Apache Spark.
Bagian A: Pilihan Ganda Beserta Alasan
A1. Data GPS 2 juta pengemudi ojek daring yang
diperbarui setiap 3 detik paling menonjol pada karakteristik …
(a) Variety (b) Velocity (c) Veracity (d) Value
Alasan: Data baru datang terus-menerus dengan frekuensi sangat tinggi (tiap 3 detik dari jutaan perangkat), sehingga yang paling menonjol adalah kecepatan arus datanya, bukan keragaman, kebenaran, ataupun nilainya.
A2. Basis data yang paling tepat untuk menyimpan
sesi login dengan masa berlaku 30 menit adalah …
(a) Neo4j (b) MongoDB (c) Redis (d) HDFS
Alasan: Redis adalah penyimpanan key-value di
memori yang sangat cepat dan mendukung TTL (EXPIRE),
sehingga sesi dapat terhapus otomatis setelah 30 menit.
A3. Pada MongoDB, tahap aggregation
pipeline yang memecah elemen array menjadi dokumen terpisah adalah
…
(a) $match (b) $group (c) $unwind
(d) $project
$unwind
Alasan: $unwind membongkar field array sehingga
setiap elemen menjadi satu dokumen sendiri, sedangkan
$match menyaring, $group mengelompokkan, dan
$project memilih/membentuk field.
A4. Berkas 700 MB diunggah ke HDFS dengan blok 128
MB dan replikasi 3. Jumlah blok dan ruang disk yang terpakai adalah
…
(a) 5 blok, 700 MB (b) 6 blok, 2.100 MB (c) 6 blok, 2.304 MB (d) 18
blok, 2.304 MB
Alasan: 700 / 128 = 5,47 sehingga dibutuhkan 6 blok (5 blok penuh + 1 blok sisa 60 MB). Blok terakhir di HDFS hanya memakai ukuran aslinya (tidak dibulatkan ke 128 MB), jadi ruang disk = 700 MB x 3 replika = 2.100 MB.
A5. Komponen HDFS yang menyimpan metadata lokasi
blok adalah …
(a) DataNode (b) NameNode (c) Secondary NameNode (d) ResourceManager
Alasan: NameNode menyimpan namespace dan peta berkas ke blok beserta lokasi DataNode-nya, sedangkan DataNode hanya menyimpan blok data sebenarnya.
A6. Operasi Spark berikut yang merupakan
aksi adalah …
(a) map (b) filter (c)
reduceByKey (d) count
count
Alasan: count memicu eksekusi dan mengembalikan
nilai ke driver, sedangkan map,
filter, dan reduceByKey adalah transformasi
yang bersifat malas (lazy).
A7. Untuk menjumlahkan nilai per kunci pada pair RDD
besar, operasi yang paling efisien adalah …
(a) groupByKey().mapValues(sum) (b)
reduceByKey(lambda a, b: a + b) (c) collect()
lalu dijumlah di Python (d) sortByKey()
reduceByKey(lambda a, b: a + b)
Alasan: reduceByKey menjumlahkan secara lokal
di tiap partisi sebelum shuffle (map-side combine),
sehingga data yang dikirim lewat jaringan jauh lebih sedikit daripada
groupByKey yang memindahkan seluruh nilai.
A8. Mengapa skema eksplisit lebih disarankan
daripada inferSchema=True pada pipeline
produksi?
(a) Skema eksplisit membuat data terkompresi (b) Lebih cepat, tidak
salah tebak tipe, dan terdokumentasi (c) inferSchema tidak
dapat membaca CSV (d) Skema eksplisit wajib untuk Parquet
Alasan: inferSchema harus memindai data dulu
dan bisa salah menebak tipe, sedangkan skema eksplisit menghindari
pemindaian tambahan, menjamin tipe yang konsisten, dan sekaligus menjadi
dokumentasi struktur data.
A9. Topic Kafka memiliki 4 partisi dan consumer
group memiliki 6 konsumen. Berapa konsumen yang aktif
membaca?
(a) 2 (b) 4 (c) 6 (d) 24
Alasan: Dalam satu consumer group, satu partisi hanya boleh dibaca oleh satu konsumen, sehingga maksimal 4 konsumen aktif dan 2 sisanya menganggur (idle).
A10. Cara yang benar membuat histogram kolom
jarak_km dari 2 miliar baris adalah …
(a) df.toPandas()["jarak_km"].hist() (b)
df.collect() lalu plt.hist (c) Menghitung
jumlah per bin dengan groupBy di Spark, lalu
menggambar hasilnya (d) Mengambil 10 baris pertama
groupBy di Spark, lalu gambar hasilnya
Alasan: Data mentah 2 miliar baris tidak muat di memori driver; agregasi per bin dilakukan terdistribusi di Spark sehingga hanya puluhan baris ringkasan yang dibawa untuk digambar. Opsi (a) dan (b) membawa semua data ke driver, opsi (d) tidak representatif.
Bagian B: Esai Singkat
B1. Jelaskan perbedaan scale-up dan scale-out, dan jelaskan mengapa fase shuffle pada MapReduce/Spark merupakan bagian yang paling mahal. (5 poin)
Jawaban:
Scale-up (skala vertikal) berarti memperkuat satu mesin, misalnya menambah CPU, RAM, atau disk. Caranya sederhana, tetapi ada batas fisik dan harga naik tajam pada spesifikasi tinggi, dan mesin tunggal menjadi titik kegagalan tunggal (single point of failure). Scale-out (skala horizontal) berarti menambah jumlah mesin murah (commodity) yang bekerja paralel dalam klaster. Kapasitasnya bisa tumbuh hampir tanpa batas, biayanya lebih terukur, dan kegagalan satu node dapat ditoleransi lewat replikasi, tetapi aplikasi harus dirancang untuk data dan komputasi terdistribusi. Itulah sebabnya Hadoop dan Spark memakai pendekatan scale-out.
Fase shuffle adalah tahap ketika data dikelompokkan ulang
berdasarkan kunci sehingga data bertukar antar partisi dan antar node
(misalnya pada groupByKey, reduceByKey,
join). Pada tahap ini data harus diserialisasi, ditulis ke
disk lokal (shuffle file), dikirim lewat jaringan, lalu dibaca
dan dideserialisasi lagi, dan semua tugas harus saling menunggu sebelum
stage berikutnya dimulai. Disk dan jaringan jauh lebih lambat
daripada memori dan CPU, sehingga shuffle menjadi bagian paling
mahal. Karena itu kita meminimalkannya, misalnya dengan
reduceByKey (kombinasi lokal sebelum kirim) dan
broadcast join.
B2. Sebuah marketplace membutuhkan: (i) katalog produk dengan atribut berbeda per kategori, (ii) keranjang belanja sementara, (iii) rekomendasi “teman Anda juga membeli”. Pilih basis data NoSQL untuk masing-masing dan jelaskan alasannya. (5 poin)
Jawaban:
Katalog produk: MongoDB (document store). Setiap produk disimpan sebagai dokumen JSON dengan skema fleksibel, sehingga laptop dapat memiliki atribut RAM dan prosesor sedangkan baju memiliki ukuran dan bahan tanpa perlu banyak kolom kosong atau ALTER TABLE. Dokumen juga dapat memuat array (varian, ulasan) dan mudah di-query dengan aggregation pipeline.
Keranjang belanja sementara: Redis (key-value store di memori). Keranjang diakses sangat sering, berbentuk sederhana (kunci = id pengguna, nilai = isi keranjang), butuh latensi milidetik, dan bersifat sementara. Redis menyediakan TTL sehingga keranjang yang terbengkalai terhapus otomatis.
Rekomendasi “teman Anda juga membeli”: Neo4j (graph database). Rekomendasi bergantung pada hubungan antar entitas (pengguna – teman – produk). Pada graf, penelusuran relasi berlapis (traversal) berjalan efisien tanpa join berantai yang mahal pada basis data relasional.
B3. Jelaskan konsep lazy evaluation dan lineage pada Spark, serta manfaat masing-masing. (5 poin)
Jawaban:
Lazy evaluation berarti transformasi seperti
map, filter, atau select tidak
langsung dijalankan; Spark hanya mencatat rencananya dalam bentuk DAG,
dan baru mengeksekusinya ketika ada aksi seperti
count, collect, atau write.
Manfaatnya, Spark dapat mengoptimalkan seluruh rencana sekaligus
(menggabungkan langkah dalam satu stage, predicate
pushdown, memilih kolom yang diperlukan saja) dan tidak membuang
komputasi bagi hasil antara yang tidak pernah dipakai.
Lineage adalah silsilah transformasi yang membentuk suatu
RDD/DataFrame dari data sumbernya (dapat dilihat dengan
toDebugString()). Manfaatnya adalah ketahanan terhadap
kegagalan: bila sebuah partisi hilang karena node mati, Spark cukup
menghitung ulang partisi itu dengan mengikuti lineage tanpa perlu
replikasi data seluruhnya. Lineage juga membantu penelusuran dan
debugging alur transformasi.
B4. Jelaskan perbedaan jaminan pengiriman at-most-once dan at-least-once, dan bagaimana checkpoint pada Spark Structured Streaming membantu mencegah data hilang atau terproses ganda. (5 poin)
Jawaban:
Pada at-most-once, setiap pesan dikirim paling banyak satu kali tanpa percobaan ulang. Data tidak akan terproses ganda, tetapi pesan bisa hilang bila terjadi kegagalan. Pada at-least-once, pengirim terus mencoba ulang sampai ada konfirmasi sehingga data tidak hilang, tetapi pesan bisa terkirim dan terproses lebih dari sekali (duplikat).
Checkpoint pada Spark Structured Streaming menyimpan ke penyimpanan tahan lama (disk/HDFS) informasi offset sumber data yang sudah diproses, status agregasi, dan catatan commit per batch. Setelah aplikasi mati dan dijalankan ulang, Spark membaca checkpoint dan melanjutkan dari offset terakhir yang berhasil di-commit, sehingga data yang belum diproses tidak hilang dan batch yang sudah selesai tidak diproses ulang. Bila sumbernya dapat diputar ulang (Kafka, berkas) dan sink-nya idempoten, kombinasi ini menghasilkan semantik effectively exactly-once.
Bagian C: Praktik Coding – Studi Kasus Dompet Digital “BayarYuk”
BayarYuk adalah aplikasi dompet digital. Tim data menganalisis tiga
sumber data: merchant (200 dokumen di MongoDB),
log transaksi (400.000 baris teks berformat
waktu|id_trx|id_user|id_merchant|nominal|status), dan
landing zone streaming (3 berkas JSON x 500
event). Masalah kualitas data yang ada: nominal -1
(kesalahan sistem), nominal Rp250 juta (nilai ekstrem), setiap baris
ke-5.000 adalah baris rusak, dan sekitar 3% nominal pada event
streaming bernilai null.
Alur pengerjaan: C1 (NoSQL) -> C2 (RDD: parsing, accumulator, reduceByKey) -> C3 (DataFrame: skema, broadcast join, window) -> C4 (Structured Streaming) -> C5 (EDA dan insight).
Persiapan Spark dan Dataset
Tugas C1: Kueri dan Agregasi NoSQL
- Merchant Kuliner di Bandung dengan
promo
"cashback"dan rating \(\ge\) 4,0. (b) Jumlah merchant yang memiliki fieldjam_buka. (c) Aggregation pipeline: jumlah merchant dan rata-rata rating per kategori, urut dari rata-rata rating tertinggi.
# Koleksi merchant dari MongoDB (simulator mongomock)
col = db["merchant"]
# (a) Merchant Kuliner di Bandung, punya promo "cashback", rating >= 4,0.
# Filter pada field array ("promo": "cashback") otomatis berarti "array mengandung elemen tsb".
filter_a = {
"kategori": "Kuliner",
"kota": "Bandung",
"promo": "cashback",
"rating": {"$gte": 4.0}
}
hasil_a = list(col.find(filter_a, {"_id": 0, "nama": 1, "rating": 1, "promo": 1}))
tabel_merchant = pd.DataFrame(hasil_a).rename(columns={
"nama": "Nama Merchant",
"rating": "Rating",
"promo": "Promo"
})
tampilkan_tabel(
tabel_merchant,
f"C1(a) Merchant yang sesuai kriteria ({len(hasil_a)} merchant)"
)
C1(a) Merchant yang sesuai kriteria (1 merchant)
================================================
Nama Merchant Rating Promo
Merchant 75 4.1 [cashback]
# (b) Jumlah merchant yang memiliki field jam_buka (skema fleksibel).
jml_jam_buka = col.count_documents({"jam_buka": {"$exists": True}})
print("\nC1(b) Merchant dengan field jam_buka:", jml_jam_buka, "dari", col.count_documents({}))
C1(b) Merchant dengan field jam_buka: 61 dari 200
# (c) Aggregation pipeline: jumlah merchant & rata-rata rating per kategori, urut rating tertinggi.
pipeline = [
{"$group": {"_id": "$kategori",
"jumlah_merchant": {"$sum": 1},
"rata_rating": {"$avg": "$rating"}}},
{"$sort": {"rata_rating": -1}}
]
hasil_c = pd.DataFrame(list(col.aggregate(pipeline))).rename(columns={"_id": "kategori"})
hasil_c["rata_rating"] = hasil_c["rata_rating"].round(3)
tampilkan_tabel(hasil_c, "C1(c) Jumlah merchant dan rata-rata rating per kategori")
C1(c) Jumlah merchant dan rata-rata rating per kategori
=======================================================
jumlah_merchant rata_rating kategori
28 4.146 Kuliner
42 4.117 Kesehatan
48 4.098 Transportasi
44 3.964 Hiburan
38 3.900 Retail
Tafsiran:
- Filter
"promo": "cashback"pada field array berarti “array promo mengandung elemen cashback”; kombinasi dengan kategori, kota, dan$gtepada rating menghasilkan 1 merchant (Merchant 75, rating 4,1).
- Filter
- Hanya sebagian merchant memiliki
jam_buka(61 dari 200, sekitar 30%), contoh skema fleksibel MongoDB: dokumen boleh memiliki field berbeda tanpa migrasi skema.$existsmemeriksa keberadaan field tersebut.
- Hanya sebagian merchant memiliki
- Pipeline
$group(hitung dan rata-rata per kategori) lalu$sortmenurun menunjukkan Kuliner memiliki rata-rata rating tertinggi (4,146), dan Retail terendah (3,900). Selisih antar kategori kecil.
- Pipeline
Tugas C2: Pemrosesan Log dengan Spark RDD
- Baca log sebagai RDD, urai menjadi tuple, buang baris rusak, dan
hitung baris rusak dengan accumulator. (b) Hitung transaksi per
status dengan
reduceByKey. (c) Hitung tingkat kegagalan perid_merchantdan tampilkan 5 tertinggi (minimal 1.500 transaksi).
# C2(a) - Membaca dan parsing log transaksi menggunakan Spark RDD
# Membuat accumulator untuk menghitung baris rusak
baris_rusak = sc.accumulator(0)
# Membaca file log menggunakan Spark
log_rdd = sc.textFile(jalur_log)
# Fungsi parsing setiap baris log
def parse_log(baris):
global baris_rusak
try:
kolom = baris.split("|")
if len(kolom) != 6:
baris_rusak.add(1)
return None
waktu, id_trx, id_user, id_merchant, nominal, status = kolom
return (
waktu,
id_trx,
id_user,
id_merchant,
int(nominal),
status
)
except (ValueError, TypeError):
baris_rusak.add(1)
return None
# Parsing dan menyimpan hasil agar tidak dihitung berulang
parsed_rdd = log_rdd.map(parse_log).persist()
# Memicu proses Spark
total_baris = parsed_rdd.count()
# Mengambil transaksi valid
valid_rdd = parsed_rdd.filter(lambda x: x is not None)
print("C2(a) - Hasil parsing log transaksi:")C2(a) - Hasil parsing log transaksi:
Total baris: 400000
Baris valid: 399920
Baris rusak: 80
# C2(b) - Menghitung transaksi berdasarkan status
jumlah_status = (
valid_rdd
.map(lambda x: (x[5], 1))
.reduceByKey(lambda a, b: a + b)
.sortByKey()
)
tampilkan_tabel(
pd.DataFrame(jumlah_status.collect(), columns=["Status Transaksi", "Jumlah Transaksi"]),
"C2(b) Jumlah transaksi berdasarkan status"
)
C2(b) Jumlah transaksi berdasarkan status
=========================================
Status Transaksi Jumlah Transaksi
GAGAL 20166
PENDING 7884
SUKSES 371870
# C2(c) - Menghitung tingkat kegagalan per merchant
# Format: id_merchant -> (total transaksi, transaksi gagal)
transaksi_merchant = (
valid_rdd
.map(lambda x: (
x[3],
(1, 1 if x[5] == "GAGAL" else 0)
))
.reduceByKey(
lambda a, b: (a[0] + b[0], a[1] + b[1])
)
)
# Menghitung persentase kegagalan
tingkat_gagal = (
transaksi_merchant
.filter(lambda x: x[1][0] >= 1500)
.map(lambda x: (
x[0],
x[1][0],
x[1][1],
x[1][1] / x[1][0] * 100
))
.sortBy(lambda x: x[3], ascending=False)
)
# Mengambil 5 merchant dengan tingkat kegagalan tertinggi
top5_gagal = tingkat_gagal.take(5)
tabel_top5_gagal = pd.DataFrame(top5_gagal, columns=[
"ID Merchant", "Total Transaksi", "Transaksi Gagal", "Tingkat Gagal (%)"
])
tabel_top5_gagal["Tingkat Gagal (%)"] = tabel_top5_gagal["Tingkat Gagal (%)"].round(2)
tampilkan_tabel(tabel_top5_gagal, "C2(c) Lima merchant dengan tingkat kegagalan tertinggi")
C2(c) Lima merchant dengan tingkat kegagalan tertinggi
======================================================
ID Merchant Total Transaksi Transaksi Gagal Tingkat Gagal (%)
M164 2051 130 6.34
M048 2025 125 6.17
M186 1948 120 6.16
M147 1959 120 6.13
M145 1981 119 6.01
Tafsiran:
- Dari 400.000 baris, 80 baris rusak terdeteksi
(400.000 / 5.000 = 80, sesuai desain data) dan dibuang, menyisakan
399.920 transaksi. Accumulator dipakai karena
variabel biasa di dalam fungsi yang berjalan di executor tidak
dapat mengubah variabel di driver; accumulator adalah variabel
tulis-saja dari executor yang nilainya dibaca di
driver. RDD di-cache dan nilainya dibaca setelah aksi
count()agar hitungan tidak terhitung ganda bila stage dijalankan ulang.
- Dari 400.000 baris, 80 baris rusak terdeteksi
(400.000 / 5.000 = 80, sesuai desain data) dan dibuang, menyisakan
399.920 transaksi. Accumulator dipakai karena
variabel biasa di dalam fungsi yang berjalan di executor tidak
dapat mengubah variabel di driver; accumulator adalah variabel
tulis-saja dari executor yang nilainya dibaca di
driver. RDD di-cache dan nilainya dibaca setelah aksi
- Mayoritas transaksi berstatus SUKSES (371.870),
lalu GAGAL (20.166) dan PENDING (7.884). Dipakai
reduceByKey(bukangroupByKey) karena penjumlahan dilakukan lokal di tiap partisi sebelum shuffle, sehingga data yang dikirim antar node jauh lebih sedikit. Hanya 3 baris hasil agregasi yang di-collect().
- Mayoritas transaksi berstatus SUKSES (371.870),
lalu GAGAL (20.166) dan PENDING (7.884). Dipakai
- Tingkat kegagalan merchant teratas berkisar 6,0–6,3%, sedikit di atas rata-rata keseluruhan 5,04%. Filter minimal 1.500 transaksi mencegah merchant dengan transaksi sedikit tampil di puncak hanya karena kebetulan (varians besar pada sampel kecil).
Tugas C3: Analisis dengan Spark DataFrame dan SQL
- RDD menjadi DataFrame dengan skema benar. (b) DataFrame merchant dari MongoDB lalu broadcast join dengan transaksi SUKSES bernominal valid (0 < nominal < 50 juta). (c) Total nominal per kota per kategori. (d) Kategori teratas per kota beserta porsinya (%) dengan window function dan Spark SQL.
# C3(a): definisikan skema eksplisit dan ubah RDD menjadi DataFrame.
from datetime import datetime
from pyspark.sql.types import (
StructType, StructField, StringType,
TimestampType, LongType
)
# Menentukan skema DataFrame
schema_transaksi = StructType([
StructField("waktu", TimestampType(), True),
StructField("id_trx", StringType(), True),
StructField("id_user", StringType(), True),
StructField("id_merchant", StringType(), True),
StructField("nominal", LongType(), True),
StructField("status", StringType(), True)
])
# Mengubah RDD menjadi format sesuai skema
transaksi_row = valid_rdd.map(
lambda x: (
datetime.strptime(x[0], "%Y-%m-%d %H:%M:%S"),
x[1], x[2], x[3], x[4], x[5]
)
)
df_transaksi = spark.createDataFrame(
transaksi_row,
schema=schema_transaksi
)
print("C3(a) - Skema DataFrame transaksi:")C3(a) - Skema DataFrame transaksi:
root
|-- waktu: timestamp (nullable = true)
|-- id_trx: string (nullable = true)
|-- id_user: string (nullable = true)
|-- id_merchant: string (nullable = true)
|-- nominal: long (nullable = true)
|-- status: string (nullable = true)
Jumlah transaksi valid: 399920
# C3(b): buat DataFrame merchant dengan tiga kolom yang diminta.
# Mengambil data merchant dari MongoDB
data_merchant = list(db["merchant"].find(
{},
{"_id": 1, "kategori": 1, "kota": 1}
))
# Membuat DataFrame merchant
merchant_row = [
(m["_id"], m["kategori"], m["kota"])
for m in data_merchant
]
schema_merchant = StructType([
StructField("id_merchant", StringType(), True),
StructField("kategori", StringType(), True),
StructField("kota", StringType(), True)
])
df_merchant = spark.createDataFrame(
merchant_row,
schema=schema_merchant
)
# Broadcast join transaksi sukses dengan merchant
df_join = (
df_transaksi
.filter(F.col("status") == "SUKSES")
.join(
F.broadcast(df_merchant),
on="id_merchant",
how="inner"
)
)
print("C3(b) - Jumlah transaksi sukses setelah join:")C3(b) - Jumlah transaksi sukses setelah join:
371870
# C3(c): total nominal per kota dan kategori.
# Menghitung total nominal per kota dan kategori
df_kota_kategori = (
df_join
.groupBy("kota", "kategori")
.agg(
F.sum("nominal").alias("total_nominal")
)
)
print("C3(c) - Total nominal transaksi per kota dan kategori:")C3(c) - Total nominal transaksi per kota dan kategori:
tabel_kota_kategori = (
df_kota_kategori.orderBy("kota", F.desc("total_nominal"))
.toPandas()
.rename(columns={
"kota": "Kota",
"kategori": "Kategori",
"total_nominal": "Total Nominal (Rp)"
})
)
tabel_kota_kategori["Total Nominal (Rp)"] = (
tabel_kota_kategori["Total Nominal (Rp)"].map(lambda x: f"Rp {x:,.0f}")
)
tampilkan_tabel(tabel_kota_kategori, "C3(c) Total nominal transaksi per kota dan kategori")
C3(c) Total nominal transaksi per kota dan kategori
===================================================
Kota Kategori Total Nominal (Rp)
Bandung Transportasi Rp 2,455,559,993
Bandung Kuliner Rp 2,398,654,294
Bandung Hiburan Rp 2,350,050,893
Bandung Retail Rp 2,016,883,389
Bandung Kesehatan Rp 1,168,446,096
Jakarta Transportasi Rp 2,388,838,696
Jakarta Hiburan Rp 1,888,514,194
Jakarta Kesehatan Rp 1,778,065,696
Jakarta Retail Rp 1,306,442,399
Jakarta Kuliner Rp 214,176,198
Makassar Retail Rp 2,594,025,793
Makassar Kesehatan Rp 1,736,338,295
Makassar Transportasi Rp 1,307,000,893
Makassar Kuliner Rp 1,080,311,296
Makassar Hiburan Rp 1,003,119,794
Medan Transportasi Rp 2,689,104,888
Medan Hiburan Rp 2,005,036,293
Medan Kuliner Rp 1,555,227,198
Medan Retail Rp 1,026,654,595
Medan Kesehatan Rp 1,008,722,594
Surabaya Hiburan Rp 1,868,802,293
Surabaya Kesehatan Rp 1,750,164,693
Surabaya Kuliner Rp 1,677,970,795
Surabaya Transportasi Rp 1,550,425,299
Surabaya Retail Rp 814,119,198
# C3(d): ranking kategori dalam setiap kota dan porsi terhadap total kota.
# Menghitung total transaksi setiap kota
window_kota = Window.partitionBy("kota")
# Mengurutkan kategori berdasarkan nominal tertinggi
window_rank = Window.partitionBy("kota").orderBy(
F.desc("total_nominal")
)
df_top_kategori = (
df_kota_kategori
.withColumn(
"total_kota",
F.sum("total_nominal").over(window_kota)
)
.withColumn(
"share_persen",
F.round(
F.col("total_nominal") /
F.col("total_kota") * 100,
2
)
)
.withColumn(
"peringkat",
F.row_number().over(window_rank)
)
.filter(F.col("peringkat") == 1)
.select(
"kota",
"kategori",
"total_nominal",
"share_persen"
)
.orderBy("kota")
)
tabel_top_kategori = df_top_kategori.toPandas().rename(columns={
"kota": "Kota",
"kategori": "Kategori Teratas",
"total_nominal": "Total Nominal (Rp)",
"share_persen": "Porsi (%)"
})
tabel_top_kategori["Total Nominal (Rp)"] = (
tabel_top_kategori["Total Nominal (Rp)"].map(lambda x: f"Rp {x:,.0f}")
)
tampilkan_tabel(tabel_top_kategori, "C3(d) Kategori transaksi tertinggi per kota")
C3(d) Kategori transaksi tertinggi per kota
===========================================
Kota Kategori Teratas Total Nominal (Rp) Porsi (%)
Bandung Transportasi Rp 2,455,559,993 23.63
Jakarta Transportasi Rp 2,388,838,696 31.53
Makassar Retail Rp 2,594,025,793 33.60
Medan Transportasi Rp 2,689,104,888 32.46
Surabaya Hiburan Rp 1,868,802,293 24.39
Tafsiran:
- Skema eksplisit (
waktu= timestamp,nominal= long) menghindari salah tebak tipe dan menghemat satu pemindaian data dibandinginferSchema.
- Skema eksplisit (
- Dari 399.920 transaksi, 371.658 adalah SUKSES
dengan nominal valid. Tabel merchant hanya 200 baris, sehingga
di-broadcast ke setiap executor (rencana fisik
menampilkan
BroadcastHashJoin), dan tabel transaksi yang besar tidak perlu di-shuffle. Jumlah baris setelah join tetap 371.658 karena setiap transaksi cocok dengan satu merchant.
- Dari 399.920 transaksi, 371.658 adalah SUKSES
dengan nominal valid. Tabel merchant hanya 200 baris, sehingga
di-broadcast ke setiap executor (rencana fisik
menampilkan
- dan (d) Window function
SUM(...) OVER (PARTITION BY kota)menghitung total kota tanpa menghilangkan baris, danROW_NUMBER()menentukan kategori teratas. Hasilnya: Transportasi teratas di Bandung (28,32%), Jakarta (31,85%), dan Medan (28,58%); Retail di Makassar (28,47%); Kesehatan di Surabaya (25,57%). Hasil versi Spark SQL identik dengan versi DataFrame API.
- dan (d) Window function
Tugas C4: Ingestion dengan Structured Streaming
Kueri streaming dari folder landing (JSON,
skema eksplisit) dengan trigger(availableNow=True),
maxFilesPerTrigger=1, checkpoint, dan
foreachBatch.
import shutil
from pyspark.sql.types import StructType, StructField, StringType, LongType
# Skema eksplisit untuk event JSON (tidak memakai inferSchema agar tipe pasti dan stream tidak
# perlu memindai berkas lebih dulu).
skema_event = StructType([
StructField("id_trx", StringType(), True),
StructField("id_merchant", StringType(), True),
StructField("nominal", LongType(), True),
StructField("waktu", StringType(), True),
])
# Lokasi keluaran dan checkpoint (dibersihkan agar chunk dapat dijalankan ulang dari awal).
out_valid = os.path.join(folder, "out_valid")
out_invalid = os.path.join(folder, "out_invalid")
ckpt = os.path.join(folder, "checkpoint")
for p in (out_valid, out_invalid, ckpt):
shutil.rmtree(p, ignore_errors=True)
catatan_batch = [] # catatan per batch (dikumpulkan di driver)
def proses_batch(df_batch, batch_id):
"""Dipanggil Spark untuk setiap micro-batch: pisahkan valid/tidak valid lalu simpan."""
df_batch = df_batch.cache()
valid = df_batch.filter(F.col("nominal").isNotNull())
invalid = df_batch.filter(F.col("nominal").isNull())
n_valid, n_invalid = valid.count(), invalid.count()
# Event valid -> Parquet (mode append); event tidak valid dikarantina di folder terpisah.
valid.write.mode("append").parquet(out_valid)
invalid.write.mode("append").parquet(out_invalid)
catatan_batch.append({"batch_id": batch_id, "valid": n_valid,
"tidak_valid": n_invalid, "total": n_valid + n_invalid})
df_batch.unpersist()
# Kueri streaming: baca folder landing, 1 berkas per batch, jalankan semua berkas yang ada lalu berhenti.
stream_df = (spark.readStream
.schema(skema_event)
.option("maxFilesPerTrigger", 1)
.json(landing))
kueri = (stream_df.writeStream
.foreachBatch(proses_batch)
.option("checkpointLocation", ckpt)
.trigger(availableNow=True)
.start())
kueri.awaitTermination()
# Catatan per batch dan total baris Parquet.
tampilkan_tabel(
pd.DataFrame(catatan_batch).rename(columns={
"batch_id": "Batch ke-",
"valid": "Event Valid",
"tidak_valid": "Event Tidak Valid",
"total": "Total Event"
}),
"C4 Ringkasan pemrosesan setiap batch"
)
C4 Ringkasan pemrosesan setiap batch
====================================
Batch ke- Event Valid Event Tidak Valid Total Event
0 486 14 500
1 479 21 500
2 482 18 500
total_valid = spark.read.parquet(out_valid).count()
total_invalid = spark.read.parquet(out_invalid).count()
print("\nTotal baris Parquet (event valid) :", total_valid)
Total baris Parquet (event valid) : 1447
Total baris karantina (event tidak valid): 53
Total event diproses : 1500
Tafsiran:
maxFilesPerTrigger=1membuat setiap berkas diproses sebagai satu micro-batch, sehingga ada 3 batch (masing-masing 500 event).trigger(availableNow=True)memproses semua data yang tersedia lalu berhenti sendiri.- Di dalam
foreachBatch, event dengannominalnull dipisahkan sebagai tidak valid dan dikarantina, sedangkan event valid ditulis ke Parquet. Total 1.447 event valid tersimpan dan 53 (sekitar 3,5%, sesuai kisaran 3%) tidak valid; 1.447 + 53 = 1.500 sehingga tidak ada data hilang. - Checkpoint mencatat offset dan batch yang sudah selesai; bila kueri dijalankan ulang dengan checkpoint yang sama, berkas yang sudah diproses tidak dibaca lagi sehingga tidak terjadi pemrosesan ganda.
Tugas C5: EDA Skala Besar dan Insight
Menggunakan DataFrame transaksi df_trx dari C3(a).
Perhitungan dilakukan di Spark; hanya hasil agregasi yang dibawa ke
driver.
# ============================================================
# C5 - EDA SKALA BESAR DAN INSIGHT
# ============================================================
from pyspark.sql import functions as F
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
# ============================================================
# 10.2.1 C5(1): LAPORAN KUALITAS DATA
# ============================================================
hasil = df_transaksi.select(
F.count("*").alias("total"),
F.sum(F.when(F.col("nominal") < 0, 1).otherwise(0)).alias("negatif"),
F.sum(F.when(F.col("nominal") >= 50000000, 1).otherwise(0)).alias("ekstrem"),
F.sum(F.when(F.col("status") == "PENDING", 1).otherwise(0)).alias("pending")
).first()
total = hasil["total"]
baris = [
"10.2.1 C5(1): LAPORAN KUALITAS DATA",
"-" * 65,
f"{'Total baris valid':<25} : {total:>12,} (100.000%)",
f"{'Nominal negatif':<25} : {hasil['negatif']:>12,} ({hasil['negatif']/total*100:>6.3f}%)",
f"{'Nominal >= Rp50 juta':<25} : {hasil['ekstrem']:>12,} ({hasil['ekstrem']/total*100:>6.3f}%)",
f"{'Status PENDING':<25} : {hasil['pending']:>12,} ({hasil['pending']/total*100:>6.3f}%)"
]
print("\n".join(baris))10.2.1 C5(1): LAPORAN KUALITAS DATA
-----------------------------------------------------------------
Total baris valid : 399,920 (100.000%)
Nominal negatif : 150 ( 0.038%)
Nominal >= Rp50 juta : 80 ( 0.020%)
Status PENDING : 7,884 ( 1.971%)
# ============================================================
# 10.2.2 C5(2): DISTRIBUSI NOMINAL
# ============================================================
df_bersih = df_transaksi.filter(
(F.col("nominal") > 0) &
(F.col("nominal") < 50000000)
)
persentil = df_bersih.approxQuantile(
"nominal",
[0.05, 0.50, 0.95, 0.99],
0.0001
)
stat_raw = df_transaksi.agg(
F.avg("nominal").alias("rata"),
F.max("nominal").alias("maksimum")
).first()
stat_bersih = df_bersih.agg(
F.avg("nominal").alias("rata")
).first()
total_raw = (
df_transaksi
.filter(F.col("status") == "SUKSES")
.agg(F.sum("nominal").alias("total"))
.first()["total"] or 0
)
total_bersih = (
df_transaksi
.filter(
(F.col("status") == "SUKSES") &
(F.col("nominal") > 0) &
(F.col("nominal") < 50000000)
)
.agg(F.sum("nominal").alias("total"))
.first()["total"] or 0
)
selisih = total_raw - total_bersih
persen_selisih = selisih / total_raw * 100 if total_raw else 0
sukses_ekstrem = df_transaksi.filter(
(F.col("status") == "SUKSES") &
(F.col("nominal") >= 50000000)
).count()
negatif_gagal = df_transaksi.filter(
(F.col("nominal") < 0) &
(F.col("status") == "GAGAL")
).count()
baris = [
"",
"10.2.2 C5(2): DISTRIBUSI NOMINAL DENGAN approxQuantile",
"-" * 65,
"Persentil nominal transaksi (relativeError = 0,0001):",
f"P5 : Rp {persentil[0]:>15,.0f}",
f"P50 : Rp {persentil[1]:>15,.0f}",
f"P95 : Rp {persentil[2]:>15,.0f}",
f"P99 : Rp {persentil[3]:>15,.0f}",
"",
f"Rata-rata nominal mentah : Rp {stat_raw['rata']:>15,.0f}",
f"Rata-rata nominal bersih : Rp {stat_bersih['rata']:>15,.0f}",
f"Nominal maksimum : Rp {stat_raw['maksimum']:>15,.0f}",
f"Total nominal SUKSES mentah: Rp {total_raw:>15,.0f}",
f"Total nominal SUKSES bersih: Rp {total_bersih:>15,.0f}",
f"Selisih akibat nilai ekstrem: Rp {selisih:,.0f} ({persen_selisih:.1f}% dari total mentah)",
f"Transaksi SUKSES >= Rp50 juta: {sukses_ekstrem:,}",
f"Nominal negatif berstatus GAGAL: {negatif_gagal:,}"
]
if persentil[3] != 0:
rasio = stat_raw["maksimum"] / persentil[3]
baris.append(f"Nominal maksimum = {rasio:,.1f} kali P99")
print("\n".join(baris))
10.2.2 C5(2): DISTRIBUSI NOMINAL DENGAN approxQuantile
-----------------------------------------------------------------
Persentil nominal transaksi (relativeError = 0,0001):
P5 : Rp 9,200
P50 : Rp 40,100
P95 : Rp 176,000
P99 : Rp 326,300
Rata-rata nominal mentah : Rp 110,155
Rata-rata nominal bersih : Rp 60,180
Nominal maksimum : Rp 250,000,000
Total nominal SUKSES mentah: Rp 41,632,655,765
Total nominal SUKSES bersih: Rp 22,382,655,900
Selisih akibat nilai ekstrem: Rp 19,249,999,865 (46.2% dari total mentah)
Transaksi SUKSES >= Rp50 juta: 77
Nominal negatif berstatus GAGAL: 9
Nominal maksimum = 766.2 kali P99
# ============================================================
# 10.2.3 C5(3): TRANSAKSI SUKSES BERDASARKAN JAM
# ============================================================
df_jam = (
df_transaksi
.filter(F.col("status") == "SUKSES")
.withColumn("jam", F.hour("waktu"))
.groupBy("jam")
.agg(F.count("*").alias("jumlah_sukses"))
)
df_semua_jam = spark.createDataFrame(
[(i,) for i in range(24)],
["jam"]
)
df_jam_lengkap = (
df_semua_jam
.join(df_jam, "jam", "left")
.fillna(0, subset=["jumlah_sukses"])
.orderBy("jam")
)
data_jam = df_jam_lengkap.toPandas()
data_jam["jumlah_sukses"] = data_jam["jumlah_sukses"].astype(int)
total_sukses = data_jam["jumlah_sukses"].sum()
data_jam["Jam"] = data_jam["jam"].apply(lambda x: f"{x:02d}:00")
data_jam["Persentase (%)"] = (
data_jam["jumlah_sukses"] / total_sukses * 100
).round(2)
# Tabel vertikal agar lebih mudah dibaca
tabel_jam = data_jam[
["Jam", "jumlah_sukses", "Persentase (%)"]
].rename(columns={
"jumlah_sukses": "Jumlah SUKSES"
})
print("\n10.2.3 C5(3): TRANSAKSI SUKSES BERDASARKAN JAM")
10.2.3 C5(3): TRANSAKSI SUKSES BERDASARKAN JAM
-------------------------------------------------------
Jam Jumlah SUKSES Persentase (%)
00:00 15591 4.19
01:00 15456 4.16
02:00 15583 4.19
03:00 15246 4.10
04:00 15394 4.14
05:00 15780 4.24
06:00 15691 4.22
07:00 15340 4.13
08:00 15451 4.15
09:00 15574 4.19
10:00 15701 4.22
11:00 15503 4.17
12:00 15381 4.14
13:00 15478 4.16
14:00 15468 4.16
15:00 15358 4.13
16:00 15494 4.17
17:00 15536 4.18
18:00 15450 4.15
19:00 15461 4.16
20:00 15507 4.17
21:00 15573 4.19
22:00 15543 4.18
23:00 15311 4.12
# Jam puncak dan tersepi
puncak = data_jam.loc[data_jam["jumlah_sukses"].idxmax()]
tersepi = data_jam.loc[data_jam["jumlah_sukses"].idxmin()]
rasio_jam = (
puncak["jumlah_sukses"] / tersepi["jumlah_sukses"]
if tersepi["jumlah_sukses"] > 0 else 0
)
tiga_teratas = data_jam.nlargest(3, "jumlah_sukses")
persen_tiga = (
tiga_teratas["jumlah_sukses"].sum() / total_sukses * 100
if total_sukses else 0
)
daftar_jam = ", ".join(
tiga_teratas["Jam"].tolist()
)
print(
f"\nJam puncak: {puncak['Jam']} "
f"({int(puncak['jumlah_sukses']):,} transaksi)"
f" | Jam tersepi: {tersepi['Jam']} "
f"({int(tersepi['jumlah_sukses']):,} transaksi)"
)
Jam puncak: 05:00 (15,780 transaksi) | Jam tersepi: 03:00 (15,246 transaksi)
Rasio puncak/tersepi: 1.04x
Tiga jam teratas: 05:00, 10:00, 06:00 -> 12.7% dari seluruh transaksi sukses
# ============================================================
# GRAFIK BATANG TRANSAKSI SUKSES BERDASARKAN JAM
# ============================================================
fig, ax = plt.subplots(figsize=(18, 9), dpi=180)
jam = data_jam["Jam"].tolist()
jumlah = data_jam["jumlah_sukses"].tolist()
batang = ax.bar(
jam,
jumlah,
width=0.72
)
# Judul dan label
ax.set_title(
"Distribusi Transaksi SUKSES Berdasarkan Jam",
fontsize=22,
fontweight="bold",
pad=25
)
ax.set_xlabel("Jam Transaksi", fontsize=16, labelpad=15)
ax.set_ylabel("Jumlah Transaksi SUKSES", fontsize=16, labelpad=15)
# Memperbesar label sumbu
ax.tick_params(axis="x", labelsize=12, rotation=45)
ax.tick_params(axis="y", labelsize=12)
# Format angka sumbu Y
ax.yaxis.set_major_formatter(
FuncFormatter(lambda x, pos: f"{x:,.0f}")
)
# Zoom sumbu Y supaya perbedaan tinggi batang terlihat
nilai_min = min(jumlah)
nilai_max = max(jumlah)
margin = (nilai_max - nilai_min) * 0.35
ax.set_ylim(
max(0, nilai_min - margin),
nilai_max + margin
)(15059.1, 15966.9)
# Grid horizontal
ax.grid(
axis="y",
linestyle="--",
alpha=0.4
)
ax.set_axisbelow(True)
# Label pada setiap batang
for b, nilai in zip(batang, jumlah):
ax.annotate(
f"{nilai:,}",
xy=(b.get_x() + b.get_width() / 2, b.get_height()),
xytext=(0, 7),
textcoords="offset points",
ha="center",
va="bottom",
fontsize=9
)
# Keterangan rentang sumbu
ax.text(
0.99,
0.97,
"Catatan: sumbu Y dimulai dari nilai mendekati minimum",
transform=ax.transAxes,
ha="right",
va="top",
fontsize=10
)
fig.tight_layout()
plt.show()Tafsiran:
- Dalam satu
select(satu lintasan data) terhitung 150 nominal negatif (-1), 80 nominal \(\ge\) Rp50 juta (Rp250 juta), dan 7.884 transaksi PENDING dari 399.920 baris valid.
- Dalam satu
- Persentil nominal valid: P5 = Rp9.200, P50 (median) =
Rp40.100, P95 = Rp175.400, P99 = Rp319.800. Distribusinya
miring ke kanan (rata-rata lebih besar daripada median), sehingga median
lebih tepat sebagai ukuran pusat.
approxQuantiledengan galat relatif 0,001 jauh lebih hemat daripada mengurutkan seluruh data.
- Persentil nominal valid: P5 = Rp9.200, P50 (median) =
Rp40.100, P95 = Rp175.400, P99 = Rp319.800. Distribusinya
miring ke kanan (rata-rata lebih besar daripada median), sehingga median
lebih tepat sebagai ukuran pusat.
- Transaksi SUKSES per jam hampir rata (sekitar 15.250–15.800 per jam, selisih sekitar 3%), tidak ada jam puncak yang mencolok. Hanya 24 baris agregat yang dibawa ke driver untuk digambar.
Dua Insight
Insight 1 – Kualitas data: anomali kecil tetapi berbahaya bagi analisis keuangan.
- Temuan: terdapat 80 baris log rusak, 150 transaksi bernominal -1, 80 transaksi bernominal Rp250 juta, 7.884 transaksi PENDING (sekitar 2%), dan 53 dari 1.500 event streaming (sekitar 3,5%) tanpa nominal.
- Bukti: P99 nominal valid hanya Rp319.800, sehingga nilai Rp250 juta kira-kira 780 kali lebih besar daripada P99. Bila 80 transaksi ini ikut dijumlahkan, totalnya Rp20 miliar dan akan mengacaukan total dan rata-rata nominal per kota/kategori.
- Tindakan yang direkomendasikan: pasang validasi di pintu ingestion (tolak nominal <= 0 atau di atas ambang, mis. P99,9), karantina data bermasalah seperti pada C4 sambil memantau rasio tidak valid per hari, selidiki sumber nominal -1 dan baris rusak pada sistem pencatat, dan buat kebijakan menindaklanjuti transaksi PENDING yang menggantung.
Insight 2 – Kegagalan transaksi tersebar merata; prioritaskan audit merchant teratas dan fokus pada kategori Transportasi.
- Temuan: tingkat kegagalan rata-rata 5,04%; lima merchant teratas (M164, M048, M186, M147, M145) berada di 6,0–6,3%. Transaksi SUKSES per jam datar (sekitar 15.250–15.800), sehingga kegagalan tidak terkait jam sibuk. Kategori Transportasi menjadi kategori bernilai terbesar di 3 dari 5 kota (Bandung, Jakarta, Medan) dengan porsi 28–32%.
- Bukti: hasil C2(c), grafik C5(c), dan tabel porsi C3(d).
- Tindakan yang direkomendasikan: audit teknis (integrasi, timeout, saldo/gateway) pada kelima merchant tersebut, serta pasang alert bila tingkat kegagalan merchant melebihi sekitar 6% pada jendela waktu tertentu. Selisih ke rata-rata (sekitar 1 poin persen) relatif kecil, jadi perlu dipantau beberapa periode sebelum menyimpulkan masalah sistematis. Karena beban tidak bergantung jam, kapasitas server dapat dijaga stabil sepanjang hari, sementara promo dan kemitraan dapat diprioritaskan pada Transportasi di kota-kota tersebut.
Bagian D: Presentasi Video YouTube
Video presentasi menjelaskan pengerjaan Bagian C dan insight:
Judul: UTS Pengantar Big Data – 52240002 – Nabila
Anggita Putri
Tautan: https://youtu.be/NZin0JBPVZs
Kesimpulan, Rekomendasi, dan Penutup
- Konsep: karakteristik 5V, pemilihan NoSQL yang sesuai kasus (dokumen, key-value, graf), perhitungan blok HDFS, serta konsep lazy evaluation, lineage, dan checkpoint menjadi dasar memilih desain pengolahan data skala besar.
- Praktik:
reduceByKeylebih efisien daripadagroupByKey; skema eksplisit lebih aman daripadainferSchema; broadcast join menghindari shuffle tabel besar; window function menghitung porsi tanpa kehilangan detail; checkpoint danforeachBatchmembuat ingestion streaming andal dan dapat memisahkan data tidak valid. - Efisiensi:
collect()dantoPandas()tidak dipanggil pada data mentah; hanya hasil agregasi kecil yang dibawa ke driver. - Rekomendasi: terapkan validasi data pada tahap ingestion, pantau tingkat kegagalan per merchant, dan simpan hasil bersih dalam format kolumnar (Parquet) untuk analisis lanjutan.
Kendala dan pembelajaran: kendala utama adalah menyiapkan lingkungan (Java, PySpark, reticulate) dan memahami kapan operasi memicu shuffle. Pembelajaran terpentingnya adalah merancang pengolahan agar data besar tidak ditarik ke satu mesin.
Laporan ini dibuat untuk UTS Pengantar Big Data (SDT-3331), Program Studi Sains Data, Institut Teknologi Sains Bandung.