binboost 0.2.4__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: binboost
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Gradient Boosting Berbasis Aturan Logika Adaptif untuk Fitur Biner
5
5
  Author: Rangga Wahyu Pratama
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  from .binboost import BinBoost
2
2
  from .multiclass import OneVsRestBinBoost
3
3
 
4
- __version__ = "0.2.4"
4
+ __version__ = "0.2.5"
5
5
  __author__ = "Rangga Wahyu Pratama"
6
6
  __all__ = ["BinBoost", "OneVsRestBinBoost"]
@@ -1,18 +1,16 @@
1
- # binboost/binarizer.py
2
1
  import numpy as np
3
2
 
4
3
 
5
4
  class GradientBinarizer:
6
- # Binarisasi fitur numerik berbasis korelasi dengan gradien saat ini
5
+ # 1. Binarisasi Fitur Numerik Berdasarkan Korelasi Dengan Gradient Saat Ini
7
6
  def __init__(self, strategy='gradient', n_thresholds='auto'):
8
7
  self.strategy = strategy
9
8
  self.n_thresholds = n_thresholds
10
- # Ambang batas tetap per fitur disimpan setelah fit pertama untuk strategi non-gradien
9
+ # 1. Simpan Threshold Tetap Per Fitur Buat Strategi Selain Gradient
11
10
  self._fixed_thresholds = {}
12
11
 
13
- def _get_candidates(self, values):
14
- # Kembalikan daftar kandidat ambang batas untuk satu fitur
15
- unique = np.unique(values)
12
+ def _candidates_from_unique(self, unique):
13
+ # 1. Kembalikan Kandidat Threshold Dari Nilai Unik Yang Udah Terurut
16
14
  if len(unique) <= 1:
17
15
  return np.array([])
18
16
  if self.n_thresholds == 'auto':
@@ -24,23 +22,28 @@ class GradientBinarizer:
24
22
  idx = np.linspace(0, len(unique) - 2, max_t, dtype=int)
25
23
  return (unique[idx] + unique[idx + 1]) / 2
26
24
 
25
+ def _get_candidates(self, values):
26
+ # 1. Kembalikan Kandidat Threshold Buat Satu Fitur
27
+ return self._candidates_from_unique(np.unique(values))
28
+
27
29
  def _best_threshold_gradient(self, values, gradients):
28
- # 1. Cari ambang batas t* yang memaksimalkan korelasi dengan gradien
29
- candidates = self._get_candidates(values)
30
+ # 1. Cari Threshold Yang Bikin Nilai Mutlak Jumlah Gradient Sampel Di Atasnya Paling Besar
31
+ unique, inverse = np.unique(values, return_inverse=True)
32
+ candidates = self._candidates_from_unique(unique)
30
33
  if len(candidates) == 0:
31
34
  return 0.0, np.zeros(len(values), dtype=np.float64)
32
- best_t = candidates[0]
33
- best_score = -np.inf
34
- for t in candidates:
35
- binary = (values > t).astype(np.float64)
36
- score = np.abs(np.dot(gradients, binary))
37
- if score > best_score:
38
- best_score = score
39
- best_t = t
35
+ # a. Hitung Jumlah Gradient Per Nilai Unik Lalu Jumlahkan Kumulatif Dari Kanan
36
+ g_per_unique = np.bincount(inverse.ravel(), weights=gradients, minlength=len(unique))
37
+ suffix = np.concatenate([np.cumsum(g_per_unique[::-1])[::-1], [0.0]])
38
+ # b. Hitung Jumlah Gradient Sampel Yang Nilainya Lebih Besar Dari Tiap Kandidat
39
+ k = np.searchsorted(unique, candidates, side='right')
40
+ scores = np.abs(suffix[k])
41
+ # c. Ambil Kandidat Pertama Yang Skornya Paling Tinggi
42
+ best_t = candidates[int(np.argmax(scores))]
40
43
  return best_t, (values > best_t).astype(np.float64)
41
44
 
42
45
  def _best_threshold_quantile(self, col_idx, values):
43
- # 1. Gunakan ambang batas tetap dari kuantil distribusi data
46
+ # 1. Pakai Threshold Tetap Dari Kuantil Distribusi Data
44
47
  if col_idx not in self._fixed_thresholds:
45
48
  candidates = self._get_candidates(values)
46
49
  if len(candidates) == 0:
@@ -51,16 +54,15 @@ class GradientBinarizer:
51
54
  return t, (values > t).astype(np.float64)
52
55
 
53
56
  def _best_threshold_uniform(self, col_idx, values):
