Bab 29 — Riset yang Dapat Direproduksi: Analisis yang Bisa Diulang Siapa Saja

Seri I3 — Statistik dan Pemodelan

CatatanDi bab ini, Anda akan:
  • Memahami beda mengulang (replikasi) dan mereproduksi, dan mengapa keduanya penting
  • Menulis notebook Jupyter yang rapi dan bisa dijalankan dari atas ke bawah
  • Membuat laporan Quarto berparameter yang dirender dari kode, dan mengenal Jupyter Book
  • Mengunci lingkungan dengan uv lock, serta mengenal Snakemake dan DVC untuk alur dan versi data
  • Membagikan data dan kode dengan benar: README, lisensi, CITATION.cff, checksum, dan DOI

Semua skrip bab ini ada di folder kode/bab29/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Bayangkan Anda memberi resep rendang kepada tetangga, tetapi lupa menulis bahwa santannya harus kental dan apinya kecil. Hasilnya berbeda, dan Anda berdua bingung. Analisis data sering bernasib sama: setengah tahun kemudian, Anda sendiri tidak bisa mengulang angka di laporan, karena data sudah berubah, paket sudah diperbarui, atau lupa langkahnya. Riset yang dapat direproduksi artinya orang lain (dan Anda di masa depan) bisa mengambil data dan kode yang sama, menjalankannya, dan mendapat angka yang sama. Itu syarat minimal agar hasil analisis bisa dipercaya.

CatatanINGAT

Dua istilah yang sering tertukar. Reproduksi (reproducibility): data dan kode yang sama menghasilkan angka yang sama. Replikasi (replicability): penelitian baru dengan data baru menghasilkan kesimpulan yang sama. Bab ini tentang yang pertama, dan itulah yang bisa kita jamin lewat teknik di bawah.

%%{init: {"flowchart": {"diagramPadding": 40}}}%%
flowchart TD
  A["Data mentah<br/>(tidak diubah, ada checksum)"] --> B["Skrip pembersihan<br/>dan analisis"]
  L["Lingkungan terkunci<br/>(uv.lock)"] --> B
  P["Parameter<br/>(benih acak, ambang)"] --> B
  B --> C["Laporan Quarto<br/>(angka dan gambar dari kode)"]
  C --> D["Bagikan: kode, data,<br/>lisensi, DOI"]

Alur riset yang dapat direproduksi: tiap kotak ditulis sebagai kode atau berkas, bukan klik manual.

Seluruh bab ini berpegang pada satu prinsip: semua langkah tertulis sebagai kode atau berkas, tidak ada langkah “saya klik sana-sini”. Catatan penting: semua data di bab ini fiktif.

Notebook Jupyter yang rapi

Jupyter notebook menggabungkan teks, kode, dan hasil dalam satu berkas .ipynb. Sangat enak untuk menjelajah, tetapi mudah rusak: sel dijalankan tidak berurutan, variabel “hantu” tersisa di memori, dan hasilnya tidak bisa diulang. Aturan agar notebook rapi:

  1. Urutan dari atas ke bawah. Notebook harus bisa dijalankan dengan “Restart kernel and run all” tanpa error.
  2. Pengaturan di satu sel di awal (benih acak, jalur berkas, ambang), supaya mudah diubah.
  3. Impor di awal, dan jangan bergantung pada sel yang sudah terhapus.
  4. Benih acak tetap bila memakai bilangan acak.
  5. Teks penjelas tentang apa dan mengapa, bukan hanya kode.
  6. Bila isinya sudah matang, pindahkan fungsi ke berkas .py dan impor dari notebook.

Skrip berikut membuat notebook yang mengikuti aturan itu lewat kode (pustaka nbformat), lalu menjalankannya dari atas ke bawah di kernel baru (pustaka nbclient), seperti “Restart and run all”.

01_notebook_rapi.py
# Bab 29 - Notebook Jupyter yang rapi: dibuat lewat kode, dijalankan dari atas ke bawah
import os
from pathlib import Path

import nbformat
from nbclient import NotebookClient

keluar = Path(__file__).parent / "_tmp"
keluar.mkdir(exist_ok=True)

