binboost 0.2.3__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.3
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.3"
4
+ __version__ = "0.2.5"
5
5
  __author__ = "Rangga Wahyu Pratama"
6
- __all__ = ["BinBoost"]
6
+ __all__ = ["BinBoost", "OneVsRestBinBoost"]
@@ -1,19 +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
7
-
5
+ # 1. Binarisasi Fitur Numerik Berdasarkan Korelasi Dengan Gradient Saat Ini
8
6
  def __init__(self, strategy='gradient', n_thresholds='auto'):
9
7
  self.strategy = strategy
10
8
  self.n_thresholds = n_thresholds
11
- # Ambang batas tetap per fitur disimpan setelah fit pertama untuk strategi non-gradien
9
+ # 1. Simpan Threshold Tetap Per Fitur Buat Strategi Selain Gradient
12
10
  self._fixed_thresholds = {}
13
11
 
14
- def _get_candidates(self, values):
15
- # Kembalikan daftar kandidat ambang batas untuk satu fitur
16
- unique = np.unique(values)
12
+ def _candidates_from_unique(self, unique):
13
+ # 1. Kembalikan Kandidat Threshold Dari Nilai Unik Yang Udah Terurut
17
14
  if len(unique) <= 1:
18
15
  return np.array([])
19
16
  if self.n_thresholds == 'auto':
@@ -25,23 +22,28 @@ class GradientBinarizer:
25
22
  idx = np.linspace(0, len(unique) - 2, max_t, dtype=int)
26
23
  return (unique[idx] + unique[idx + 1]) / 2
27
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
+
28
29
  def _best_threshold_gradient(self, values, gradients):
29
- # 1. Cari ambang batas t* yang memaksimalkan korelasi dengan gradien
30
- 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)
31
33
  if len(candidates) == 0:
32
34
  return 0.0, np.zeros(len(values), dtype=np.float64)
33
- best_t = candidates[0]
34
- best_score = -np.inf
35
- for t in candidates:
36
- binary = (values > t).astype(np.float64)
37
- score = np.abs(np.dot(gradients, binary))
38
- if score > best_score:
39
- best_score = score
40
- 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))]
41
43
  return best_t, (values > best_t).astype(np.float64)
42
44
 
43
45
  def _best_threshold_quantile(self, col_idx, values):
44
- # 1. Gunakan ambang batas tetap dari kuantil distribusi data
46
+ # 1. Pakai Threshold Tetap Dari Kuantil Distribusi Data
45
47
  if col_idx not in self._fixed_thresholds:
46
48
  candidates = self._get_candidates(values)
47
49
  if len(candidates) == 0:
@@ -52,16 +54,15 @@ class GradientBinarizer:
52
54
  return t, (values > t).astype(np.float64)
53
55
 
54
56
  def _best_threshold_uniform(self, col_idx, values):
55
- # 1. Gunakan ambang batas tengah dari rentang nilai fitur
57
+ # 1. Pakai Threshold Di Tengah Rentang Nilai Fitur
56
58
  if col_idx not in self._fixed_thresholds:
57
59
  self._fixed_thresholds[col_idx] = (values.min() + values.max()) / 2
58
60
  t = self._fixed_thresholds[col_idx]
59
61
  return t, (values > t).astype(np.float64)
60
62
 
61
63
  def _best_threshold_kmeans(self, col_idx, values):
62
- # 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
63
65
  if col_idx not in self._fixed_thresholds:
64
- v = values.reshape(-1, 1)
65
66
  c1 = np.percentile(values, 25)
66
67
  c2 = np.percentile(values, 75)
67
68
  for _ in range(100):
@@ -76,8 +77,8 @@ class GradientBinarizer:
76
77
  return t, (values > t).astype(np.float64)
77
78
 
78
79
  def transform(self, X, gradients, numeric_cols):
79
- # Binarisasi semua fitur numerik dan kembalikan matriks biner serta ambang batas iterasi ini
80
- 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)
81
82
  thresholds_this_iter = {}