54
- # 1. Gunakan ambang batas tengah dari rentang nilai fitur
57
+ # 1. Pakai Threshold Di Tengah Rentang Nilai Fitur
55
58
  if col_idx not in self._fixed_thresholds:
56
59
  self._fixed_thresholds[col_idx] = (values.min() + values.max()) / 2
57
60
  t = self._fixed_thresholds[col_idx]
58
61
  return t, (values > t).astype(np.float64)
59
62
 
60
63
  def _best_threshold_kmeans(self, col_idx, values):
61
- # 1. Gunakan titik tengah dua centroid K-Means satu dimensi sebagai ambang batas
64
+ # 1. Pakai Titik Tengah Dua Centroid KMeans Satu Dimensi Sebagai Threshold
62
65
  if col_idx not in self._fixed_thresholds:
63
- v = values.reshape(-1, 1)
64
66
  c1 = np.percentile(values, 25)
65
67
  c2 = np.percentile(values, 75)
66
68
  for _ in range(100):
@@ -75,8 +77,8 @@ class GradientBinarizer:
75
77
  return t, (values > t).astype(np.float64)
76
78
 
77
79
  def transform(self, X, gradients, numeric_cols):
78
- # Binarisasi semua fitur numerik dan kembalikan matriks biner serta ambang batas iterasi ini
79
- X_bin = X.copy().astype(np.float64)
80
+ # 1. Binarisasi Semua Fitur Numerik Lalu Kembalikan Matriks Biner Dan Threshold Iterasi Ini
81
+ X_bin = X.astype(np.float64, copy=True)
80
82
  thresholds_this_iter = {}
81
83
  for col_idx in numeric_cols:
82
84
  values = X[:, col_idx].astype(np.float64)
@@ -93,3 +95,10 @@ class GradientBinarizer:
93
95
  X_bin[:, col_idx] = binary
94
96
  thresholds_this_iter[col_idx] = t
95
97
  return X_bin, thresholds_this_iter
98
+
99
+ def apply_thresholds(self, X, thresholds, numeric_cols):
100
+ # 1. Binarisasi Data Pakai Threshold Yang Udah Ditentukan Tanpa Nyari Ulang
101
+ X_bin = X.astype(np.float64, copy=True)
102
+ for col_idx in numeric_cols:
103
+ X_bin[:, col_idx] = (X[:, col_idx] > thresholds[col_idx]).astype(np.float64)
104
+ return X_bin
@@ -1,4 +1,3 @@
1
- # binboost/binboost.py
2
1
  import numpy as np
3
2
 
4
3
  from .loss import get_loss, sigmoid
@@ -63,6 +62,12 @@ class BinBoost:
63
62
  lambda0 : float, default=3.0
64
63
  Konstanta regularisasi dasar untuk bobot Newton adaptif (λ_R = lambda0 · L / sqrt(n_R+1)).
65
64
 
65
+ gamma : float, default=0.001
66
+ Ambang penurunan galat minimum yang sifatnya relatif. Aturan baru cuma diterima kalau
67
+ penurunan galat kuadratnya minimal gamma kali energi gradient saat itu. Kalau gak ada
68
+ aturan yang lolos training berhenti lebih awal. Nilai 0.0 mematikan pemangkasan ini.
69
+ Karena relatif satu nilai bisa dipakai buat berbagai dataset dan loss.
70
+
66
71
  binarize_strategy : str, default='gradient'
67
72
  Strategi binarisasi fitur numerik. Pilihan: 'gradient', 'quantile',
68
73
  'uniform', 'kmeans'.
@@ -86,11 +91,28 @@ class BinBoost:
86
91
  Threshold prediksi kelas. Nilai 'auto' menggunakan indeks Youden
87
92
  untuk mencari threshold optimal pada data training.
88
93
 
94
+ ohe_policy : str, default='block'
95
+ Perlakuan kelompok fitur hasil one hot encoding. Nilai block melarang dua kategori dari
96
+ fitur yang sama masuk satu aturan. Nilai allow membolehkannya. Kelompok dikenali kalau
97
+ kolom biner saling eksklusif dan jumlahnya pas satu di tiap baris.
98
+
99
+ Bobot sampel pada training:
100
+ Bobot tidak negatif per sampel lewat argumen sample_weight di fit. Bobot dinormalisasi
101
+ ke rata-rata satu jadi cuma rasio antarbobot yang berpengaruh. Bobot dipakai di skor awal.
102
+ Bobot dipakai di gradient dan hessian. Bobot dipakai di pencarian aturan pakai kuadrat
103
+ terkecil berbobot. Bobot dipakai di bobot Newton aturan. Bobot dipakai di loss training
104
+ dan threshold Youden. min_samples_rule menghitung sampel yang bobotnya positif.
105
+ Subsample per iterasi tetap seragam. Bobot yang semuanya sama hasilnya identik dengan
106
+ tanpa bobot.
107
+
89
108
  Atribut yang tersedia Setelah fit
