def _prepare_note_info(
    self,
    notes: pd.DataFrame,
    ratings: pd.DataFrame,
    noteStatusHistory: pd.DataFrame,
    prescoringRaterModelOutput: pd.DataFrame,
    prepareForTraining: bool,
    cutoff: Optional[str],
  ) -> pd.DataFrame:
    """Generate a DataFrame with one row per note containing all feature information.

    Note that some columns contain list values (e.g. a single column contains all
    Helpfulness ratings, where each rating is a unique string containing the rater ID
    and their Helpfulness rating).

    Args:
      notes: pd.DataFrame
      ratings: pd.DataFrame
      noteStatusHistory: pd.DataFrame
      prescoringRaterModelOutput: pd.DataFrame
      prepareForTraining: True if ratings should be filtered to discard data after the
        point of scoring to avoid skew.
      cutoff: Whether to prune ratings to those available when a note enters stabilization
        or gains CRH status.  None if prepareForTraining=False.

    Returns:
      pd.DataFrame containing all feature information with one row per note.
    """
    # Validate and normalize types
    assert ((prepareForTraining == False) & (cutoff is None)) | (
      prepareForTraining & (cutoff in {_MIN, _MAX})
    )
    notes[c.tweetIdKey] = notes[c.tweetIdKey].astype(pd.Int64Dtype())
    noteStatusHistory[c.createdAtMillisKey] = noteStatusHistory[c.createdAtMillisKey].astype(
      pd.Int64Dtype()
    )

    # Prep notes
    scoredNotes = self._get_notes(notes, noteStatusHistory)
    if prepareForTraining:
      assert cutoff is not None
      # Prune to recent notes
      scoredNotes = scoredNotes[scoredNotes[c.tweetIdKey] > _MIN_TWEET_ID]
      # Validate that stabilization timestamps are valid
      assert (
        noteStatusHistory[[c.noteIdKey, c.timestampMillisOfFirstNmrDueToMinStableCrhTimeKey]].merge(
          scoredNotes[[c.noteIdKey]]
        )[c.timestampMillisOfFirstNmrDueToMinStableCrhTimeKey]
        < 0
      ).sum() == 0
      # Compute flip labels
      labels = self._label_notes(noteStatusHistory.merge(scoredNotes[[c.noteIdKey]]))
      # Compute scoring cutoffs based on when the note entered and left stabilization
      scoringCutoff = self._compute_scoring_cutoff(
        noteStatusHistory.merge(scoredNotes[[c.noteIdKey]]),
        ratings.merge(scoredNotes[[c.noteIdKey]]),
        cutoff,
      )
      # Validate and merge data, effectively pruning to notes that have a label
      scoredNotes = scoredNotes.merge(scoringCutoff, on=c.noteIdKey)
      assert len(scoredNotes) == len(scoringCutoff)
      assert len(labels) == len(
        scoringCutoff.merge(labels)
      )  # labels should be a subset of scoringCutoff
      scoredNotes = scoredNotes.merge(labels)
      assert len(scoredNotes) == len(labels)
    totalScoredNotes = len(scoredNotes)

    # Prep ratings
    # Prune ratings to only include scored notes and other notes on the same post
    assert scoredNotes[c.tweetIdKey].min() > 0  # tweet should be set for all notes being scored
    adjacentNotes = notes[[c.noteIdKey, c.tweetIdKey]].merge(
      scoredNotes[[c.tweetIdKey]].drop_duplicates()
    )[[c.noteIdKey]]
    assert len(adjacentNotes) == adjacentNotes[c.noteIdKey].nunique()
    ratings = ratings.merge(adjacentNotes)
    assert len(ratings) == len(ratings[[c.noteIdKey, c.raterParticipantIdKey]].drop_duplicates())
    # Associate rater factors
    raterFactors = self._compute_rater_factors(prescoringRaterModelOutput)
    assert len(raterFactors) == raterFactors[c.raterParticipantIdKey].nunique()
    ratings = ratings.merge(raterFactors, how="left")
    # Generate rating datasets for self, peer misleading and peer non-misleading notes
    localRatings = self._prepare_local_ratings(ratings, scoredNotes[[c.noteIdKey]])
    peerRatings = self._prepare_peer_ratings(
      ratings,
      notes[[c.noteIdKey, c.tweetIdKey, c.classificationKey]],
      scoredNotes[[c.noteIdKey, c.tweetIdKey]],
    )
    peerMisleadingRatings = peerRatings[
      peerRatings[c.classificationKey] == c.notesSaysTweetIsMisleadingKey
    ]
    peerNonMisleadingRatings = peerRatings[
      peerRatings[c.classificationKey] == c.noteSaysTweetIsNotMisleadingKey
    ]
    if prepareForTraining:
      localRatings = self._apply_cutoff(localRatings, scoredNotes)
      peerMisleadingRatings = self._apply_cutoff(peerMisleadingRatings, scoredNotes)
      peerNonMisleadingRatings = self._apply_cutoff(peerNonMisleadingRatings, scoredNotes)

    # Extract featuers
    # Generate features that depend on self ratings only
    writingLatency = self._get_note_writing_latency(
      scoredNotes[[c.noteIdKey, _TWEET_CREATION_MILLIS, _NOTE_CREATION_MILLIS]]
    )
    scoredNotes = scoredNotes.merge(writingLatency, how="inner")
    noteAuthors = noteStatusHistory[[c.noteIdKey, c.noteAuthorParticipantIdKey]]
    scoredNotes = scoredNotes.merge(noteAuthors, how="inner")
    quickRatings = self._get_quick_rating_stats(
      scoredNotes[[c.noteIdKey, _NOTE_CREATION_MILLIS]], localRatings
    )
    scoredNotes = scoredNotes.merge(quickRatings, how="inner")
    burstRatings = self._get_burst_rating_stats(
      scoredNotes[[c.noteIdKey]], localRatings[[c.noteIdKey, c.createdAtMillisKey]]
    )
    scoredNotes = scoredNotes.merge(burstRatings, how="inner")
    recentRatings = self._get_recent_rating_stats(
      scoredNotes, localRatings[[c.noteIdKey, c.createdAtMillisKey]], prepareForTraining
    )
    scoredNotes = scoredNotes.merge(recentRatings, how="inner")
    peerNoteCounts = self._get_note_counts(
      scoredNotes, notes, noteStatusHistory, prepareForTraining
    )
    scoredNotes = scoredNotes.merge(peerNoteCounts, how="inner")
    # Generate features based on self and peer ratings
    for df, prefix in [
      (localRatings, _LOCAL),
      (peerMisleadingRatings, _PEER_MISLEADING),
      (peerNonMisleadingRatings, _PEER_NON_MISLEADING),
    ]:
      features = self._make_note_info_from_ratings(scoredNotes, df)
      overlapCols = (set(scoredNotes.columns) & set(features.columns)) - {c.noteIdKey}
      features = features[[col for col in features.columns if col not in overlapCols]]
      features = features.rename(
        columns={col: f"{prefix}_{col}" for col in features if col != c.noteIdKey}
      )
      scoredNotes = scoredNotes.merge(features, how="left")

    # Merge rating totals for debugging / info
    scoredNotes = scoredNotes.merge(
      localRatings[[c.noteIdKey]]
      .value_counts()
      .to_frame()
      .reset_index(drop=False)
      .rename(columns={"count": _LOCAL}),
      how="left",
    )
    scoredNotes = scoredNotes.merge(
      peerMisleadingRatings[[c.noteIdKey]]
      .value_counts()
      .to_frame()
      .reset_index(drop=False)
      .rename(columns={"count": _PEER_MISLEADING}),
      how="left",
    )
    scoredNotes = scoredNotes.merge(
      peerNonMisleadingRatings[[c.noteIdKey]]
      .value_counts()
      .to_frame()
      .reset_index(drop=False)
      .rename(columns={"count": _PEER_NON_MISLEADING}),
      how="left",
    )
    scoredNotes = scoredNotes.fillna(
      {_LOCAL: 0, _PEER_MISLEADING: 0, _PEER_NON_MISLEADING: 0}
    ).astype(
      {
        _LOCAL: pd.Int64Dtype(),
        _PEER_MISLEADING: pd.Int64Dtype(),
        _PEER_NON_MISLEADING: pd.Int64Dtype(),
      }
    )
    assert len(scoredNotes) == totalScoredNotes, f"{len(scoredNotes)} vs {totalScoredNotes}"
    return scoredNotes
Generate a DataFrame with one row per note containing all feature information.

    Note that some columns contain list values (e.g. a single column contains all
    Helpfulness ratings, where each rating is a unique string containing the rater ID
    and their Helpfulness rating).

    Args:
      notes: pd.DataFrame
      ratings: pd.DataFrame
      noteStatusHistory: pd.DataFrame
      prescoringRaterModelOutput: pd.DataFrame
      prepareForTraining: True if ratings should be filtered to discard data after the
        point of scoring to avoid skew.
      cutoff: Whether to prune ratings to those available when a note enters stabilization
        or gains CRH status.  None if prepareForTraining=False.

    Returns:
      pd.DataFrame containing all feature information with one row per note.
    
sourcecode/scoring/pflip_plus_model.py