nb = nbformat.v4.new_notebook()
nb.cells = [
    nbformat.v4.new_markdown_cell("# Uji beda tinggi pohon antar-plot\nData tiruan. Benih tetap supaya hasilnya sama."),
    nbformat.v4.new_code_cell("# 1. Pengaturan: semua angka yang bisa diubah ada di sini\nBENIH = 42\nN = 30"),
    nbformat.v4.new_code_cell("# 2. Impor\nimport numpy as np\nfrom scipy import stats"),
    nbformat.v4.new_code_cell(
        "# 3. Data\nrng = np.random.default_rng(BENIH)\n"
        "plot_a = rng.normal(15, 2, N)\nplot_b = rng.normal(17, 2, N)"),
    nbformat.v4.new_code_cell(
        "# 4. Analisis\nhasil = stats.ttest_ind(plot_a, plot_b, equal_var=False)\n"
        "print(f'rata-rata A {plot_a.mean():.2f}, B {plot_b.mean():.2f}, p = {hasil.pvalue:.1e}')"),
]
nb.metadata["kernelspec"] = {"name": "python3", "display_name": "Python 3", "language": "python"}

# "Restart and run all": jalankan semua sel berurutan di kernel baru
# (Peringatan teknis dari kernel di Windows kita alihkan ke "kotak sampah" supaya keluaran bersih.)
simpan_stderr = os.dup(2)
os.dup2(os.open(os.devnull, os.O_WRONLY), 2)
try:
    NotebookClient(nb, timeout=120, kernel_name="python3", resources={"metadata": {"path": str(keluar)}}).execute()
finally:
    os.dup2(simpan_stderr, 2)
nbformat.validate(nb)
nbformat.write(nb, keluar / "uji_beda.ipynb")

print("Jumlah sel:", len(nb.cells), "(kode:", sum(c.cell_type == "code" for c in nb.cells), ")")
print("Nomor eksekusi berurutan:", [c.execution_count for c in nb.cells if c.cell_type == "code"])
for c in nb.cells:
    if c.cell_type == "code" and c.outputs:
        print("Keluaran sel:", c.outputs[0].text.strip())
Jumlah sel: 5 (kode: 4 )
Nomor eksekusi berurutan: [1, 2, 3, 4]
Keluaran sel: rata-rata A 15.03, B 17.23, p = 1.4e-06

Notebook punya 5 sel (4 kode, 1 teks). Nomor eksekusi 1, 2, 3, 4 berurutan: tanda notebook bersih. Bila nomornya melompat (misalnya 1, 5, 2), berarti sel pernah dijalankan acak, dan hasilnya patut dicurigai. Berkas hasilnya disimpan di folder _tmp/.

Quarto: laporan yang dibuat dari kode

Quarto adalah perangkat penerbitan ilmiah: satu berkas teks .qmd (Markdown ditambah sel kode) dirender menjadi HTML, PDF, atau Word. Kodenya dijalankan saat merender, sehingga angka dan gambar di laporan selalu berasal dari kode terbaru, tidak pernah disalin-tempel. Buku yang sedang Anda baca dibuat dengan cara yang sama.

Laporan kecil proyek kita ada di kode/bab29/laporan/laporan.qmd. Bagian pentingnya:

  • Kepala YAML (di antara dua baris ---) berisi judul dan format, misalnya embed-resources: true agar HTML menjadi satu berkas mandiri.
  • Sel bertanda #| tags: [parameters] menyimpan nilai bawaan (benih = 42). Nilainya bisa diganti dari baris perintah tanpa mengubah berkas: quarto render laporan.qmd -P benih:7.
  • Ekspresi sebaris `{python} benih` menaruh nilai variabel langsung di kalimat.
  • Sel dengan #| label: fig-... dan #| fig-cap: "..." menghasilkan gambar bernomor dan bersubjudul.
02_render_quarto.py
# Bab 29 - Merender laporan Quarto dari kode (laporan/laporan.qmd)
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

folder = Path(__file__).parent
kerja = folder / "_tmp" / "laporan"
if kerja.exists():
    shutil.rmtree(kerja)
kerja.mkdir(parents=True)
shutil.copy(folder / "laporan" / "laporan.qmd", kerja)

# Quarto menjalankan sel Python dengan penafsir yang kita tunjuk lewat QUARTO_PYTHON
lingkungan = dict(os.environ, QUARTO_PYTHON=sys.executable)
quarto = shutil.which("quarto") or "quarto"

for benih in (42, 7):
    hasil = subprocess.run(
        [quarto, "render", "laporan.qmd", "-P", f"benih:{benih}"],
        cwd=kerja, env=lingkungan, capture_output=True, text=True,
    )
    html = (kerja / "laporan.html").read_text(encoding="utf-8")
    ada = "Pada benih" in html
    print(f"benih {benih}: kode keluar {hasil.returncode}, HTML dibuat {(kerja / 'laporan.html').exists()}, "
          f"parameter terbaca di laporan: {ada}")
    teks = re.sub(r"<[^>]+>", "", html)
    print("   hasil di laporan:", re.search(r"Rata-rata A [0-9].*", teks).group(0))