90
109
  --------------------------------
91
110
  estimators_ : list of Rule
92
111
  Daftar aturan yang dipelajari.
93
112
 
113
+ F0_ : float
114
+ Skor awal berupa log-odds dari proporsi kelas positif yang ditambahkan ke semua prediksi.
115
+
94
116
  rule_weights_ : ndarray of float shape (n_estimators_, 2)
95
117
  Bobot dua sisi setiap aturan. Kolom 0 adalah w0 (tidak memenuhi aturan)
96
118
  dan kolom 1 adalah w1 (memenuhi aturan).
@@ -136,8 +158,8 @@ class BinBoost:
136
158
  beam_width=5,
137
159
  subsample=0.8,
138
160
  min_samples_rule=5,
139
- lambda0=3.0,
140
- gamma=0.0,
161
+ lambda0=3.0,
162
+ gamma=0.001,
141
163
  binarize_strategy='gradient',
142
164
  n_thresholds='auto',
143
165
  n_iter_no_change=None,
@@ -145,6 +167,7 @@ class BinBoost:
145
167
  random_state=None,
146
168
  warm_start=False,
147
169
  threshold='auto',
170
+ ohe_policy='block',
148
171
  ):
149
172
  self.n_estimators = n_estimators
150
173
  self.learning_rate = learning_rate
@@ -160,7 +183,7 @@ class BinBoost:
160
183
  self.beam_width = beam_width
161
184
  self.subsample = subsample
162
185
  self.min_samples_rule = min_samples_rule
163
- self.lambda0 = lambda0
186
+ self.lambda0 = lambda0
164
187
  self.gamma = gamma
165
188
  self.binarize_strategy = binarize_strategy
166
189
  self.n_thresholds = n_thresholds
@@ -169,10 +192,11 @@ class BinBoost:
169
192
  self.random_state = random_state
170
193
  self.warm_start = warm_start
171
194
  self.threshold = threshold
195
+ self.ohe_policy = ohe_policy
172
196
  self._threshold_fitted = 0.5
173
197
 
174
198
  def _validate_input(self, X, y=None):
175
- # Ubah input ke numpy array dan pastikan dimensinya benar
199
+ # 1. Ubah Input Jadi Array Dan Pastikan Dimensinya Bener
176
200
  if hasattr(X, 'values'):
177
201
  if hasattr(X, 'columns'):
178
202
  self._input_feature_names = list(X.columns)
@@ -190,66 +214,85 @@ class BinBoost:
190
214
  return X, y
191
215
 
192
216
  def _detect_numeric_cols(self, X):
193
- # Identifikasi kolom fitur yang bukan biner murni sebagai fitur numerik
194
- numeric = []
195
- for col in range(X.shape[1]):
196
- unique_vals = np.unique(X[:, col])
197
- if not np.all(np.isin(unique_vals, [0.0, 1.0])):
198
- numeric.append(col)
199
- return numeric
217
+ # 1. Cari Kolom Yang Bukan Biner Murni Buat Dianggap Fitur Numerik
218
+ is_biner = ((X == 0.0) | (X == 1.0)).all(axis=0)
219
+ return [int(c) for c in np.where(~is_biner)[0]]
200
220
 
201
221
  def _detect_ohe_groups(self, X):
202
- # 1. Deteksi kelompok fitur OneHotEncoding berdasarkan sifat mutually exclusive
203
- n_features = X.shape[1]
222
+ # 1. Cari Kelompok Kolom One Hot Encoding Yang Saling Eksklusif Dan Jumlahnya Pas Satu Per Baris
223
+ is_biner = ((X == 0.0) | (X == 1.0)).all(axis=0)
224
+ aktif = np.where(is_biner & (X.sum(axis=0) > 0))[0]
225
+ m = len(aktif)
226
+ if m < 2:
227
+ return None
228
+ Xb = X[:, aktif].astype(np.float32)
229
+ # a. Dua Kolom Eksklusif Kalau Gak Pernah Bernilai Satu Barengan
230
+ eksklusif = (Xb.T @ Xb) == 0
231
+ terpakai = np.zeros(m, dtype=bool)
204
232
  groups = {}