82
83
  for col_idx in numeric_cols:
83
84
  values = X[:, col_idx].astype(np.float64)
@@ -94,3 +95,10 @@ class GradientBinarizer:
94
95
  X_bin[:, col_idx] = binary
95
96
  thresholds_this_iter[col_idx] = t
96
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_ = (
@@ -309,6 +357,7 @@ class BinBoost:
309
357
  )
310
358
 
311
359
  numeric_cols = self._detect_numeric_cols(X)
360
+ self.numeric_cols_ = numeric_cols
312
361
  self.ohe_groups_ = self._detect_ohe_groups(X)
313
362
 
314
363
  self._binarizer = GradientBinarizer(
@@ -324,7 +373,7 @@ class BinBoost:
324
373
  feature_selection_threshold=0.0,
325
374
  min_samples_rule=self.min_samples_rule,
326
375
  rule_complexity_penalty=self.rule_complexity_penalty,
327
- ohe_groups=self.ohe_groups_,
376
+ ohe_groups=self.ohe_groups_ if self.ohe_policy == 'block' else None,
328
377
  gamma=self.gamma,
329
378
  )
330
379
 
@@ -333,31 +382,41 @@ class BinBoost:
333
382
  self.rule_weights_ = []
334
383
  self.thresholds_ = {col: [] for col in numeric_cols}
335
384
  self.train_score_ = []
336
- 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_)
337
392
 
338
393
  no_improve_count = 0
339
394
  best_score = np.inf
340
395
 
341
396
  for m in range(self.n_estimators):
342
- # 1. Hitung gradien dari fungsi loss yang dipilih
397
+ # 3. Hitung Gradient Dari Loss Yang Dipilih
343
398
  gradients = loss_fn.gradient(y, self._F)
344
- hessians = loss_fn.hessian(y, self._F)
399
+ hessians = loss_fn.hessian(y, self._F)
345
400
 
346
- # 2. Ambil subsample data untuk iterasi ini
401
+ # 4. Ambil Subsample Buat Iterasi Ini
347
402
  idx = self._subsample_indices(n_samples, rng)
348
403
  X_sub = X[idx]
349
404
  g_sub = gradients[idx]
350
- 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
351
410
 
352
- # 3. Binarisasi fitur numerik menggunakan strategi yang dipilih
353
- 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)
354
413
 
355
- # 4. Seleksi fitur kandidat menggunakan kriteria gain yang konsisten
356
- 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)
357
416
  rule_finder.forced_candidate_features = candidate_features
358
417
 
359
- # 5. Cari aturan terbaik menggunakan beam search
360
- 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)
361
420
  if rule is None:
362
421
  break
363
422
 
@@ -365,24 +424,26 @@ class BinBoost:
365
424
  list(self.feature_names_in_) if self.feature_names_in_ is not None else None
366
425
  )
367
426
 
368
- # 6. Perbarui prediksi model untuk seluruh data menggunakan update dua sisi
369
- 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)
370
429
  h_full = rule.evaluate(X_bin_full)
371
- w1, w0 = self._hitung_bobot_dua_sisi(h_full, gradients, hessians, len(rule.features))
372
-
373
- # 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
374
435
  self._F = self._F + self.learning_rate * (w1 * h_full + w0 * (1.0 - h_full))
375
436
 
376
- # 8. Simpan aturan dengan bobot dua sisi dan statistik iterasi ini
437
+ # 10. Simpan Aturan Beserta Bobot Dua Sisi Dan Thresholdnya
377
438
  self.estimators_.append(rule)
378
439
  self.rule_weights_.append(np.array([w0, w1]))
379
- for col, t in thresholds_full.items():
440
+ for col, t in thresholds_iter.items():
380
441
  self.thresholds_[col].append(t)
381
442
 
382
- current_score = loss_fn.loss(y, self._F)
443
+ current_score = loss_fn.loss(y, self._F, sample_weight=s)
383
444
  self.train_score_.append(current_score)