benih 42: kode keluar 0, HTML dibuat True, parameter terbaca di laporan: True
   hasil di laporan: Rata-rata A 1.81, B 2.28; p = 0.0009
benih 7: kode keluar 0, HTML dibuat True, parameter terbaca di laporan: True
   hasil di laporan: Rata-rata A 1.55, B 2.20; p = 0.0001

Kode ini tidak dijalankan saat buku dibuat karena butuh Quarto dan Jupyter terpasang, jadi keluarannya di atas diambil sekali dari komputer penulis. Skrip merender laporan dua kali, dengan benih 42 dan 7. Hasilnya: dengan benih 42 rata-rata plot A 1,81 dan B 2,28 (p = 0,0009), persis sama dengan Bab 26; dengan benih 7 angkanya berubah (1,55 dan 2,20) tanpa menyentuh isi laporan. Itulah kekuatan laporan berparameter.

TipTIPS

Beberapa temuan nyata saat menyiapkan bab ini: (1) bendera --output-dir pada Quarto hanya berlaku untuk proyek, bukan berkas tunggal, jadi skrip menyalin laporan ke folder kerja lalu merender di sana; (2) parameter lewat -P pada notebook Jupyter membutuhkan pustaka papermill (Quarto akan memberi tahu bila belum terpasang); (3) Quarto memilih penafsir Python lewat variabel lingkungan QUARTO_PYTHON, dan skrip mengisinya dengan sys.executable.

Jupyter Book adalah perangkat serupa untuk membuat buku atau situs dari notebook. Menurut dokumentasi resminya, Jupyter Book 2 dibangun di atas MyST Document Engine; perintahnya jupyter book (dan myst untuk MyST langsung). Jupyter Book tidak dijalankan di buku ini; buka dokumentasi di jupyterbook.org bila Anda ingin menerbitkan seri notebook sebagai situs.

Lingkungan terkunci

Kode yang sama bisa menghasilkan angka berbeda bila versi numpy atau scipy berubah. Solusinya mengunci versi tepat semua paket. Dengan uv (dikenalkan di Seri I1), pyproject.toml menyatakan paket yang Anda butuhkan, dan uv lock memilih versi persis semua paket (termasuk paket yang ikut terbawa) dan menuliskannya ke uv.lock. Orang lain cukup uv sync (instal sesuai kunci), atau uv run yang mengunci dan menyinkronkan otomatis. Opsi --locked memeriksa apakah kunci sesuai pyproject.toml dan gagal bila usang; --frozen memakai kunci apa adanya tanpa memeriksa. Alternatif yang lebih sederhana tetapi kurang lengkap adalah pip freeze > requirements.txt.

03_lingkungan_terkunci.py
# Bab 29 - Lingkungan terkunci dengan uv: pyproject.toml menjadi uv.lock
import shutil
import subprocess
import tomllib
from pathlib import Path

kerja = Path(__file__).parent / "_tmp" / "proyek_kunci"
if kerja.exists():
    shutil.rmtree(kerja)
kerja.mkdir(parents=True)

(kerja / "pyproject.toml").write_text('''[project]
name = "analisis-hutan"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["numpy", "scipy"]
''', encoding="utf-8")

# uv lock: pilih versi semua paket (termasuk paket bawaan) dan tulis ke uv.lock
hasil = subprocess.run(["uv", "lock"], cwd=kerja, capture_output=True, text=True)
print("uv lock, kode keluar:", hasil.returncode)

kunci = tomllib.loads((kerja / "uv.lock").read_text(encoding="utf-8"))
paket = {p["name"]: p["version"] for p in kunci["package"]}
print("Jumlah paket di uv.lock:", len(paket))
for nama in sorted(paket):
    print(f"  {nama} == {paket[nama]}")

# uv lock --check: apakah uv.lock masih sesuai dengan pyproject.toml?
cek = subprocess.run(["uv", "lock", "--check"], cwd=kerja, capture_output=True, text=True)
print("uv lock --check, kode keluar:", cek.returncode, "(0 = masih sesuai)")

