"""
90/10 Hybrid Classifier
=======================
90%: Holomorphic function (general solution)
10%: Sparse value anchors (particular corrections)
"""

import numpy as np
from scipy.optimize import minimize
import time

class HybridClassifier:
    """
    A classifier that combines:
    - 90%: A holomorphic/analytic function for smooth generalization
    - 10%: Sparse value anchors for high-confidence corrections
    """
    
    def __init__(self, n_features, n_anchors=None, function_ratio=0.9):
        self.n = n_features
        self.function_ratio = function_ratio
        self.anchor_ratio = 1 - function_ratio
        
        # Function parameters (smooth generalization)
        # Represents a holomorphic function f(z) = W @ phi(z)
        # where phi is a nonlinear feature map
        if n_anchors is None:
            n_anchors = int(0.1 * n_features)  # 10% of features as anchors
        self.n_anchors = n_anchors
        
        # Initialize function parameters
        # W: weight matrix for the function part
        self.W_function = np.random.randn(n_features, n_anchors) * 0.01
        self.b_function = np.zeros(n_anchors)
        
        # Anchor parameters (specific values)
        # Anchor indices and their labels
        self.anchor_indices = None
        self.anchor_labels = None
    
    def feature_map(self, x):
        """
        Nonlinear feature map (simulates holomorphic extension).
        
        phi(x) = [1, x₁, x₂, ..., xₙ, x₁², x₁x₂, ..., xₙ²]
        This creates a polynomial feature space that can represent
        arbitrary decision boundaries.
        """
        features = [1.0]  # bias
        for i in range(self.n):
            features.append(x[i])
        # Quadratic terms
        for i in range(self.n):
            for j in range(i, self.n):
                features.append(x[i] * x[j])
        return np.array(features)
    
    def function_prediction(self, x):
        """
        Prediction from the function part (90%).
        """
        phi = self.feature_map(x)
        return np.dot(self.W_function, phi) + self.b_function
    
    def anchor_prediction(self, x):
        """
        Prediction from the anchor part (10%).
        Only activates if x is close to an anchor point.
        """
        if self.anchor_indices is None:
            return np.zeros(len(self.anchor_labels))
        
        # Compute distance to all anchors
        distances = np.array([
            np.linalg.norm(x - x_a) for x_a in self.anchor_indices
        ])
        
        # Softmax weighting (closer anchors have higher weight)
        weights = np.exp(-distances / 0.1)  # bandwidth parameter
        weights /= np.sum(weights)
        
        # Weighted combination of anchor labels
        return np.dot(weights, self.anchor_labels)
    
    def predict(self, x):
        """
        Combined prediction: 90% function + 10% anchors.
        """
        f_func = self.function_prediction(x)
        f_anchor = self.anchor_prediction(x)
        
        return self.function_ratio * f_func + self.anchor_ratio * f_anchor
    
    def train(self, X, y, n_epochs=100, lr=0.01):
        """
        Train the hybrid classifier.
        
        X: (n_samples, n_features) training data
        y: (n_samples,) labels
        """
        n_samples = X.shape[0]
        
        # Step 1: Select anchor points (10% of data)
        n_anchors_actual = int(self.anchor_ratio * n_samples)
        anchor_idx = np.random.choice(n_samples, n_anchors_actual, replace=False)
        self.anchor_indices = X[anchor_idx]
        self.anchor_labels = y[anchor_idx]
        
        # Step 2: Train function parameters via gradient descent
        # Loss: MSE between prediction and true labels
        best_loss = float('inf')
        
        for epoch in range(n_epochs):
            # Forward pass
            predictions = np.array([self.predict(x) for x in X])
            loss = np.mean((predictions - y) ** 2)
            
            if loss < best_loss:
                best_loss = loss
                # Save best parameters
                best_W = self.W_function.copy()
                best_b = self.b_function.copy()
            
            # Gradient computation (simplified)
            # In practice, use automatic differentiation
            # Here we use a finite difference approximation
            
            # Update function parameters
            for i in range(self.W_function.shape[0]):
                for j in range(self.W_function.shape[1]):
                    # Perturb W_function[i,j]
                    self.W_function[i,j] += 0.001
                    pred_plus = np.array([self.predict(x) for x in X])
                    loss_plus = np.mean((pred_plus - y) ** 2)
                    
                    self.W_function[i,j] -= 0.002
                    pred_minus = np.array([self.predict(x) for x in X])
                    loss_minus = np.mean((pred_minus - y) ** 2)
                    
                    grad = (loss_plus - loss_minus) / 0.002
                    self.W_function[i,j] += 0.001
                    self.W_function[i,j] -= lr * grad
            
            # Update bias
            for j in range(self.b_function.shape[0]):
                self.b_function[j] += 0.001
                pred_plus = np.array([self.predict(x) for x in X])
                loss_plus = np.mean((pred_plus - y) ** 2)
                
                self.b_function[j] -= 0.002
                pred_minus = np.array([self.predict(x) for x in X])
                loss_minus = np.mean((pred_minus - y) ** 2)
                
                grad = (loss_plus - loss_minus) / 0.002
                self.b_function[j] += 0.001
                self.b_function[j] -= lr * grad
            
            if (epoch + 1) % 10 == 0:
                print(f"  Epoch {epoch+1}: Loss = {loss:.6f}")
        
        # Restore best parameters
        self.W_function = best_W
        self.b_function = best_b
        
        return best_loss
    
    def evaluate(self, X_test, y_test):
        """
        Evaluate on test data.
        """
        predictions = np.array([self.predict(x) for x in X_test])
        accuracy = np.mean(predictions == y_test)
        return accuracy


# ============================================================
# MAIN EXECUTION
# ============================================================

if __name__ == "__main__":
    # Generate synthetic data
    np.random.seed(42)
    n_samples = 100
    n_features = 5
    
    # Create a nonlinear decision boundary
    X = np.random.randn(n_samples, n_features)
    y = (X[:, 0] ** 2 + X[:, 1] ** 2 < 1.0).astype(int)
    
    print("=" * 60)
    print("90/10 Hybrid Classifier")
    print("=" * 60)
    print(f"Samples: {n_samples}, Features: {n_features}")
    print(f"Function ratio: 90%, Anchor ratio: 10%")
    print("=" * 60)
    
    # Initialize classifier
    clf = HybridClassifier(n_features, n_anchors=10, function_ratio=0.9)
    
    # Train
    print("\nTraining...")
    t_start = time.time()
    loss = clf.train(X, y, n_epochs=50, lr=0.01)
    elapsed = time.time() - t_start
    print(f"Done! ({elapsed:.2f}s, final loss: {loss:.6f})")
    
    # Evaluate
    accuracy = clf.evaluate(X, y)
    print(f"\nTraining accuracy: {accuracy:.4f}")
    
    # Show what was learned
    print("\n--- Learned Structure ---")
    print(f"  Function parameters: {clf.W_function.shape}")
    print(f"  Anchor points: {len(clf.anchor_indices)}")
    print(f"  Function ratio: {clf.function_ratio:.0%}")
    print(f"  Anchor ratio: {clf.anchor_ratio:.0%}")
    
    print("=" * 60)
    print("Hybrid representation: 90% function + 10% values")
    print("=" * 60)