384
445
 
385
- # 9. Periksa kondisi early stopping jika diaktifkan
446
+ # 11. Cek Syarat Early Stopping Kalau Diaktifin
386
447
  if self.n_iter_no_change is not None:
387
448
  if current_score < best_score - self.tol:
388
449
  best_score = current_score
@@ -394,22 +455,22 @@ class BinBoost:
394
455
 
395
456
  self.n_estimators_ = len(self.estimators_)
396
457
  self.train_score_ = np.array(self.train_score_)
397
- self.rule_weights_ = np.array(self.rule_weights_)
458
+ self.rule_weights_ = np.array(self.rule_weights_).reshape(-1, 2)
398
459
  self.rules_ = [r.to_string(self.feature_names_in_) for r in self.estimators_]
399
460
  self._compute_feature_importances(n_features)
400
461
  self.is_fitted_ = True
401
- # Tentukan threshold prediksi optimal setelah seluruh iterasi selesai
462
+ # 12. Tentuin Threshold Prediksi Terbaik Setelah Semua Iterasi Kelar
402
463
  if self.threshold == 'auto':
403
- self._threshold_fitted = self._cari_threshold_optimal(X, y)
464
+ self._threshold_fitted = self._cari_threshold_optimal(X, y, sample_weight=s)
404
465
  else:
405
466
  self._threshold_fitted = float(self.threshold)
406
467
  return self
407
468
 
408
469
  def _compute_feature_importances(self, n_features):
409
- # Hitung skor kepentingan fitur dari frekuensi kemunculan dikali rata-rata bobot absolut
470
+ # 1. Hitung Feature Importance Dari Selisih Bobot Dua Sisi Tiap Aturan
410
471
  importances = np.zeros(n_features)
411
472
  for rule, weights in zip(self.estimators_, self.rule_weights_):
412
- # Gunakan selisih absolut w1 dan w0 sebagai ukuran gain aturan
473
+ # a. Pakai Selisih Mutlak Bobot Memenuhi Dan Tidak Memenuhi Sebagai Ukuran Pengaruh
413
474
  gain = np.abs(weights[1] - weights[0])
414
475
  for f in rule.features:
415
476
  importances[f] += gain
@@ -420,48 +481,60 @@ class BinBoost:
420
481
  if not getattr(self, 'is_fitted_', False):
421
482
  raise RuntimeError("Model belum dilatih. Panggil fit terlebih dahulu.")
422
483
 
423
- def _cari_threshold_optimal(self, X, y):
424
- # 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
425
486
  proba = self.predict_proba(X)[:, 1]
426
- ambang_kandidat = np.sort(np.unique(proba))
427
- skor_terbaik = -np.inf
428
- threshold_terbaik = 0.5
429
- pos = np.sum(y == 1)
430
- neg = np.sum(y == 0)
431
- 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:
432
496
  return 0.5
433
- for t in ambang_kandidat:
434
- pred = (proba >= t).astype(int)
435
- tp = np.sum((pred == 1) & (y == 1))
436
- tn = np.sum((pred == 0) & (y == 0))
437
- sensitivity = tp / pos
438
- specificity = tn / neg
439
- # Indeks Youden memaksimalkan sensitivity tambah specificity dikurangi 1
440
- youden = sensitivity + specificity - 1
441
- if youden > skor_terbaik:
442
- skor_terbaik = youden
443
- threshold_terbaik = t
444
- return float(threshold_terbaik)
445
-
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
+
446
515
  def _binarize_untuk_iterasi(self, X, iterasi_idx, numeric_cols):
447
- 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()
448
520
  for col in numeric_cols:
449
521
  t = self.thresholds_[col][iterasi_idx]
450
522
  X_bin[:, col] = (X[:, col] > t).astype(np.float64)
451
523
  return X_bin
452
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
+
453
532
  def _decision_function(self, X):
454
533
  self._check_is_fitted()
455
534
  X, _ = self._validate_input(X)