205
- used = set()
206
233
  group_id = 0
207
- for i in range(n_features):
208
- if i in used:
234
+ for i in range(m):
235
+ if terpakai[i]:
209
236
  continue
210
- group = [i]
211
- for j in range(i + 1, n_features):
212
- if j in used:
213
- continue
214
- # a. Dua fitur bersifat mutually exclusive jika tidak pernah bernilai 1 bersamaan
215
- both_one = np.sum((X[:, i] == 1) & (X[:, j] == 1))
216
- # b. Batasi ukuran grup maksimum 20 agar tidak memblokir terlalu banyak fitur
217
- if both_one == 0:
218
- if len(group) < 20 and \
219
- np.all(np.isin(np.unique(X[:, i]), [0.0, 1.0])) and \
220
- np.all(np.isin(np.unique(X[:, j]), [0.0, 1.0])):
221
- group.append(j)
222
- used.add(j)
223
- if len(group) > 1:
224
- groups[group_id] = group
237
+ anggota = [i]
238
+ jumlah = Xb[:, i].copy()
239
+ kandidat = np.where(eksklusif[i] & ~terpakai)[0]
240
+ for j in kandidat[kandidat > i]:
241
+ # b. Anggota Baru Harus Eksklusif Ke Semua Anggota Kelompok
242
+ if eksklusif[j, anggota].all():
243
+ anggota.append(int(j))
244
+ jumlah += Xb[:, j]
245
+ # c. Berhenti Kalau Kelompok Udah Nutup Semua Baris
246
+ if np.all(jumlah == 1.0):
247
+ break
248
+ if len(anggota) > 1 and np.all(jumlah == 1.0):
249
+ groups[group_id] = [int(aktif[a]) for a in anggota]
250
+ terpakai[anggota] = True
225
251
  group_id += 1
226
- used.add(i)
227
252
  return groups if groups else None
228
253
 
254
+ def _validate_sample_weight(self, sample_weight, n_samples):
255
+ # 1. Kembalikan None Kalau Gak Ada Bobot Atau Bobot Yang Udah Dinormalisasi Ke Rata-Rata Satu
256
+ if sample_weight is None:
257
+ return None
258
+ s = np.asarray(sample_weight, dtype=np.float64).ravel()
259
+ if len(s) != n_samples:
260
+ raise ValueError("Panjang bobot sampel harus sama dengan jumlah baris X.")
261
+ if not np.all(np.isfinite(s)) or np.any(s < 0):
262
+ raise ValueError("Bobot sampel harus berhingga dan tidak negatif.")
263
+ total = s.sum()
264
+ if total <= 0:
265
+ raise ValueError("Jumlah bobot sampel harus positif.")
266
+ s = s * (n_samples / total)
267
+ # 2. Bobot Yang Semuanya Sama Dianggap Sama Dengan Tanpa Bobot
268
+ if np.ptp(s) <= 1e-12:
269
+ return None
270
+ return s
271
+
229
272
  def _subsample_indices(self, n_samples, rng):
230
- # Kembalikan indeks subsample acak tanpa pengembalian
273
+ # 1. Kembalikan Indeks Subsample Acak Tanpa Pengembalian
231
274
  if self.subsample >= 1.0:
232
275
  return np.arange(n_samples)
233
276
  n_sub = max(1, int(n_samples * self.subsample))
234
277
  return rng.choice(n_samples, size=n_sub, replace=False)
235
278
 
236
279
  def _build_operators(self):
237
- # Gabungkan daftar operator dan tambahkan XOR jika use_xor aktif
280
+ # 1. Gabungin Daftar Operator Terus Tambahin XOR Kalau Diaktifin
238
281
  ops = list(self.operators)
239
282
  if self.use_xor and 'XOR' not in ops:
240
283
  ops.append('XOR')
241
284
  return ops
242
285
 
243
286
  def _seleksi_fitur(self, X_bin, gradients, hessians):
244
- dot_gf = X_bin.T @ gradients
245
- H_f = X_bin.T @ hessians
246
- n_f = X_bin.sum(axis=0)
247
- lambda_f = self.lambda0 / np.sqrt(n_f + 1.0)
248
- gains = (dot_gf ** 2) / (H_f + lambda_f + 1e-10)
249
-
250
287
  if self.feature_selection_threshold <= 0.0:
251
288
  return np.arange(X_bin.shape[1])
252
289
 