# Tambah dependensi di pyproject.toml tanpa mengunci ulang: kunci jadi usang
teks = (kerja / "pyproject.toml").read_text(encoding="utf-8").replace('"scipy"', '"scipy", "pandas"')
(kerja / "pyproject.toml").write_text(teks, encoding="utf-8")
cek2 = subprocess.run(["uv", "lock", "--check"], cwd=kerja, capture_output=True, text=True)
print("Setelah menambah pandas, uv lock --check, kode keluar:", cek2.returncode, "(bukan 0 = kunci usang)")
uv lock, kode keluar: 0
Jumlah paket di uv.lock: 3
  analisis-hutan == 0.1.0
  numpy == 2.5.3
  scipy == 1.18.1
uv lock --check, kode keluar: 0 (0 = masih sesuai)
Setelah menambah pandas, uv lock --check, kode keluar: 1 (bukan 0 = kunci usang)

Kode ini tidak dijalankan saat buku dibuat karena butuh internet dan versi paket berubah seiring waktu, jadi keluarannya diambil sekali. Hasilnya: uv lock sukses, dan uv.lock berisi 3 paket (proyek itu sendiri, numpy 2.5.3, dan scipy 1.18.1 pada hari dijalankan). uv lock --check bernilai 0 (kunci sesuai). Setelah kita menambah pandas ke pyproject.toml tanpa mengunci ulang, --check bernilai 1: kunci usang. Aturan praktis: simpan uv.lock bersama kode di git, dan kunci ulang tiap kali dependensi berubah.

PeringatanAWAS

Kunci hanya menjamin versi paket Python. Ia tidak mengunci sistem operasi, versi Python itu sendiri (selain syarat minimum), atau pustaka sistem seperti GDAL. Untuk riset yang sangat sensitif, catat juga versi Python dan sistem Anda di README.

Snakemake: alur analisis yang tahu apa yang perlu diulang

Analisis nyata punya banyak langkah: ambil data, bersihkan, uji, gambar, laporan. Menjalankan semuanya dengan tangan itu rawan salah, dan mengulang semua langkah saat hanya satu berubah itu boros. Snakemake menyatakan alur sebagai aturan (rule): tiap aturan tahu masukan dan keluarannya, dan Snakemake menyusun urutan serta hanya menjalankan yang perlu.

04_snakemake.py
# Bab 29 - Alur analisis dengan Snakemake: aturan, ketergantungan, dan jalankan ulang seperlunya
import os
import shutil
import subprocess
from pathlib import Path

kerja = Path(__file__).parent / "_tmp" / "alur"
if kerja.exists():
    shutil.rmtree(kerja)
kerja.mkdir(parents=True)

(kerja / "Snakefile").write_text(r'''rule semua:
    input: "hasil/ringkasan.txt"

rule buat_data:
    output: "data/tinggi.csv"
    run:
        import random
        random.seed(42)
        with open(output[0], "w") as f:
            f.write("plot,tinggi_m\n")
            for p, rata in (("A", 15.0), ("B", 18.0)):
                for _ in range(10):
                    f.write(f"{p},{random.gauss(rata, 2):.2f}\n")

rule ringkas:
    input: "data/tinggi.csv"
    output: "hasil/ringkasan.txt"
    run:
        import csv, statistics
        nilai = {}
        for baris in csv.DictReader(open(input[0])):
            nilai.setdefault(baris["plot"], []).append(float(baris["tinggi_m"]))
        with open(output[0], "w") as f:
            for p, v in sorted(nilai.items()):
                f.write(f"plot {p}: rata-rata {statistics.mean(v):.2f} m (n={len(v)})\n")
''', encoding="utf-8")


def jalankan(*argumen):
    """Jalankan snakemake lalu tampilkan hanya baris yang penting (tanpa jam dan jalur komputer)."""
    hasil = subprocess.run(["snakemake", "--cores", "1", *argumen], cwd=kerja,
                           capture_output=True, text=True, env=dict(os.environ, PYTHONUTF8="1"))
    for baris in (hasil.stdout + hasil.stderr).splitlines():
        if baris.startswith(("localrule", "rule", "    reason", "Nothing to be done", "Job stats")) \
                or baris.strip().startswith(("buat_data", "ringkas", "semua", "total")):
            print(baris)


print("=== Jalankan pertama ===")
jalankan()
print((kerja / "hasil" / "ringkasan.txt").read_text(encoding="utf-8").rstrip())

print("=== Jalankan kedua (tidak ada yang berubah) ===")
jalankan()

print("=== Satu baris data ditambah, lalu simulasi (dry-run, -n) ===")
with open(kerja / "data" / "tinggi.csv", "a", encoding="utf-8") as f:
    f.write("A,15.00" + chr(10))