456
- F = np.zeros(X.shape[0])
457
- numeric_cols = self._detect_numeric_cols(X)
458
- for i, (rule, weights) in enumerate(zip(self.estimators_, self.rule_weights_)):
459
- if self.binarize_strategy == 'gradient' and numeric_cols:
460
- X_bin = self._binarize_untuk_iterasi(X, i, numeric_cols)
461
- else:
462
- dummy_grad = np.ones(X.shape[0])
463
- X_bin, _ = self._binarizer.transform(X, dummy_grad, numeric_cols)
464
- 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):
465
538
  w0, w1 = weights[0], weights[1]
466
539
  F += self.learning_rate * (w1 * h + w0 * (1.0 - h))
467
540
  return F
@@ -521,15 +594,8 @@ class BinBoost:
521
594
  """
522
595
  self._check_is_fitted()
523
596
  X, _ = self._validate_input(X)
524
- numeric_cols = self._detect_numeric_cols(X)
525
- F = np.zeros(X.shape[0])
526
- for i, (rule, weights) in enumerate(zip(self.estimators_, self.rule_weights_)):
527
- if self.binarize_strategy == 'gradient' and numeric_cols:
528
- X_bin = self._binarize_untuk_iterasi(X, i, numeric_cols)
529
- else:
530
- dummy_grad = np.ones(X.shape[0])
531
- X_bin, _ = self._binarizer.transform(X, dummy_grad, numeric_cols)
532
- 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):
533
599
  w0, w1 = weights[0], weights[1]
534
600
  F += self.learning_rate * (w1 * h + w0 * (1.0 - h))
535
601
  p = sigmoid(F)
@@ -549,24 +615,16 @@ class BinBoost:
549
615
  def apply(self, X):
550
616
  """
551
617
  Kembalikan nilai aktivasi setiap aturan untuk setiap sampel.
552
-
618
+
553
619
  Kembalian
554
620
  ----------
555
621
  ndarray of shape (n_samples, n_estimators_)
556
622
  """
557
623
  self._check_is_fitted()
558
624
  X, _ = self._validate_input(X)
559
- numeric_cols = self._detect_numeric_cols(X)
560
- results = []
561
-
562
- for i, rule in enumerate(self.estimators_):
563
- if self.binarize_strategy == 'gradient' and numeric_cols:
564
- X_bin = self._binarize_untuk_iterasi(X, i, numeric_cols)
565
- else:
566
- dummy_grad = np.ones(X.shape[0])
567
- X_bin, _ = self._binarizer.transform(X, dummy_grad, numeric_cols)
568
- results.append(rule.evaluate(X_bin))
569
-
625
+ results = [h for _, _, _, h in self._aktivasi_per_aturan(X)]
626
+ if not results:
627
+ return np.zeros((X.shape[0], 0))
570
628
  return np.column_stack(results)
571
629
 
572
630
  def get_params(self, deep=True):
@@ -592,8 +650,8 @@ class BinBoost:
592
650
  'beam_width': self.beam_width,
593
651
  'subsample': self.subsample,
594
652
  'min_samples_rule': self.min_samples_rule,
595
- 'lambda0': self.lambda0,
596
- 'gamma': self.gamma,
653
+ 'lambda0': self.lambda0,
654
+ 'gamma': self.gamma,
597
655
  'binarize_strategy': self.binarize_strategy,
598
656
  'n_thresholds': self.n_thresholds,
599
657
  'n_iter_no_change': self.n_iter_no_change,
@@ -601,6 +659,7 @@ class BinBoost:
601
659
  'random_state': self.random_state,
602
660
  'warm_start': self.warm_start,
603
661
  'threshold': self.threshold,
662
+ 'ohe_policy': self.ohe_policy,
604
663
  }
605
664
 
606
665
  def set_params(self, **params):
@@ -670,4 +729,4 @@ class BinBoost:
670
729
  else f"X{f}"
671
730
  )
672
731
  usage[name] = usage.get(name, 0) + 1
673
- return usage
732
+ return usage