290
+ dot_gf = X_bin.T @ gradients
291
+ H_f = X_bin.T @ hessians
292
+ n_f = X_bin.sum(axis=0)
293
+ lambda_f = self.lambda0 / np.sqrt(n_f + 1.0)
294
+ gains = (dot_gf ** 2) / (H_f + lambda_f + 1e-10)
295
+
253
296
  nilai_persentil = np.percentile(gains, self.feature_selection_threshold * 100)
254
297
  selected = np.where(gains >= nilai_persentil)[0]
255
298
  if len(selected) == 0:
@@ -283,14 +326,19 @@ class BinBoost:
283
326
  X : array-like of shape (n_samples, n_features)
284
327
  y : array-like of shape (n_samples,), nilai 0 atau 1
285
328
  sample_weight : array-like of shape (n_samples,), opsional
329
+ Bobot tidak negatif per sampel. Penjelasan lengkap ada di docstring kelas.
286
330
 
287
331
  Kembalian
288
332
  ----------
289
333
  self
290
334
  """
335
+ if self.ohe_policy not in ('block', 'allow'):
336
+ raise ValueError("Parameter ohe_policy harus 'block' atau 'allow'.")
337
+
291
338
  self._input_feature_names = None
292
339
  X, y = self._validate_input(X, y)
293
340
  n_samples, n_features = X.shape
341
+ s = self._validate_sample_weight(sample_weight, n_samples)
294
342
 
295
343
  self.n_features_in_ = n_features
296
344
  self.feature_names_in_ = (
@@ -325,7 +373,7 @@ class BinBoost:
325
373
  feature_selection_threshold=0.0,
326
374
  min_samples_rule=self.min_samples_rule,
327
375
  rule_complexity_penalty=self.rule_complexity_penalty,
328
- ohe_groups=self.ohe_groups_,
376
+ ohe_groups=self.ohe_groups_ if self.ohe_policy == 'block' else None,
329
377
  gamma=self.gamma,
330
378
  )
331
379
 
@@ -334,31 +382,41 @@ class BinBoost:
334
382
  self.rule_weights_ = []
335
383
  self.thresholds_ = {col: [] for col in numeric_cols}
336
384
  self.train_score_ = []
337
- self._F = loss_fn.init_F(y)
385
+ self._F = loss_fn.init_F(y, sample_weight=s)
386
+ # 1. Simpan Skor Awal Biar Prediksi Sama Dengan Waktu Training
387
+ self.F0_ = float(self._F[0])
388
+ else:
389
+ # 2. Ubah Atribut Array Balik Jadi List Biar Bisa Ditambah
390
+ self.rule_weights_ = [np.asarray(w) for w in self.rule_weights_]
391
+ self.train_score_ = list(self.train_score_)
338
392
 
339
393
  no_improve_count = 0
340
394
  best_score = np.inf
341
395
 
342
396
  for m in range(self.n_estimators):
343
- # 1. Hitung gradien dari fungsi loss yang dipilih
397
+ # 3. Hitung Gradient Dari Loss Yang Dipilih
344
398
  gradients = loss_fn.gradient(y, self._F)
345
- hessians = loss_fn.hessian(y, self._F)
399
+ hessians = loss_fn.hessian(y, self._F)
346
400
 
347
- # 2. Ambil subsample data untuk iterasi ini
401
+ # 4. Ambil Subsample Buat Iterasi Ini
348
402
  idx = self._subsample_indices(n_samples, rng)
349
403
  X_sub = X[idx]
350
404
  g_sub = gradients[idx]
351
- h_sub = hessians[idx]
405
+ h_sub = hessians[idx]
406
+ s_sub = s[idx] if s is not None else None
407
+ # a. Kalikan Gradient Dan Hessian Dengan Bobot Buat Binarisasi Dan Seleksi Fitur
408
+ gw_sub = g_sub * s_sub if s_sub is not None else g_sub
409
+ hw_sub = h_sub * s_sub if s_sub is not None else h_sub
352
410
 
353
- # 3. Binarisasi fitur numerik menggunakan strategi yang dipilih
354
- X_bin, thresholds_iter = binarizer.transform(X_sub, g_sub, numeric_cols)
411
+ # 5. Binarisasi Fitur Numerik Pakai Strategi Yang Dipilih
412
+ X_bin, thresholds_iter = binarizer.transform(X_sub, gw_sub, numeric_cols)
355
413
 
356
- # 4. Seleksi fitur kandidat menggunakan kriteria gain yang konsisten
357
- candidate_features = self._seleksi_fitur(X_bin, g_sub, h_sub)
414
+ # 6. Pilih Fitur Kandidat Pakai Kriteria Penurunan Galat Yang Konsisten
415
+ candidate_features = self._seleksi_fitur(X_bin, gw_sub, hw_sub)
358
416
  rule_finder.forced_candidate_features = candidate_features
359
417
 
360
- # 5. Cari aturan terbaik menggunakan beam search
361
- rule = rule_finder.find_best_rule(X_bin, g_sub)
418
+ # 7. Cari Aturan Terbaik Pakai Beam Search
419
+ rule = rule_finder.find_best_rule(X_bin, g_sub, s_sub)
362
420
  if rule is None:
363
421
  break
364
422
 
@@ -366,24 +424,26 @@ class BinBoost:
366
424
  list(self.feature_names_in_) if self.feature_names_in_ is not None else None
367
425
  )
368
426
 
369
- # 6. Perbarui prediksi model untuk seluruh data menggunakan update dua sisi
370
- X_bin_full, thresholds_full = binarizer.transform(X, gradients, numeric_cols)
427
+ # 8. Pakai Threshold Yang Sama Ke Seluruh Data Terus Evaluasi Aturannya
428
+ X_bin_full = binarizer.apply_thresholds(X, thresholds_iter, numeric_cols)
371
429
  h_full = rule.evaluate(X_bin_full)
372
- w1, w0 = self._hitung_bobot_dua_sisi(h_full, gradients, hessians, len(rule.features))
373
-
374
- # 7. Update F menggunakan kontribusi dua sisi: w1 untuk yang memenuhi, w0 untuk yang tidak
430
+ gw = gradients * s if s is not None else gradients
431
+ hw = hessians * s if s is not None else hessians
432
+ w1, w0 = self._hitung_bobot_dua_sisi(h_full, gw, hw, len(rule.features))
433
+
434
+ # 9. Update Skor Pakai Kontribusi Dua Sisi
375
435
  self._F = self._F + self.learning_rate * (w1 * h_full + w0 * (1.0 - h_full))
376
436
 
377
- # 8. Simpan aturan dengan bobot dua sisi dan statistik iterasi ini
437
+ # 10. Simpan Aturan Beserta Bobot Dua Sisi Dan Thresholdnya
378
438
  self.estimators_.append(rule)
379
439
  self.rule_weights_.append(np.array([w0, w1]))
380
- for col, t in thresholds_full.items():
440
+ for col, t in thresholds_iter.items():
381
441
  self.thresholds_[col].append(t)
382
442
 
383
- current_score = loss_fn.loss(y, self._F)
443
+ current_score = loss_fn.loss(y, self._F, sample_weight=s)
384
444
  self.train_score_.append(current_score)
385
445
 
386
- # 9. Periksa kondisi early stopping jika diaktifkan
446
+ # 11. Cek Syarat Early Stopping Kalau Diaktifin
387
447
  if self.n_iter_no_change is not None:
388
448
  if current_score < best_score - self.tol:
389
449
  best_score = current_score
@@ -395,22 +455,22 @@ class BinBoost:
395
455
 
396
456
  self.n_estimators_ = len(self.estimators_)
397
457
  self.train_score_ = np.array(self.train_score_)
398
- self.rule_weights_ = np.array(self.rule_weights_)
458
+ self.rule_weights_ = np.array(self.rule_weights_).reshape(-1, 2)
399
459
  self.rules_ = [r.to_string(self.feature_names_in_) for r in self.estimators_]
400
460
  self._compute_feature_importances(n_features)
401
461
  self.is_fitted_ = True
402
- # Tentukan threshold prediksi optimal setelah seluruh iterasi selesai
462
+ # 12. Tentuin Threshold Prediksi Terbaik Setelah Semua Iterasi Kelar
403
463
  if self.threshold == 'auto':
404
- self._threshold_fitted = self._cari_threshold_optimal(X, y)
464
+ self._threshold_fitted = self._cari_threshold_optimal(X, y, sample_weight=s)
405
465
  else:
406
466
  self._threshold_fitted = float(self.threshold)
407
467
  return self
408
468
 
409
469
  def _compute_feature_importances(self, n_features):
410
- # Hitung skor kepentingan fitur dari frekuensi kemunculan dikali rata-rata bobot absolut
470
+ # 1. Hitung Feature Importance Dari Selisih Bobot Dua Sisi Tiap Aturan
411
471
  importances = np.zeros(n_features)
412
472
  for rule, weights in zip(self.estimators_, self.rule_weights_):
413
- # Gunakan selisih absolut w1 dan w0 sebagai ukuran gain aturan
473
+ # a. Pakai Selisih Mutlak Bobot Memenuhi Dan Tidak Memenuhi Sebagai Ukuran Pengaruh
414
474
  gain = np.abs(weights[1] - weights[0])
415
475
  for f in rule.features:
416
476
  importances[f] += gain
@@ -421,48 +481,60 @@ class BinBoost:
421
481
  if not getattr(self, 'is_fitted_', False):
422
482
  raise RuntimeError("Model belum dilatih. Panggil fit terlebih dahulu.")
423
483
 
424
- def _cari_threshold_optimal(self, X, y):
425
- # Cari threshold prediksi optimal menggunakan indeks Youden pada data training
484
+ def _cari_threshold_optimal(self, X, y, sample_weight=None):
485
+ # 1. Cari Threshold Prediksi Terbaik Pakai Indeks Youden Di Data Training
426
486
  proba = self.predict_proba(X)[:, 1]
427
- ambang_kandidat = np.sort(np.unique(proba))
428
- skor_terbaik = -np.inf
429
- threshold_terbaik = 0.5
430
- pos = np.sum(y == 1)
431
- neg = np.sum(y == 0)
432
- if pos == 0 or neg == 0:
487
+ bobot = np.ones(len(y)) if sample_weight is None else np.asarray(sample_weight, dtype=np.float64)
488
+ is_pos = (y == 1)
489
+ pos = bobot[is_pos].sum()
490
+ neg = bobot[~is_pos].sum()
491
+ if pos <= 0 or neg <= 0:
492
+ return 0.5
493
+ ambang = np.unique(proba)
494
+ # 2. Lewati Pencarian Kalau Semua Probabilitas Sama
495
+ if len(ambang) < 2:
433
496
  return 0.5
434
- for t in ambang_kandidat:
435
- pred = (proba >= t).astype(int)
436
- tp = np.sum((pred == 1) & (y == 1))
437
- tn = np.sum((pred == 0) & (y == 0))
438
- sensitivity = tp / pos
439
- specificity = tn / neg
440
- # Indeks Youden memaksimalkan sensitivity tambah specificity dikurangi 1
441
- youden = sensitivity + specificity - 1
442
- if youden > skor_terbaik:
443
- skor_terbaik = youden
444
- threshold_terbaik = t
445
- return float(threshold_terbaik)
446
-
497
+
498
+ def bobot_di_atas(p, w):
499
+ # 3. Hitung Jumlah Bobot Di Atas Tiap Threshold Lewat Sorting
500
+ urut = np.argsort(p, kind='stable')
501
+ kum = np.concatenate([[0.0], np.cumsum(w[urut])])
502
+ idx = np.searchsorted(p[urut], ambang, side='left')
503
+ return kum[-1] - kum[idx]
504
+
505
+ tp = bobot_di_atas(proba[is_pos], bobot[is_pos])
506
+ fp = bobot_di_atas(proba[~is_pos], bobot[~is_pos])
507
+ tn = neg - fp
508
+ sensitivity = tp / pos
509
+ specificity = tn / neg
510
+ # 4. Hitung Indeks Youden Dari Sensitivity Plus Specificity Dikurangi Satu
511
+ youden = sensitivity + specificity - 1
512
+ # 5. Ambil Threshold Terkecil Yang Indeks Youdennya Paling Tinggi
513
+ return float(ambang[int(np.argmax(youden))])
514
+
447
515
  def _binarize_untuk_iterasi(self, X, iterasi_idx, numeric_cols):
448
- X_bin = X.copy().astype(np.float64)
516
+ # 1. Binarisasi Pakai Threshold Yang Disimpan Di Iterasi Itu
517
+ if not numeric_cols:
518
+ return X
519
+ X_bin = X.copy()
449
520
  for col in numeric_cols:
450
521
  t = self.thresholds_[col][iterasi_idx]
451
522
  X_bin[:, col] = (X[:, col] > t).astype(np.float64)
452
523
  return X_bin
453
524
 
525
+ def _aktivasi_per_aturan(self, X):
526
+ # 1. Hasilkan Tiap Aturan Beserta Bobot Dan Aktivasinya Di Data
527
+ numeric_cols = self.numeric_cols_
528
+ for i, (rule, weights) in enumerate(zip(self.estimators_, self.rule_weights_)):
529
+ X_bin = self._binarize_untuk_iterasi(X, i, numeric_cols)
530
+ yield i, rule, weights, rule.evaluate(X_bin)
531
+
454
532
  def _decision_function(self, X):
455
533
  self._check_is_fitted()
456
534
  X, _ = self._validate_input(X)
457
- F = np.zeros(X.shape[0])
458
- numeric_cols = self.numeric_cols_
459
- for i, (rule, weights) in enumerate(zip(self.estimators_, self.rule_weights_)):
460
- if self.binarize_strategy == 'gradient' and numeric_cols:
461
- X_bin = self._binarize_untuk_iterasi(X, i, numeric_cols)
462
- else:
463
- dummy_grad = np.ones(X.shape[0])
464
- X_bin, _ = self._binarizer.transform(X, dummy_grad, numeric_cols)
465
- h = rule.evaluate(X_bin)
535
+ # 1. Mulai Dari Skor Awal Yang Sama Kayak Waktu Training
536
+ F = np.full(X.shape[0], getattr(self, 'F0_', 0.0))
537
+ for _, _, weights, h in self._aktivasi_per_aturan(X):
466
538
  w0, w1 = weights[0], weights[1]
467
539
  F += self.learning_rate * (w1 * h + w0 * (1.0 - h))
468
540
  return F
@@ -522,15 +594,8 @@ class BinBoost:
522
594
  """