jalankan("-n")
=== Jalankan pertama ===
Job stats:
buat_data        1
ringkas          1
semua            1
total            3
localrule buat_data:
    reason: Missing output files: data/tinggi.csv
localrule ringkas:
    reason: Missing output files: hasil/ringkasan.txt; Input files updated by another job: data/tinggi.csv
localrule semua:
    reason: Input files updated by another job: hasil/ringkasan.txt
plot A: rata-rata 14.72 m (n=10)
plot B: rata-rata 18.38 m (n=10)
=== Jalankan kedua (tidak ada yang berubah) ===
Nothing to be done (all requested files are present and up to date).
=== Satu baris data ditambah, lalu simulasi (dry-run, -n) ===
Job stats:
ringkas        1
semua          1
total          2
rule ringkas:
    reason: Updated input files: data/tinggi.csv
rule semua:
    reason: Input files updated by another job: hasil/ringkasan.txt
Job stats:
ringkas        1
semua          1
total          2
        semua
        ringkas

Kode ini tidak dijalankan saat buku dibuat karena Snakemake belum tersedia sebagai paket jadi untuk Python 3.14. Penulis menjalankannya di lingkungan sementara Python 3.12 (Snakemake 9.27.0), dan keluarannya di atas diambil sekali. Skrip menulis Snakefile berisi tiga aturan (buat_data, ringkas, semua) lalu:

  1. Jalan pertama: semua 3 pekerjaan dijalankan, dengan alasan masing-masing (“Missing output files”). Hasil ringkasan: rata-rata plot A 14,72 m, plot B 18,38 m.
  2. Jalan kedua: “Nothing to be done”. Tidak ada yang berubah, jadi tidak ada yang diulang.
  3. Data diubah (satu baris ditambah) lalu dijalankan sebagai simulasi (-n, dry-run): hanya ringkas dan semua yang akan diulang, alasannya “Updated input files”. buat_data tidak diulang. Pada proyek besar, ini menghemat jam komputasi.

Snakemake juga mendukung pencatatan log, lingkungan per aturan, dan menjalankan di klaster; lihat dokumentasi resminya untuk itu (tidak dicoba di sini).

DVC: versi untuk data

Git hebat untuk kode, tetapi kurang cocok untuk berkas data besar. DVC (Data Version Control) menyimpan data di tempat terpisah (“cache” atau penyimpanan jarak jauh), dan di Git hanya menyimpan berkas penunjuk kecil berisi sidik jari (md5) data. Mengganti versi kode otomatis menunjuk ke versi data yang cocok.

05_dvc.py
# Bab 29 - Versi data dengan DVC: Git menyimpan penunjuk kecil, DVC menyimpan datanya
import os
import shutil
import subprocess
from pathlib import Path

kerja = Path(__file__).parent / "_tmp" / "demo_dvc"
if kerja.exists():
    shutil.rmtree(kerja, onexc=lambda fungsi, jalur, galat: (os.chmod(jalur, 0o700), fungsi(jalur)))   # cache DVC berstatus baca-saja
(kerja / "data").mkdir(parents=True)
env = dict(os.environ, DVC_NO_ANALYTICS="1")      # matikan analitik pemakaian DVC
NL = chr(10)


def dvc(*argumen):
    hasil = subprocess.run(["dvc", *argumen], cwd=kerja, capture_output=True, text=True, env=env)
    return (hasil.stdout + hasil.stderr).strip()


# --no-scm: demo tanpa Git. Di proyek sungguhan, jalankan di dalam repo Git.
print(dvc("init", "--no-scm").splitlines()[0])

data = kerja / "data" / "ukur.csv"
data.write_text("plot,tinggi_m" + NL + "A,15.2" + NL + "B,18.1" + NL, encoding="utf-8", newline=NL)
dvc("add", "data/ukur.csv")
print("--- isi data/ukur.csv.dvc (penunjuk kecil yang di-commit ke Git) ---")
print((kerja / "data" / "ukur.csv.dvc").read_text(encoding="utf-8").strip())
print("--- dvc status ---")
print(dvc("status"))

data.write_text(data.read_text(encoding="utf-8") + "C,16.0" + NL, encoding="utf-8", newline=NL)
print("--- setelah data diubah, dvc status ---")
print(dvc("status").replace(chr(92), "/"))

