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

Jawaban: (b) Velocity

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

Jawaban: (c) Redis

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

Jawaban: (c) $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

Jawaban: (b) 6 blok, 2.100 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

Jawaban: (b) NameNode

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

Jawaban: (d) 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()

Jawaban: (b) 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

Jawaban: (b) Lebih cepat, tidak salah tebak tipe, dan terdokumentasi

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

Jawaban: (b) 4

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

Jawaban: (c) Hitung jumlah per bin dengan 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:

  1. 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.

  2. 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.

  3. 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

  1. Merchant Kuliner di Bandung dengan promo "cashback" dan rating \(\ge\) 4,0. (b) Jumlah merchant yang memiliki field jam_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:

    1. Filter "promo": "cashback" pada field array berarti “array promo mengandung elemen cashback”; kombinasi dengan kategori, kota, dan $gte pada rating menghasilkan 1 merchant (Merchant 75, rating 4,1).
    1. Hanya sebagian merchant memiliki jam_buka (61 dari 200, sekitar 30%), contoh skema fleksibel MongoDB: dokumen boleh memiliki field berbeda tanpa migrasi skema. $exists memeriksa keberadaan field tersebut.
    1. Pipeline $group (hitung dan rata-rata per kategori) lalu $sort menurun menunjukkan Kuliner memiliki rata-rata rating tertinggi (4,146), dan Retail terendah (3,900). Selisih antar kategori kecil.

Tugas C2: Pemrosesan Log dengan Spark RDD

  1. 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 per id_merchant dan 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:
print("Total baris:", total_baris)
Total baris: 400000
print("Baris valid:", valid_rdd.count())
Baris valid: 399920
print("Baris rusak:", baris_rusak.value)
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:

    1. 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.
    1. Mayoritas transaksi berstatus SUKSES (371.870), lalu GAGAL (20.166) dan PENDING (7.884). Dipakai reduceByKey (bukan groupByKey) 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().
    1. 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

  1. 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:
df_transaksi.printSchema()
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)
print("Jumlah transaksi valid:", df_transaksi.count())
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:
print(df_join.count())
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:

    1. Skema eksplisit (waktu = timestamp, nominal = long) menghindari salah tebak tipe dan menghemat satu pemindaian data dibanding inferSchema.
    1. 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.
    1. dan (d) Window function SUM(...) OVER (PARTITION BY kota) menghitung total kota tanpa menghilangkan baris, dan ROW_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.

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
print("Total baris karantina (event tidak valid):", total_invalid)
Total baris karantina (event tidak valid): 53
print("Total event diproses                     :", total_valid + total_invalid)
Total event diproses                     : 1500

Tafsiran:

  • maxFilesPerTrigger=1 membuat 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 dengan nominal null 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
print("-" * 55)
-------------------------------------------------------
print(tabel_jam.to_string(index=False))
  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)
print(f"Rasio puncak/tersepi: {rasio_jam:.2f}x")
Rasio puncak/tersepi: 1.04x
print(
    f"Tiga jam teratas: {daftar_jam} "
    f"-> {persen_tiga:.1f}% dari seluruh transaksi sukses"
)
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()

plt.close(fig)

Tafsiran:

    1. 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.
    1. 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. approxQuantile dengan galat relatif 0,001 jauh lebih hemat daripada mengurutkan seluruh data.
    1. 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

  1. 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.
  2. Praktik: reduceByKey lebih efisien daripada groupByKey; skema eksplisit lebih aman daripada inferSchema; broadcast join menghindari shuffle tabel besar; window function menghitung porsi tanpa kehilangan detail; checkpoint dan foreachBatch membuat ingestion streaming andal dan dapat memisahkan data tidak valid.
  3. Efisiensi: collect() dan toPandas() tidak dipanggil pada data mentah; hanya hasil agregasi kecil yang dibawa ke driver.
  4. 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.