docs: def fit(
              self,
              peptides,
              affinities,
              allele_encoding=None,
              inequalities=None,
              sample_weights=None,
              shuffle_permutation=None,
              verbose=1,
              progress_preamble="",
              progress_print_interval=5.0):
          """
          Fit the neural network.
          
          Parameters
          ----------
          peptides : EncodableSequences or list of string
          
          affinities : list of float
              nM affinities. Must be same length of as peptides.
          
          allele_encoding : AlleleEncoding, optional
              If not specified, the model will be a single-allele predictor.
  
          inequalities : list of string, each element one of ">", "<", or "=".
              Inequalities to use for fitting. Same length as affinities.
              Each element must be one of ">", "<", or "=". For example, a ">"
              will train on y_pred > y_true for that element in the training set.
              Requires using a custom losses that support inequalities (e.g.
              mse_with_ineqalities).
              If None all inequalities are taken to be "=".
              
          sample_weights : list of float, optional
              If not specified, all samples (including random negatives added
              during training) will have equal weight. If specified, the random
              negatives will be assigned weight=1.0.
  
          shuffle_permutation : list of int, optional
              Permutation (integer list) of same length as peptides and affinities
              If None, then a random permutation will be generated.
  
          verbose : int
              Keras verbosity level
  
          progress_preamble : string
              Optional string of information to include in each progress update
  
          progress_print_interval : float
              How often (in seconds) to print progress update. Set to None to
              disable.
          """
          encodable_peptides = EncodableSequences.create(peptides)
          peptide_encoding = self.peptides_to_network_input(encodable_peptides)
  
          length_counts = (
              pandas.Series(encodable_peptides.sequences)
              .str.len().value_counts().to_dict())
  
          num_random_negative = {}
          for length in range(8, 16):
              num_random_negative[length] = int(
                  length_counts.get(length, 0) *
                  self.hyperparameters['random_negative_rate'] +
                  self.hyperparameters['random_negative_constant'])
          num_random_negative = pandas.Series(num_random_negative)
          logging.info("Random negative counts per length:\n%s" % (
              str(num_random_negative.to_dict())))
  
          aa_distribution = None
          if self.hyperparameters['random_negative_match_distribution']:
              aa_distribution = amino_acid_distribution(
                  encodable_peptides.sequences,
                  smoothing=self.hyperparameters[
                      'random_negative_distribution_smoothing'])
              logging.info(
                  "Using amino acid distribution for random negative:\n%s" % (
                  str(aa_distribution.to_dict())))
  
          y_values = from_ic50(affinities)
          assert numpy.isnan(y_values).sum() == 0, numpy.isnan(y_values).sum()
          if inequalities is not None:
              # Reverse inequalities because from_ic50() flips the direction
              # (i.e. lower affinity results in higher y values).
              adjusted_inequalities = pandas.Series(inequalities).map({
                  "=": "=",
                  ">": "<",
                  "<": ">",
              }).values
          else:
              adjusted_inequalities = numpy.tile("=", len(y_values))
          if len(adjusted_inequalities) != len(y_values):
              raise ValueError("Inequalities and y_values must have same length")
  
          x_dict_without_random_negatives = {
              'peptide': peptide_encoding,
          }
          allele_encoding_dims = None
          if allele_encoding is not None:
              allele_encoding_input = self.allele_encoding_to_network_input(
                  allele_encoding)
              allele_encoding_dims = allele_encoding_input.shape[1:]
              x_dict_without_random_negatives['allele'] = allele_encoding_input
  
          # Shuffle y_values and the contents of x_dict_without_random_negatives
          # This ensures different data is used for the test set for early stopping
          # when multiple models are trained.
          if shuffle_permutation is None:
              shuffle_permutation = numpy.random.permutation(len(y_values))
          y_values = y_values[shuffle_permutation]
          peptide_encoding = peptide_encoding[shuffle_permutation]
          adjusted_inequalities = adjusted_inequalities[shuffle_permutation]
          for key in x_dict_without_random_negatives:
              x_dict_without_random_negatives[key] = (
                  x_dict_without_random_negatives[key][shuffle_permutation])
          if sample_weights is not None:
              sample_weights = sample_weights[shuffle_permutation]
  
          if self.hyperparameters['loss'].startswith("custom:"):
              # Using a custom loss that supports inequalities
              try:
                  custom_loss = CUSTOM_LOSSES[
                      self.hyperparameters['loss'].replace("custom:", "")
                  ]
              except KeyError:
                  raise ValueError(
                      "No such custom loss function: %s. Supported losses are: %s" % (
                          self.hyperparameters['loss'],
                          ", ".join([
                              "custom:" + loss_name for loss_name in CUSTOM_LOSSES
                          ])))
              loss_name_or_function = custom_loss.loss
              loss_supports_inequalities = custom_loss.supports_inequalities
              loss_encode_y_function = custom_loss.encode_y
          else:
              # Using a regular keras loss. No inequalities supported.
              loss_name_or_function = self.hyperparameters['loss']
              loss_supports_inequalities = False
              loss_encode_y_function = None
  
          if not loss_supports_inequalities and (
                  any(inequality != "=" for inequality in adjusted_inequalities)):
              raise ValueError("Loss %s does not support inequalities" % (
                  loss_name_or_function))
  
          if self.network() is None:
              self._network = self.make_network(
                  allele_encoding_dims=allele_encoding_dims,
                  **self.network_hyperparameter_defaults.subselect(
                      self.hyperparameters))
              self.network().compile(
                  loss=loss_name_or_function,
                  optimizer=self.hyperparameters['optimizer'])
  
          if self.hyperparameters['learning_rate'] is not None:
              from keras import backend as K
              K.set_value(
                  self.network().optimizer.lr,
                  self.hyperparameters['learning_rate'])
  
          if loss_supports_inequalities:
              # Do not sample negative affinities: just use an inequality.
              random_negative_ic50 = self.hyperparameters['random_negative_affinity_min']
              random_negative_target = from_ic50(random_negative_ic50)
  
              y_dict_with_random_negatives = {
                  "output": numpy.concatenate([
                      numpy.tile(
                          random_negative_target, int(num_random_negative.sum())),
                      y_values,
                  ]),
              }
              # Note: we are using "<" here not ">" because the inequalities are
              # now in target-space (0-1) not affinity-space.
              adjusted_inequalities_with_random_negatives = (
                  ["<"] * int(num_random_negative.sum()) +
                  list(adjusted_inequalities))
          else:
              # Randomly sample random negative affinities
              y_dict_with_random_negatives = {
                  "output": numpy.concatenate([
                      from_ic50(
                          numpy.random.uniform(
                              self.hyperparameters[
                                  'random_negative_affinity_min'],
                              self.hyperparameters[
                                  'random_negative_affinity_max'],
                              int(num_random_negative.sum()))),
                      y_values,
                  ]),
              }
          if sample_weights is not None:
              sample_weights_with_random_negatives = numpy.concatenate([
                  numpy.ones(int(num_random_negative.sum())),
                  sample_weights])
          else:
              sample_weights_with_random_negatives = None
  
          if loss_encode_y_function is not None:
              y_dict_with_random_negatives['output'] = loss_encode_y_function(
                  y_dict_with_random_negatives['output'],
                  adjusted_inequalities_with_random_negatives)
  
          val_losses = []
          min_val_loss_iteration = None
          min_val_loss = None
  
          fit_info = collections.defaultdict(list)
          start = time.time()
          last_progress_print = None
          x_dict_with_random_negatives = {}
          for i in range(self.hyperparameters['max_epochs']):
              random_negative_peptides_list = []
              for (length, count) in num_random_negative.iteritems():
                  random_negative_peptides_list.extend(
                      random_peptides(
                          count,
                          length=length,
                          distribution=aa_distribution))
              random_negative_peptides = EncodableSequences.create(
                  random_negative_peptides_list)
              random_negative_peptides_encoding = (
                  self.peptides_to_network_input(random_negative_peptides))
  
              if not x_dict_with_random_negatives:
                  if len(random_negative_peptides) > 0:
                      x_dict_with_random_negatives["peptide"] = numpy.concatenate([
                          random_negative_peptides_encoding,
                          peptide_encoding,
                      ])
                      if 'allele' in x_dict_without_random_negatives:
                          x_dict_with_random_negatives['allele'] = numpy.concatenate([
                              x_dict_without_random_negatives['allele'][
                                  numpy.random.choice(
                                      x_dict_without_random_negatives[
                                          'allele'].shape[0],
                                      size=len(random_negative_peptides_list))],
                              x_dict_without_random_negatives['allele']
                          ])
                  else:
                      x_dict_with_random_negatives = (
                          x_dict_without_random_negatives)
              else:
                  # Update x_dict_with_random_negatives in place.
                  # This is more memory efficient than recreating it as above.
                  if len(random_negative_peptides) > 0:
                      x_dict_with_random_negatives["peptide"][:len(random_negative_peptides)] = (
                          random_negative_peptides_encoding
                      )
                      if 'allele' in x_dict_with_random_negatives:
                          x_dict_with_random_negatives['allele'][:len(random_negative_peptides)] = (
                              x_dict_with_random_negatives['allele'][
                                  len(random_negative_peptides) + numpy.random.choice(
                                      x_dict_with_random_negatives['allele'].shape[0] -
                                      len(random_negative_peptides),
                                      size=len(random_negative_peptides))
                              ]
                          )
  
              fit_history = self.network().fit(
                  x_dict_with_random_negatives,
                  y_dict_with_random_negatives,
                  shuffle=True,
                  batch_size=self.hyperparameters['minibatch_size'],
                  verbose=verbose,
                  epochs=1,
                  validation_split=self.hyperparameters['validation_split'],
                  sample_weight=sample_weights_with_random_negatives)
  
              for (key, value) in fit_history.history.items():
                  fit_info[key].extend(value)
  
              # Print progress no more often than once every few seconds.
              if progress_print_interval is not None and (
                      not last_progress_print or (
                          time.time() - last_progress_print
                          > progress_print_interval)):
                  print((progress_preamble + " " +
                         "Epoch %3d / %3d: loss=%g. "
                         "Min val loss (%s) at epoch %s" % (
                             i,
                             self.hyperparameters['max_epochs'],
                             fit_info['loss'][-1],
                             str(min_val_loss),
                             min_val_loss_iteration)).strip())
                  last_progress_print = time.time()
  
              if self.hyperparameters['validation_split']:
                  val_loss = fit_info['val_loss'][-1]
                  val_losses.append(val_loss)
  
                  if min_val_loss is None or val_loss <= min_val_loss:
                      min_val_loss = val_loss
                      min_val_loss_iteration = i
  
                  if self.hyperparameters['early_stopping']:
                      threshold = (
                          min_val_loss_iteration +
                          self.hyperparameters['patience'])
                      if i > threshold:
                          if progress_print_interval is not None:
                              print((progress_preamble + " " +
                                  "Stopping at epoch %3d / %3d: loss=%g. "
                                  "Min val loss (%s) at epoch %s" % (
                                      i,
                                      self.hyperparameters['max_epochs'],
                                      fit_info['loss'][-1],
                                      str(min_val_loss),
                                      min_val_loss_iteration)).strip())
                          break
  
          fit_info["time"] = time.time() - start
          fit_info["num_points"] = len(peptides)
          self.fit_info.append(dict(fit_info))