523
595
  self._check_is_fitted()
524
596
  X, _ = self._validate_input(X)
525
- numeric_cols = self.numeric_cols_
526
- F = np.zeros(X.shape[0])
527
- for i, (rule, weights) in enumerate(zip(self.estimators_, self.rule_weights_)):
528
- if self.binarize_strategy == 'gradient' and numeric_cols:
529
- X_bin = self._binarize_untuk_iterasi(X, i, numeric_cols)
530
- else:
531
- dummy_grad = np.ones(X.shape[0])
532
- X_bin, _ = self._binarizer.transform(X, dummy_grad, numeric_cols)
533
- h = rule.evaluate(X_bin)
597
+ F = np.full(X.shape[0], getattr(self, 'F0_', 0.0))
598
+ for _, _, weights, h in self._aktivasi_per_aturan(X):
534
599
  w0, w1 = weights[0], weights[1]
535
600
  F += self.learning_rate * (w1 * h + w0 * (1.0 - h))
536
601
  p = sigmoid(F)
@@ -550,24 +615,16 @@ class BinBoost:
550
615
  def apply(self, X):
551
616
  """
552
617
  Kembalikan nilai aktivasi setiap aturan untuk setiap sampel.
553
-
618
+
554
619
  Kembalian
555
620
  ----------
556
621
  ndarray of shape (n_samples, n_estimators_)
557
622
  """