dvc("add", "data/ukur.csv")
print("--- penunjuk baru ---")
print((kerja / "data" / "ukur.csv.dvc").read_text(encoding="utf-8").strip())
Initialized DVC repository.
--- isi data/ukur.csv.dvc (penunjuk kecil yang di-commit ke Git) ---
outs:
- md5: f273181e42e11399cec54d22dec892b8
  size: 28
  hash: md5
  path: ukur.csv
--- dvc status ---
Data and pipelines are up to date.
--- setelah data diubah, dvc status ---
data/ukur.csv.dvc:
    changed outs:
        modified:           data/ukur.csv
--- penunjuk baru ---
outs:
- md5: 822fd4be8cc45f889fdc709553c433cb
  size: 35
  hash: md5
  path: ukur.csv

Kode ini tidak dijalankan saat buku dibuat karena DVC belum tersedia sebagai paket jadi untuk Python 3.14. Penulis menjalankannya di lingkungan sementara Python 3.12 (DVC 3.67.1) dan keluarannya diambil sekali. Perhatikan alurnya: dvc add membuat ukur.csv.dvc berisi md5 dan ukuran (28 byte); dvc status melaporkan “up to date”; setelah data berubah, status melaporkan modified; dvc add ulang membuat penunjuk baru (md5 berbeda, ukuran 35 byte). Demo memakai --no-scm karena tanpa Git; di proyek sungguhan, jalankan di dalam repo Git lalu git add berkas .dvc. Skrip juga mematikan analitik pemakaian DVC lewat variabel DVC_NO_ANALYTICS, sesuai dokumentasi resminya, supaya tidak ada data terkirim keluar.

Berbagi data dan kode

Riset yang dapat direproduksi baru berguna bila orang lain menerima paketnya. Isi paket yang baik:

  • README: apa isinya, cara menjalankan, dan lisensinya.
  • Lisensi: tanpa lisensi, orang lain secara hukum tidak boleh memakai karya Anda. Pilihan umum: MIT atau Apache-2.0 untuk kode, CC BY 4.0 (boleh dipakai dengan menyebut pencipta) untuk data dan teks. Ini ringkasan umum; baca teks lisensinya dan aturan lembaga Anda.
  • Metadata pengutipan: berkas CITATION.cff (Citation File Format) memuat versi, pengarang, tanggal rilis, dan DOI.
  • Checksum: sidik jari SHA-256 tiap berkas supaya penerima bisa memastikan data tidak berubah.
  • Data sensitif: jangan bagikan data pribadi (transkrip wawancara asli, koordinat rumah). Anonimkan atau bagikan hanya yang diizinkan (Bab 28).
06_berbagi_data_kode.py
# Bab 29 - Menyiapkan paket untuk dibagikan: README, lisensi, CITATION.cff, dan checksum
import hashlib
import json
import shutil
from pathlib import Path

paket = Path(__file__).parent / "_tmp" / "paket_berbagi"
if paket.exists():
    shutil.rmtree(paket)
(paket / "data").mkdir(parents=True)

# Data tiruan kecil
(paket / "data" / "tinggi.csv").write_text("plot,tinggi_m\nA,15.2\nB,18.1\n", encoding="utf-8", newline=chr(10))

# README: apa isi paket dan cara menjalankannya
(paket / "README.md").write_text("""# Analisis tinggi pohon (data tiruan)

Isi: `data/tinggi.csv` (data fiktif), `analisis.py`.
Cara menjalankan: `uv run analisis.py`.
Lisensi: kode MIT, data CC BY 4.0.
""", encoding="utf-8", newline=chr(10))

# CITATION.cff: cara mengutip. Daftar pengarang dan DOI di bawah hanyalah contoh fiktif.
(paket / "CITATION.cff").write_text("""cff-version: 1.2.0
message: "Jika Anda memakai paket ini, mohon kutip seperti berikut."
title: "Analisis tinggi pohon (data tiruan)"
version: 0.1.0
date-released: 2026-10-10
authors:
  - family-names: Contoh
    given-names: Penulis
identifiers:
  - type: doi
    value: 10.5281/zenodo.0000000
""", encoding="utf-8", newline=chr(10))

# Checksum SHA-256: sidik jari berkas supaya orang bisa memastikan datanya tidak berubah
checksum = {}
for berkas in sorted(paket.rglob("*")):
    if berkas.is_file() and berkas.name != "checksum.json":
        checksum[berkas.relative_to(paket).as_posix()] = hashlib.sha256(berkas.read_bytes()).hexdigest()
(paket / "checksum.json").write_text(json.dumps(checksum, indent=2), encoding="utf-8", newline=chr(10))

