"""
gboost.py -- Boosting from scratch: AdaBoost (Freund & Schapire 1997) and Gradient Boosting (Friedman 2001).

Backs the notebooks in  Z-ML Trees-Gradient Boosting.

Boosting is the opposite idea to bagging. A random forest averages many INDEPENDENT deep trees to cut
VARIANCE; boosting adds many SMALL, DEPENDENT trees in sequence, each fit to the errors the running
ensemble still makes, to cut BIAS. The from-scratch core here is the boosting LOOP itself -- the trees
used as base learners are shallow regression trees (built from scratch already in the CART notebook, so
we reuse scikit-learn's for speed; the novelty is the additive, gradient-driven loop).

GRADIENT BOOSTING (Friedman) views the ensemble F(x) as a function optimised by gradient descent in
FUNCTION space. Given a loss L(y, F), each round fits a tree to the negative gradient ("pseudo-residuals")
g_i = -dL/dF, then steps  F <- F + nu * gamma, where nu is the learning rate (shrinkage). For squared loss
the pseudo-residual is just the ordinary residual y - F; for the binary log-loss it is y - sigmoid(F).

The step is not simply the tree's output. Friedman follows the tree fit with a PER-LEAF LINE SEARCH,
choosing the constant in each terminal region that best reduces the loss there. For squared loss that
constant IS the leaf mean, so the tree output can be used directly; for log-loss it is a Newton step

    gamma_L = sum_{i in L} (y_i - p_i) / sum_{i in L} p_i (1 - p_i),

and using the raw gradient-fit instead understates every update -- the ensemble still converges, but
climbs noticeably more slowly. It is the difference between trailing scikit-learn by 0.016 AUC at 50
trees and matching it to the fourth decimal.

ADABOOST (Freund & Schapire) is the historical ancestor: it re-weights the training points after each
weak learner, up-weighting the ones still misclassified, and combines the weak learners by a weighted
vote. It is exactly gradient boosting with the EXPONENTIAL loss and stumps -- the special case that
started the field.
"""

import numpy as np
from sklearn.tree import DecisionTreeRegressor, DecisionTreeClassifier


def _sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -30, 30)))


class GradientBoosting:
    """Friedman (2001) gradient boosting. loss='ls' (squared, regression) or 'log' (binary log-loss).
    Base learner: a depth-limited regression tree fit to the negative gradient."""
    def __init__(self, n_estimators=200, learning_rate=0.1, max_depth=3,
                 subsample=1.0, loss="ls", random_state=0):
        self.n_estimators = n_estimators; self.learning_rate = learning_rate
        self.max_depth = max_depth; self.subsample = subsample; self.loss = loss
        self.random_state = random_state

    def fit(self, X, y):
        X = np.asarray(X, float); y = np.asarray(y, float); n = len(y)
        rng = np.random.default_rng(self.random_state); self.trees = []
        if self.loss == "log":
            p0 = np.clip(y.mean(), 1e-3, 1 - 1e-3); self.F0 = np.log(p0 / (1 - p0))
        else:
            self.F0 = y.mean()
        F = np.full(n, self.F0)
        self.train_loss_ = []
        for m in range(self.n_estimators):
            if self.loss == "log":
                grad = y - _sigmoid(F)                                  # negative gradient of log-loss
            else:
                grad = y - F                                            # negative gradient of squared loss
            if self.subsample < 1.0:
                idx = rng.choice(n, int(self.subsample * n), replace=False)
            else:
                idx = np.arange(n)
            tree = DecisionTreeRegressor(max_depth=self.max_depth, random_state=self.random_state)
            tree.fit(X[idx], grad[idx])
            if self.loss == "log":
                # Friedman's per-leaf line search: a Newton step in each terminal region,
                # gamma_L = sum(y - p) / sum(p(1-p)). For squared loss the leaf means are
                # already optimal, so the raw tree output is used there.
                p = _sigmoid(F); lf = tree.apply(X); lfi = lf[idx]
                num = np.bincount(lfi, weights=grad[idx], minlength=lf.max() + 1)
                den = np.bincount(lfi, weights=(p * (1 - p))[idx], minlength=lf.max() + 1)
                gamma = num / np.maximum(den, 1e-12)
                step = gamma[lf]
            else:
                gamma = None; step = tree.predict(X)
            F += self.learning_rate * step
            self.trees.append((tree, gamma))
            if self.loss == "log":
                p = _sigmoid(F); self.train_loss_.append(-np.mean(y * np.log(p + 1e-9) + (1 - y) * np.log(1 - p + 1e-9)))
            else:
                self.train_loss_.append(np.mean((y - F) ** 2))
        return self

    def staged_decision_function(self, X):
        """running ensemble output after each tree -- for learning curves."""
        X = np.asarray(X, float); F = np.full(X.shape[0], self.F0)
        for tree, gamma in self.trees:
            F += self.learning_rate * (gamma[tree.apply(X)] if gamma is not None else tree.predict(X))
            yield F

    def decision_function(self, X):
        X = np.asarray(X, float); F = np.full(X.shape[0], self.F0)
        for tree, gamma in self.trees:
            F += self.learning_rate * (gamma[tree.apply(X)] if gamma is not None else tree.predict(X))
        return F

    def predict_proba(self, X):
        p = _sigmoid(self.decision_function(X)); return np.column_stack([1 - p, p])

    def predict(self, X):
        if self.loss == "log":
            return (self.decision_function(X) > 0).astype(int)
        return self.decision_function(X)


class AdaBoost:
    """Discrete AdaBoost (Freund & Schapire 1997) with decision stumps, for binary y in {0,1}."""
    def __init__(self, n_estimators=200, random_state=0):
        self.n_estimators = n_estimators; self.random_state = random_state

    def fit(self, X, y):
        X = np.asarray(X, float); yy = 2 * np.asarray(y, int) - 1; n = len(yy)      # {0,1} -> {-1,+1}
        w = np.full(n, 1.0 / n); self.stumps = []; self.alphas = []
        for m in range(self.n_estimators):
            st = DecisionTreeClassifier(max_depth=1, random_state=self.random_state)
            st.fit(X, yy, sample_weight=w)
            pred = st.predict(X)
            err = np.clip(np.sum(w * (pred != yy)) / np.sum(w), 1e-10, 1 - 1e-10)
            alpha = 0.5 * np.log((1 - err) / err)
            w *= np.exp(-alpha * yy * pred); w /= w.sum()
            self.stumps.append(st); self.alphas.append(alpha)
        return self

    def staged_decision_function(self, X):
        X = np.asarray(X, float); F = np.zeros(X.shape[0])
        for a, st in zip(self.alphas, self.stumps):
            F = F + a * st.predict(X); yield F

    def decision_function(self, X):
        X = np.asarray(X, float)
        return np.sum([a * st.predict(X) for a, st in zip(self.alphas, self.stumps)], axis=0)

    def predict(self, X):
        return (self.decision_function(X) > 0).astype(int)

    def predict_proba(self, X):
        p = _sigmoid(2 * self.decision_function(X)); return np.column_stack([1 - p, p])   # calibrated-ish score