558
623
  self._check_is_fitted()
559
624
  X, _ = self._validate_input(X)
560
- numeric_cols = self.numeric_cols_
561
- results = []
562
-
563
- for i, rule in enumerate(self.estimators_):
564
- if self.binarize_strategy == 'gradient' and numeric_cols:
565
- X_bin = self._binarize_untuk_iterasi(X, i, numeric_cols)
566
- else:
567
- dummy_grad = np.ones(X.shape[0])
568
- X_bin, _ = self._binarizer.transform(X, dummy_grad, numeric_cols)
569
- results.append(rule.evaluate(X_bin))
570
-
625
+ results = [h for _, _, _, h in self._aktivasi_per_aturan(X)]
626
+ if not results:
627
+ return np.zeros((X.shape[0], 0))
571
628
  return np.column_stack(results)
572
629
 
573
630
  def get_params(self, deep=True):
@@ -593,8 +650,8 @@ class BinBoost:
593
650
  'beam_width': self.beam_width,
594
651
  'subsample': self.subsample,
595
652
  'min_samples_rule': self.min_samples_rule,
596
- 'lambda0': self.lambda0,
597
- 'gamma': self.gamma,
653
+ 'lambda0': self.lambda0,
654
+ 'gamma': self.gamma,
598
655
  'binarize_strategy': self.binarize_strategy,
599
656
  'n_thresholds': self.n_thresholds,
600
657
  'n_iter_no_change': self.n_iter_no_change,
@@ -602,6 +659,7 @@ class BinBoost:
602
659
  'random_state': self.random_state,
603
660
  'warm_start': self.warm_start,
604
661
  'threshold': self.threshold,
662
+ 'ohe_policy': self.ohe_policy,
605
663
  }
606
664
 
607
665
  def set_params(self, **params):
@@ -671,4 +729,4 @@ class BinBoost:
671
729
  else f"X{f}"
672
730
  )
673
731
  usage[name] = usage.get(name, 0) + 1
674
- return usage
732
+ return usage