print("Isi paket:", sorted(p.relative_to(paket).as_posix() for p in paket.rglob("*") if p.is_file()))
for nama, nilai in checksum.items():
    print(f"  {nama:16} sha256 {nilai[:12]}...")

# Verifikasi: ubah data satu karakter, checksum harus berbeda
data = paket / "data" / "tinggi.csv"
asli = hashlib.sha256(data.read_bytes()).hexdigest()
data.write_text(data.read_text(encoding="utf-8").replace("15.2", "15.3"), encoding="utf-8", newline=chr(10))
baru = hashlib.sha256(data.read_bytes()).hexdigest()
print("Data berubah, checksum sama?", asli == baru)
Isi paket: ['CITATION.cff', 'README.md', 'checksum.json', 'data/tinggi.csv']
  CITATION.cff     sha256 2d6ff41e2b08...
  data/tinggi.csv  sha256 2d3d0a182611...
  README.md        sha256 e84f1bdecd00...
Data berubah, checksum sama? False

Skrip menyusun paket kecil (README, CITATION.cff, data, checksum.json) di folder _tmp/. Sidik jari tiap berkas ditampilkan 12 karakter pertama. Ketika satu karakter data diubah (15,2 menjadi 15,3), checksum berubah, dan pertanyaan “checksum sama?” dijawab False. Itu cara orang memastikan berkas yang mereka unduh sama dengan yang Anda maksud. Daftar pengarang dan DOI dalam CITATION.cff contoh ini fiktif.

DOI (digital object identifier) adalah alamat tetap untuk sebuah karya, sehingga tautan tidak mati bila situs pindah. Zenodo adalah repositori terbuka yang memberi DOI pada data dan kode yang diunggah. Menurut dokumentasinya, tiap versi baru adalah catatan terpisah dengan metadata, berkas, dan pengenal tetapnya sendiri, tertaut dengan versi lain; dengan begitu pengutip bisa menunjuk versi persis. Zenodo juga punya integrasi dengan GitHub untuk mengarsipkan rilis (detailnya ada di dokumentasi mereka). Bab ini tidak mengunggah apa pun ke Zenodo: DOI dalam contoh adalah angka nol, bukan DOI sungguhan.

Proyek Bersambung I3: analisis yang bisa diulang

Puncak proyek I3: seluruh analisis dibungkus satu fungsi yang masukannya hanya benih acak. Bila bekerja benar, dua kali jalan dengan benih sama menghasilkan hasil identik, dan itu bisa diperiksa dengan sidik jari.

07_proyek_reproduksi.py
# Bab 29 - Proyek bersambung I3: analisis utuh sebagai fungsi, hasilnya identik bila diulang
import hashlib
import json

import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
from scipy import stats


def analisis(benih):
    """Seluruh analisis proyek I3 (data tiruan). Satu masukan: benih acak."""
    rng = np.random.default_rng(benih)
    plot_a, plot_b = rng.normal(1.8, 0.6, 30), rng.normal(2.2, 0.7, 30)
    uji = stats.ttest_ind(plot_a, plot_b, equal_var=False)
    d = rng.uniform(10, 60, 60)
    b = 0.12 * d ** 2.4 * np.exp(rng.normal(0, 0.15, 60))
    model = smf.ols("ln_b ~ ln_d", data=pd.DataFrame({"ln_d": np.log(d), "ln_b": np.log(b)})).fit()
    return {"p_uji_t": round(float(uji.pvalue), 6), "kemiringan_alometrik": round(float(model.params["ln_d"]), 6)}


def sidik_jari(hasil):
    return hashlib.sha256(json.dumps(hasil, sort_keys=True).encode()).hexdigest()[:12]


h1, h2, h3 = analisis(42), analisis(42), analisis(7)
print("Benih 42, lari pertama :", h1, sidik_jari(h1))
print("Benih 42, lari kedua   :", h2, sidik_jari(h2))
print("Benih 7                :", h3, sidik_jari(h3))
print("Dua lari benih 42 identik?", h1 == h2)
print("Benih berbeda -> hasil berbeda?", h1 != h3)

