text: import numpy as np
  from typing import Tuple
  from IMLearn.metalearners.adaboost import AdaBoost
  from IMLearn.learners.classifiers import DecisionStump
  from utils import *
  import plotly.graph_objects as go
  from plotly.subplots import make_subplots
  from IMLearn.metrics import loss_functions
  
  
  def generate_data(n: int, noise_ratio: float) -> Tuple[np.ndarray, np.ndarray]:
      """
      Generate a dataset in R^2 of specified size
  
      Parameters
      ----------
      n: int
          Number of samples to generate
  
      noise_ratio: float
          Ratio of labels to invert
  
      Returns
      -------
      X: np.ndarray of shape (n_samples,2)
          Design matrix of samples
  
      y: np.ndarray of shape (n_samples,)
          Labels of samples
      """
      '''
      generate samples X with shape: (num_samples, 2) and labels y with shape (num_samples).
      num_samples: the number of samples to generate
      noise_ratio: invert the label for this ratio of the samples
      '''
      X, y = np.random.rand(n, 2) * 2 - 1, np.ones(n)
      y[np.sum(X ** 2, axis=1) < 0.5 ** 2] = -1
      y[np.random.choice(n, int(noise_ratio * n))] *= -1
      return X, y
  
  
  def fit_and_evaluate_adaboost(noise, n_learners=250, train_size=5000, test_size=500):
      (train_X, train_y), (test_X, test_y) = generate_data(train_size, noise), generate_data(test_size, noise)
  
      # Question 1: Train- and test errors of AdaBoost in noiseless case
      adaboost_model = AdaBoost(DecisionStump, n_learners).fit(train_X, train_y)
      train_loss = np.zeros(n_learners)
      test_loss = np.zeros(n_learners)
  
      for i in range(n_learners):
          train_loss[i] = adaboost_model.partial_loss(train_X, train_y, i + 1)
          test_loss[i] = adaboost_model.partial_loss(test_X, test_y, i + 1)
      iterations_array = np.arange(1, n_learners + 1)
      go.Figure(layout=go.Layout(title=f"Train and test errors as a function of number of learners, noise={noise}"
                                 , margin=dict(t=100),  width=1000, height=600)) \
          .add_trace(go.Scatter(x=iterations_array, y=train_loss, mode='lines', name="Train error")) \
          .add_trace(go.Scatter(x=iterations_array, y=test_loss, mode='lines', name="Test error"))\
          .update_layout(xaxis_title="Number of learners", yaxis_title="Error").show()
      # Question 2: Plotting decision surfaces
      T = [5, 50, 100, 250]
      lims = np.array([np.r_[train_X, test_X].min(axis=0), np.r_[train_X, test_X].max(axis=0)]).T + np.array([-.1, .1])
      if noise == 0:
          fig = make_subplots(rows=2, cols=2, subplot_titles=[f"{t} iterations" for t in T], vertical_spacing=.06)
          for i, t in enumerate(T):
              fig.add_traces(
                  [decision_surface(lambda x: adaboost_model.partial_predict(x, t), lims[0], lims[1]),
                   go.Scatter(x=test_X[:, 0], y=test_X[:, 1], mode="markers", showlegend=False,
                              marker=dict(color=test_y, symbol="circle", colorscale=[custom[0], custom[-1]],
                                          line=dict(color="black", width=1)))],
                  rows=(i // 2) + 1, cols=(i % 2) + 1)
          fig.update_layout(
              title="Decision boundaries obtained by 5, 50, 100 and 250 iterations without noise",
              margin=dict(t=100),  width=1000, height=600).update_xaxes(visible=False).update_yaxes(visible=False).show()
  
  
      # Question 3: Decision surface of best performing ensemble
      if noise == 0:
          t = int(np.argmin(test_loss)) + 1 # plus 1 because i'th place in the array corresponds to size i+1
          t_accuracy = loss_functions.accuracy(test_y, adaboost_model.partial_predict(test_X, t))
          fig = make_subplots(rows=1, cols=1,
                               subplot_titles=[f"Decision surface for {t} iterations, which achieved lowest test error,"
                                               f" and accuracy {t_accuracy}"])
          fig.add_traces([decision_surface(lambda x: adaboost_model.partial_predict(x, t), lims[0], lims[1],
                                            showscale=False),
                           go.Scatter(x=test_X[:, 0], y=test_X[:, 1], mode="markers", showlegend=False,
                                      marker=dict(color=test_y, symbol="circle", colorscale=[custom[0], custom[-1]],
                                                  line=dict(color="black", width=1)))], rows=1, cols=1)
          fig.update_layout(margin=dict(t=100), width=800, height=600)\
              .update_xaxes(visible=False).update_yaxes(visible=False).show()
  
      # Question 4: Decision surface with weighted samples
      points_sizes = (adaboost_model.D_[n_learners - 1] / np.max(adaboost_model.D_[n_learners - 1])) * 10
      fig = make_subplots(rows=1, cols=1,
                          subplot_titles=[f"Training set with points sizes proportional to their weights,"
                                          f" with noise={noise}"])
      fig.add_traces(
          [decision_surface(lambda x: adaboost_model.partial_predict(x, n_learners), lims[0], lims[1], showscale=False),
           go.Scatter(x=train_X[:, 0], y=train_X[:, 1], mode="markers", showlegend=False,
                      marker=dict(color=test_y, symbol="circle", size=points_sizes, colorscale=[custom[0], custom[-1]],
                                  line=dict(color="black", width=1)))], rows=1, cols=1)
  
      fig.update_layout(margin=dict(t=100),  width=800, height=600)\
          .update_xaxes(visible=False).update_yaxes(visible=False).show()
  
  if __name__ == '__main__':
      np.random.seed(0)
      for noise in [0, 0.4]:
          fit_and_evaluate_adaboost(noise)
  