# Tanpa benih tetap, hasil berubah tiap dijalankan: laporan tidak bisa direproduksi
bebas = [round(float(np.random.default_rng().normal()), 3) for _ in range(2)]
print("Tanpa benih, dua undian berbeda?", bebas[0] != bebas[1])
Benih 42, lari pertama : {'p_uji_t': 0.000871, 'kemiringan_alometrik': 2.406733} 4896ee7e5c71
Benih 42, lari kedua   : {'p_uji_t': 0.000871, 'kemiringan_alometrik': 2.406733} 4896ee7e5c71
Benih 7                : {'p_uji_t': 5.6e-05, 'kemiringan_alometrik': 2.409982} 89da579235e4
Dua lari benih 42 identik? True
Benih berbeda -> hasil berbeda? True
Tanpa benih, dua undian berbeda? True

Dua jalan dengan benih 42 menghasilkan hasil dan sidik jari sama (4896ee7e5c71); benih 7 menghasilkan hasil berbeda (89da579235e4). Pada baris terakhir, tanpa benih tetap dua undian berbeda: laporan seperti itu mustahil direproduksi. Laporan Quarto di laporan/laporan.qmd memakai prinsip yang sama untuk dua bagian proyek (uji beda dan regresi alometrik). Untuk melengkapi seluruh proyek I3, Anda tinggal menambah bagian dari Bab 27 (ikan, ternak, hasil panen terhadap hujan) dan Bab 28 (analisis wawancara) ke laporan itu: tiap bagian cukup satu sel kode yang memanggil fungsi analisis.

Daftar periksa ringkas untuk proyek reproduksi Anda:

  1. Data mentah tidak pernah diedit; hasil olahan selalu dibuat oleh kode.
  2. Benih acak tetap, dan ditaruh di parameter.
  3. uv.lock ada dan terbaru.
  4. Satu perintah merender seluruh laporan dari nol.
  5. README, lisensi, CITATION.cff, dan checksum disertakan.
  6. Seseorang lain (atau Anda di komputer lain) sudah mencoba menjalankannya.

Latihan

  1. Di laporan/laporan.qmd, tambahkan parameter ukuran_efek yang dipakai pada rata-rata plot B (misalnya 2.2 menjadi 1.8 + ukuran_efek), lalu render dengan -P ukuran_efek:0.1 dan -P ukuran_efek:0.8. Apa yang terjadi pada p-value?
  2. Ubah 06_berbagi_data_kode.py agar checksum memakai algoritma lain dari hashlib (misalnya sha512). Apa bedanya pada panjang sidik jari?
  3. Tambahkan satu aturan baru ke Snakefile pada 04_snakemake.py, misalnya grafik yang bergantung pada data/tinggi.csv. Jalankan dan lihat berapa pekerjaan dalam “Job stats”.
  4. Tantangan: buat satu perintah (skrip Python) yang merender laporan, menghitung checksum hasilnya, dan membandingkannya dengan render sebelumnya. Apakah dua render dengan parameter sama menghasilkan HTML yang sama bit demi bit? Mengapa mungkin tidak?

Ringkasan Bab

  • Reproduksi = data dan kode sama, angka sama. Replikasi = data baru, kesimpulan sama.
  • Notebook rapi: urut dari atas, pengaturan di awal, benih tetap, “restart and run all” tanpa error.
  • Quarto merender laporan dari kode; parameter (-P) mengubah hasil tanpa mengubah berkas; Jupyter Book 2 dibangun di atas MyST.
  • uv lock mengunci versi paket di uv.lock; --check mengungkap kunci yang usang.
  • Snakemake menjalankan hanya langkah yang perlu; DVC menyimpan penunjuk data di Git. Keduanya belum ada sebagai paket jadi untuk Python 3.14 dan dijalankan di Python 3.12.
  • Berbagi: README, lisensi, CITATION.cff, checksum, DOI (Zenodo); jangan membagikan data pribadi.

Penutup Seri I3

Selamat! Anda menyelesaikan Seri I3. Anda sekarang bisa merangkum data dan membuat selang kepercayaan, menguji beda kelompok dan membaca p-value dengan benar, membangun model regresi dan memeriksanya, meramal deret waktu, menyimulasikan ketidakpastian, menganalisis wawancara petani, nelayan, dan peternak, serta membungkus semuanya dalam laporan yang bisa diulang siapa saja.

Tetapi analisis kita makin berat dan berulang: fungsi yang sama dipanggil ratusan kali, berkas data makin besar, dan kode makin sering perlu dibungkus agar tak diulang-ulang. Seri I4 memperkuat kode dengan fitur bahasa tingkat lanjut: iterator dan generator, decorator, context manager, type hint, validasi dengan Pydantic, dan async.

Selanjutnya, di Bab 30: iterator dan generator, cara memproses berkas data besar sepotong demi sepotong tanpa menghabiskan memori.