R/olink_normalization_n.R

Defines functions olink_normalization_n

Documented in olink_normalization_n

#' Bridge and/or subset normalization of all proteins among multiple NPX
#' projects.
#'
#' @description
#' This function normalizes pairs of NPX projects (data frames) using shared
#' samples or subsets of samples.
#'
#' This function is a wrapper of olink_normalization_bridge and
#' olink_normalization_subset.
#'
#' @details
#' The input of this function is a tibble that contains all the necessary
#' information to normalize multiple NPX projects. This tibble is called the
#' normalization schema. The basic idea is that every row of the data frame is
#' a separate project to be normalized. We assume that there is always one
#' baseline project that does not normalize to any other. All other project
#' normalize to one or more projects. The function handles projects that are
#' normalized in a chain, for example:
#' \itemize{
#'    \item{1.} project 2 normalizes to project 1, and project 3 normalizes to
#'    project 2.
#'    \item{2.} project 2 normalizes to project 1, and project 3 normalizes to
#'    the combined data frame of projects 1 and 2 (that is already normalized).
#' }
#'
#' The function can also handle a mixed schema of bridge and subset
#' normalization.
#'
#' Specifications of the normalization schema data frame:
#' \itemize{
#'    \item{order:} should strictly be a numeric or integer array with unique
#'    identifiers for each project. It is necessary that this array starts from
#'    1 and that it contains no NAs.
#'    \item{name:} should strictly be a character array with unique identifiers
#'    for each project. Each entry should represent the name of the project
#'    located in the same row. No NAs are allowed.
#'    \item{data:} a named list of NPX data frames representing the projects to
#'    be normalized. Names of the items of the list should be identical to
#'    "names". No NAs are allowed.
#'    \item{samples:} a two-level nested named list of sample identifiers from
#'    each NPX project from "data". Names of the first level of the nested list
#'    should be identical to "names" and to the names of the list from "data".
#'    Projects that will be used only as reference should have their
#'    corresponding element in the list as NA, while all other projects should
#'    contain a named list of 2 arrays containing identifiers of samples to be
#'    used for the calculation of adjustment factor. The names of the two
#'    arrays should be DF1 and DF2 corresponding to the reference project and
#'    the project in the current row, respectively. For bridge normalization
#'    arrays should be of equal length and the index of each entry should
#'    correspond to the same sample. For subset normalization arrays do not
#'    need to be of equal length and the order the samples appear in does not
#'    matter. DF1 might contain sample identifiers from more than one project
#'    as long as the project in the current row is to be normalized to multiple
#'    other projects.
#'    \item{normalization_type:} a character array containing the flags "Bridge"
#'    or "Subset". Projects that will be used only as reference should have
#'    their corresponding element in the array as NA, while all other projects
#'    should contain a flag. For the time being the flag "Median" is not
#'    supported.
#'    \item{normalize_to:} a character array pointing to the project this
#'    project is to be normalized to. Elements of the array should be
#'    exclusively from the "order" column. Elements of the array may be
#'    comma-separated if the project is to be normalized to multiple projects.
#' }
#'
#' @param norm_schema A tibble with more than 1 rows and (strictly) the
#' following columns: "order", "name", "data", "samples", "normalization_type",
#' "normalize_to". See "Details" for the structure of the data frame
#' (required)
#'
#' @return A "tibble" of NPX data in long format containing normalized NPX
#' values, including adjustment factors and name of project.
#'
#' @export
#'
#' @keywords Normalization Bridge Subset n
#'
#' @examples
#' \donttest{
#' #### Bridge normalization of two projects
#'
#' # prepare datasets
#' npx_df1 <- npx_data1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#' npx_df2 <- npx_data2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#'
#' # Find overlapping samples, but exclude Olink control
#' overlap_samples <- dplyr::intersect(x = unique(npx_df1[["SampleID"]]),
#'                                     y = unique(npx_df2[["SampleID"]]))
#' overlap_samples_list <- list("DF1" = overlap_samples,
#'                              "DF2" = overlap_samples)
#'
#' # create tibble for input
#' norm_schema_bridge <- dplyr::tibble(
#'   order              = c(1L, 2L),
#'   name               = c("NPX_DF1", "NPX_DF2"),
#'   data               = list("NPX_DF1" = npx_df1,
#'                             "NPX_DF2" = npx_df2),
#'   samples            = list("NPX_DF1" = NA_character_,
#'                             "NPX_DF2" = overlap_samples_list),
#'   normalization_type = c(NA_character_, "Bridge"),
#'   normalize_to       = c(NA_character_, "1")
#' )
#'
#' # normalize
#' OlinkAnalyze::olink_normalization_n(
#'   norm_schema = norm_schema_bridge
#' )
#'
#' #### Subset normalization of two projects
#'
#' # datasets
#' npx_df1 <- npx_data1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#' npx_df2 <- npx_data2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#'
#' # Find a suitable subset of samples from both projects, but exclude Olink
#' # controls and samples that fail QC.
#' df1_samples <- npx_df1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::group_by(
#'     dplyr::across(
#'       dplyr::all_of("SampleID")
#'     )
#'   ) |>
#'   dplyr::filter(
#'     all(.data[["QC_Warning"]] == "Pass")
#'   ) |>
#'   dplyr::pull(
#'     .data[["SampleID"]]
#'   ) |>
#'   unique() |>
#'   sample(
#'     size = 16L,
#'     replace = FALSE
#'   )
#' df2_samples <- npx_df2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::group_by(
#'     dplyr::across(
#'       dplyr::all_of("SampleID")
#'     )
#'   ) |>
#'   dplyr::filter(
#'     all(.data[["QC_Warning"]] == "Pass")
#'   ) |>
#'   dplyr::pull(
#'     .data[["SampleID"]]
#'   ) |>
#'   unique() |>
#'   sample(
#'     size = 16L,
#'     replace = FALSE
#'   )
#'
#' # create named list
#' subset_samples_list <- list("DF1" = df1_samples,
#'                             "DF2" = df2_samples)
#'
#' # create tibble for input
#' norm_schema_subset <- dplyr::tibble(
#'   order              = c(1L, 2L),
#'   name               = c("NPX_DF1", "NPX_DF2"),
#'   data               = list("NPX_DF1" = npx_df1,
#'                             "NPX_DF2" = npx_df2),
#'   samples            = list("NPX_DF1" = NA_character_,
#'                             "NPX_DF2" = subset_samples_list),
#'   normalization_type = c(NA_character_, "Subset"),
#'   normalize_to       = c(NA_character_, "1")
#' )
#'
#' # Normalize
#' OlinkAnalyze::olink_normalization_n(
#'   norm_schema = norm_schema_subset
#' )
#'
#' #### Subset normalization  of two projects using all samples
#'
#' # datasets
#' npx_df1 <- npx_data1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#' npx_df2 <- npx_data2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#'
#' # Find a suitable subset of samples from both projects, but exclude Olink
#' # controls and samples that fail QC.
#' df1_samples_all <- npx_df1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::group_by(
#'     dplyr::across(
#'       dplyr::all_of("SampleID")
#'     )
#'   ) |>
#'   dplyr::filter(
#'     all(.data[["QC_Warning"]] == "Pass")
#'   ) |>
#'   dplyr::pull(
#'     .data[["SampleID"]]
#'   ) |>
#'   unique()
#' df2_samples_all <- npx_df2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::group_by(
#'     dplyr::across(
#'       dplyr::all_of("SampleID")
#'     )
#'   ) |>
#'   dplyr::filter(
#'     all(.data[["QC_Warning"]] == "Pass")
#'   ) |>
#'   dplyr::pull(
#'     .data[["SampleID"]]
#'   ) |>
#'   unique()
#'
#' # create named list
#' subset_samples_all_list <- list("DF1" = df1_samples_all,
#'                                 "DF2" = df2_samples_all)
#'
#' # create tibble for input
#' norm_schema_subset_all <- dplyr::tibble(
#'   order              = c(1L, 2L),
#'   name               = c("NPX_DF1", "NPX_DF2"),
#'   data               = list("NPX_DF1" = npx_df1,
#'                             "NPX_DF2" = npx_df2),
#'   samples            = list("NPX_DF1" = NA_character_,
#'                             "NPX_DF2" = subset_samples_all_list),
#'  normalization_type = c(NA_character_, "Subset"),
#'  normalize_to       = c(NA_character_, "1")
#' )
#'
#' # Normalize
#' OlinkAnalyze::olink_normalization_n(
#'   norm_schema = norm_schema_subset_all
#' )
#'
#' #### Multi-project normalization using bridge and subset samples
#'
#' ## NPX data frames to bridge
#' npx_df1 <- npx_data1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#' npx_df2 <- npx_data2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#'
#' # manipulating the sample NPX datasets to create another two random ones
#' npx_df3 <- npx_data2 |>
#'   dplyr::mutate(
#'     SampleID = paste(.data[["SampleID"]], "_mod", sep = ""),
#'     PlateID = paste(.data[["PlateID"]], "_mod", sep = ""),
#'     NPX = sample(x = .data[["NPX"]], size = dplyr::n(), replace = FALSE)
#'   ) |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#'
#' npx_df4 <- npx_data1 |>
#'   dplyr::mutate(
#'     SampleID = paste(.data[["SampleID"]], "_mod2", sep = ""),
#'     PlateID = paste(.data[["PlateID"]], "_mod2", sep = ""),
#'     NPX = sample(x = .data[["NPX"]], size = dplyr::n(), replace = FALSE)
#'   ) |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#'
#' ## samples to use for normalization
#' # Bridge samples with same identifiers between npx_df1 and npx_df2
#' overlap_samples <- dplyr::intersect(unique(npx_df1[["SampleID"]]),
#'                                     unique(npx_df2[["SampleID"]]))
#' overlap_samples_df1_df2 <- list("DF1" = overlap_samples,
#'                                 "DF2" = overlap_samples)
#'
#' # Bridge samples with different identifiers between npx_df2 and npx_df3
#' overlap_samples_df2_df3 <- list(
#'   "DF1" = sample(x = setdiff(x = unique(npx_df2[["SampleID"]]),
#'                              y = overlap_samples),
#'                  size = 10L,
#'                  replace = FALSE),
#'   "DF2" = sample(x = setdiff(x = unique(npx_df3[["SampleID"]]),
#'                              y = overlap_samples),
#'                  size = 10L,
#'                  replace = FALSE)
#' )
#'
#' # Samples to use for intensity normalization between npx_df4 and the
#' # normalized dataset of npx_df1 and npx_df2
#' overlap_samples_df12_df4 <- list(
#'   "DF1" = sample(
#'     x = c(unique(npx_df1[["SampleID"]]), unique(npx_df2[["SampleID"]])),
#'     size = 100L,
#'     replace = FALSE
#'   ) |>
#'     unique(),
#'   "DF2" = sample(
#'     x = unique(npx_df4[["SampleID"]]),
#'     size = 40L,
#'     replace = FALSE
#'   )
#' )
#'
#' # create tibble for input
#' norm_schema_n <- dplyr::tibble(
#'   order              = c(1L, 2L, 3L, 4L),
#'   name               = c("NPX_DF1", "NPX_DF2", "NPX_DF3", "NPX_DF4"),
#'   data               = list("NPX_DF1" = npx_df1,
#'                             "NPX_DF2" = npx_df2,
#'                             "NPX_DF3" = npx_df3,
#'                             "NPX_DF4" = npx_df4),
#'   samples            = list("NPX_DF1" = NA_character_,
#'                             "NPX_DF2" = overlap_samples_df1_df2,
#'                             "NPX_DF3" = overlap_samples_df2_df3,
#'                             "NPX_DF4" = overlap_samples_df12_df4),
#'   normalization_type = c(NA_character_, "Bridge", "Bridge", "Subset"),
#'   normalize_to       = c(NA_character_, "1", "2", "1,2")
#' )
#'
#' OlinkAnalyze::olink_normalization_n(
#'   norm_schema = norm_schema_n
#' )
#' }
#'
olink_normalization_n <- function(norm_schema) {
  # Upcoming updates/features:
  #   automatic detection of bridge samples
  #   automatic detection of samples for subset normalization
  #   check for control samples and warn the user
  #   Check that rows have all the information they need to work
  #   check that names, names of data and names of samples are identical

  # check that norm_schema has all the infor we need to proceed
  normalize_n_check <- olink_normalization_n_check(norm_schema = norm_schema)

  if (normalize_n_check != "TRUE") {
    stop(normalize_n_check)
  }
  rm(normalize_n_check)

  # create an empty list to store results
  normalized_npx <- list()

  # extract data for global reference project
  project_ref_global <- norm_schema |>
    dplyr::filter(.data[["order"]] == 1L) |>
    dplyr::pull(.data[["name"]])

  normalized_npx <- norm_schema |>
    dplyr::filter(.data[["order"]] == 1L) |>
    dplyr::pull(.data[["data"]])
  names(normalized_npx) <- project_ref_global

  normalized_npx[[project_ref_global]] <- normalized_npx[[project_ref_global]] |> # nolint: line_length_linter
    dplyr::mutate(Project = .env[["project_ref_global"]],
                  Adj_factor = 0L)

  # extract remaining batches
  project_remaining <- norm_schema |>
    dplyr::filter(.data[["order"]] != 1L) |>
    dplyr::pull(.data[["order"]])

  # replace original dataset with its name to save memory
  # this way we do not keep two copies of the same dataset
  # the copy we need is under the normalized_npx list
  norm_schema$data[[project_ref_global]] <- project_ref_global

  # for each remaining batch run one normalization
  for (project_i in project_remaining) {

    # extract the next project to be normalized
    project_i_row <- norm_schema |>
      dplyr::filter(.data[["order"]] == .env[["project_i"]])

    # non-reference npx df
    project_i_npx <- norm_schema |>
      dplyr::filter(.data[["order"]] == .env[["project_i"]]) |>
      dplyr::pull(.data[["data"]]) |>
      unname() |>
      dplyr::bind_rows()

    # extract name and type of normalization to apply
    project_i_name <- project_i_row$name
    project_i_normalization_type <- project_i_row$normalization_type

    # extract names of projects to normalize to
    project_i_norm_to <- strsplit(x = project_i_row$normalize_to,
                                  split = ",",
                                  fixed = TRUE) |>
      unlist()

    project_ref_names <- norm_schema |>
      dplyr::filter(.data[["order"]] %in% .env[["project_i_norm_to"]]) |>
      dplyr::pull(.data[["name"]])

    project_ref_names_combo <- paste(project_ref_names,
                                     collapse = ",")

    # reference NPX dataset
    if (length(project_i_norm_to) > 1L) {
      project_ref_npx <- normalized_npx[project_ref_names] |>
        dplyr::bind_rows()
    } else {
      project_ref_npx <- normalized_npx[[project_ref_names]]
    }
    # drop these two columns as they will be reassigned from
    # the normalization function
    project_ref_npx <- project_ref_npx |>
      dplyr::select(
        -dplyr::any_of(
          c("Project", "Adj_factor")
        )
      )

    samples_norm <- project_i_row$samples |>
      unname() |>
      unlist(recursive = FALSE)

    # normalize
    if (project_i_normalization_type == "Subset") {
      project_i_npx_norm <- olink_normalization_subset(
        project_1_df = project_ref_npx,
        project_2_df = project_i_npx,
        reference_samples = samples_norm,
        project_1_name = project_ref_names_combo,
        project_2_name = project_i_name,
        project_ref_name = project_ref_names_combo
      )
    } else if (project_i_normalization_type == "Bridge") {
      project_i_npx_norm <- olink_normalization_bridge(
        project_1_df = project_ref_npx,
        project_2_df = project_i_npx,
        bridge_samples = samples_norm,
        project_1_name = project_ref_names_combo,
        project_2_name = project_i_name,
        project_ref_name = project_ref_names_combo
      )

      # remove bridge samples from non-reference dataset
      project_i_npx_norm <- project_i_npx_norm |>
        dplyr::anti_join(
          project_i_npx |>
            dplyr::filter(.data[["SampleID"]] %in% samples_norm$DF2) |>
            dplyr::mutate(Project = .env[["project_i_name"]]) |>
            dplyr::distinct(.data[["SampleID"]], .data[["Project"]]),
          by = c("SampleID", "Project")
        )

      cli::cli_inform(
        "Removed {.val {length(samples_norm$DF2)}} bridge samples from the
        bridge normalized dataset between {.val {project_ref_names_combo}} and
        {.val {project_i_name}}."
      )
    }

    # add to main list of normalized npx datasets
    normalized_npx[[project_i_name]] <- project_i_npx_norm |>
      dplyr::filter(.data[["Project"]] != .env[["project_ref_names_combo"]])

    # replace original dataset with its name to save memory
    # this way we do not keep two copies of the same dataset
    # the copy we need is under the normalized_npx list
    norm_schema$data[[project_i_name]] <- project_i_name

    # cleanup to make sure that we reset variables and receive relevant errors
    rm(project_i_row, project_i_npx, project_i_name,
       project_i_normalization_type, project_i_norm_to, project_ref_names,
       project_ref_names_combo, project_ref_npx, samples_norm,
       project_i_npx_norm)
  }

  normalized_npx <- dplyr::bind_rows(normalized_npx)

  # update maxlod if present
  normalized_npx <- norm_internal_update_maxlod(
    df = normalized_npx,
    cols = check_npx(df = normalized_npx) |>
      suppressMessages() |>
      suppressWarnings() |>
      (\(.) .$col_names)()
  )

  return(normalized_npx)
}

#' Bridge normalization of all proteins between two NPX projects.
#'
#' @description
#' Normalizes two NPX projects (data frames) using shared samples.
#'
#' This function is a wrapper of olink_normalization.
#'
#' @details
#' In bridging normalization one of the projects is adjusted to another using
#' shared samples (bridge samples). It is not necessary for the shared
#' samples to be named the same in each project. Adjustment between the two
#' projects is made using the median of the paired differences between the
#' shared samples. The two data frames are inputs project_1_df and project_2_df,
#' the one being adjusted to is specified in the input project_ref_name and the
#' shared samples are specified in bridge_samples.
#'
#' @param project_1_df Data frame of the first project (required).
#' @param project_2_df Data frame of the second project (required).
#' @param bridge_samples Named list of 2 arrays containing SampleID of shared
#' samples to be used for the calculation of adjustment factor. The
#' names of the two arrays should be DF1 and DF2 corresponding to projects 1
#' and 2, respectively. Arrays should be of equal length and index of each entry
#' should correspond to the same sample. (required)
#' @param project_1_name Name of the first project (default: P1).
#' @param project_2_name Name of the second project (default: P2).
#' @param project_ref_name Name of the project to be used as reference set.
#' Needs to be one of the project_1_name or project_2_name. It marks the
#' project to which the other project will be adjusted to (default: P1).
#' @param format Boolean that controls whether the normalized dataset will be
#' formatted for input to downstream analysis.
#' @param project_1_check_log A named list returned by [`check_npx()`]. If
#' `NULL`, [`check_npx()`] will be run internally using `project_1_df`.
#' (default: `NULL`)
#' @param project_2_check_log A named list returned by [`check_npx()`]. If
#' `NULL`, [`check_npx()`] will be run internally using `project_2_df`.
#' (default: `NULL`)
#'
#' @return A "tibble" of NPX data in long format containing normalized NPX
#' values, including adjustment factors and name of project.
#'
#' @export
#'
#' @keywords Normalization Bridge
#'
#' @examples
#' \donttest{
#' # prepare datasets
#' npx_df1 <- npx_data1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#' npx_df2 <- npx_data2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#'
#' # Find overlapping samples, but exclude Olink control
#' overlap_samples <- dplyr::intersect(x = unique(npx_df1[["SampleID"]]),
#'                                     y = unique(npx_df2[["SampleID"]]))
#' overlap_samples_list <- list("DF1" = overlap_samples,
#'                              "DF2" = overlap_samples)
#'
#' # check npx
#' df1_check_log <- OlinkAnalyze::check_npx(df = npx_df1)
#' df2_check_log <- OlinkAnalyze::check_npx(df = npx_df2)
#'
#' # Normalize
#' OlinkAnalyze::olink_normalization_bridge(
#'   project_1_df = npx_df1,
#'   project_2_df = npx_df2,
#'   bridge_samples = overlap_samples_list,
#'   project_1_name = "P1",
#'   project_2_name = "P2",
#'   project_ref_name = "P1",
#'   project_1_check_log = df1_check_log,
#'   project_2_check_log = df2_check_log
#' )
#' }
#'
olink_normalization_bridge <- function(project_1_df,
                                       project_2_df,
                                       bridge_samples,
                                       project_1_name = "P1",
                                       project_2_name = "P2",
                                       project_ref_name = "P1",
                                       format = FALSE,
                                       project_1_check_log = NULL,
                                       project_2_check_log = NULL) {

  check_project_name <- olink_normalization_project_name_check(
    project_1_name = project_1_name,
    project_2_name = project_2_name,
    project_ref_name = project_ref_name
  )
  if (check_project_name != "TRUE") {
    stop(check_project_name)
  }
  rm(check_project_name)

  check_bridge_samples <- olink_normalization_sample_check(
    list_samples = bridge_samples,
    check_mode = "bridge",
    project_1_all_samples = unique(project_1_df$SampleID),
    project_2_all_samples = unique(project_2_df$SampleID)
  )
  if (check_bridge_samples != "TRUE") {
    stop(check_bridge_samples)
  }
  rm(check_bridge_samples)

  # place bridge samples side by side in a data frame
  update_sampleid <- dplyr::bind_cols(bridge_samples) |>
    dplyr::rename("SampleID_df1" = "DF1",
                  "SampleID_df2" = "DF2")

  # change the SampleID of the non-reference data frame to match the bridging
  # samples from the reference data frame. This is done because the
  # OlinkAnalyze::olink_normalization function requires so.
  project_2_df <- project_2_df |>
    dplyr::left_join(update_sampleid, by = c("SampleID" = "SampleID_df2")) |>
    dplyr::mutate(SampleID_df1 = dplyr::if_else(is.na(.data[["SampleID_df1"]]),
                                                .data[["SampleID"]],
                                                .data[["SampleID_df1"]])) |>
    dplyr::select(-dplyr::all_of("SampleID")) |>
    dplyr::rename("SampleID" = "SampleID_df1")

  # bridge normalize the two data frames
  norm_df <- olink_normalization(
    df1 = project_1_df,
    df2 = project_2_df,
    overlapping_samples_df1 = bridge_samples$DF1,
    overlapping_samples_df2 = NULL,
    df1_project_nr = project_1_name,
    df2_project_nr = project_2_name,
    reference_project = project_ref_name,
    reference_medians = NULL,
    format = format,
    df1_check_log = project_1_check_log,
    df2_check_log = project_2_check_log
  )

  # switch back to the original non-reference project's SampleID
  norm_df <- norm_df |>
    dplyr::left_join(update_sampleid, by = c("SampleID" = "SampleID_df1")) |>
    dplyr::mutate(
      SampleID_df2 = dplyr::case_when(
        is.na(.data[["SampleID_df2"]]) ~ .data[["SampleID"]],
        !is.na(.data[["SampleID_df2"]]) & .data[["Project"]] == .env[["project_ref_name"]] ~ .data[["SampleID"]], # nolint: line_length_linter
        !is.na(.data[["SampleID_df2"]]) & .data[["Project"]] != .env[["project_ref_name"]] ~ .data[["SampleID_df2"]], # nolint: line_length_linter
        TRUE ~ NA_character_
      )
    ) |>
    dplyr::select(-dplyr::all_of("SampleID")) |>
    dplyr::rename("SampleID" = "SampleID_df2") |>
    dplyr::select(dplyr::all_of(names(norm_df)))
  rm(update_sampleid)

  return(norm_df)
}

#' Subset normalization of all proteins between two NPX projects.
#'
#' @description
#' Normalizes two NPX projects (data frames) using all or a subset of samples.
#'
#' This function is a wrapper of olink_normalization.
#'
#' @details
#' In subset normalization one of the projects is adjusted to another using
#' a subset of all samples from each. Please note that the subsets of samples
#' are not expected to be replicates of each other or to have the SampleID.
#' Adjustment between the two projects is made using the assay-specific
#' differences in median between the subsets of samples from the two projects.
#' The two data frames are inputs project_1_df and project_2_df, the one being
#' adjusted to is specified in the input project_ref_name and the shared
#' samples are specified in reference_samples.
#'
#' A special case of subset normalization is to use all samples (except control
#' samples) from each project as a subset.
#'
#' @param project_1_df Data frame of the first project (required).
#' @param project_2_df Data frame of the second project (required).
#' @param reference_samples Named list of 2 arrays containing SampleID of the
#' subset of samples to be used for the calculation of median NPX within each
#' project. The names of the two arrays should be DF1 and DF2 corresponding to
#' projects 1 and 2, respectively. Arrays do not need to be of equal length and
#' the order the samples appear in does not play any role. (required)
#' @param project_1_name Name of the first project (default: P1).
#' @param project_2_name Name of the second project (default: P2).
#' @param project_ref_name Name of the project to be used as reference set.
#' Needs to be one of the project_1_name or project_2_name. It marks the
#' project to which the other project will be adjusted to (default: P1).
#' @param format Boolean that controls whether the normalized dataset will be
#' formatted for input to downstream analysis.
#' @param project_1_check_log A named list returned by [`check_npx()`]. If
#' `NULL`, [`check_npx()`] will be run internally using `project_1_df`.
#' (default: `NULL`)
#' @param project_2_check_log A named list returned by [`check_npx()`]. If
#' `NULL`, [`check_npx()`] will be run internally using `project_2_df`.
#' (default: `NULL`)
#'
#' @return A "tibble" of NPX data in long format containing normalized NPX
#' values, including adjustment factors and name of project.
#'
#' @export
#'
#' @keywords Normalization Subset
#'
#' @examples
#' \donttest{
#' #### Subset normalization
#'
#' # datasets
#' npx_df1 <- npx_data1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#' npx_df2 <- npx_data2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#'
#' # Find a suitable subset of samples from both projects, but exclude Olink
#' # controls and samples that fail QC.
#' df1_samples <- npx_df1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::group_by(
#'     dplyr::across(
#'       dplyr::all_of("SampleID")
#'     )
#'   ) |>
#'   dplyr::filter(
#'     all(.data[["QC_Warning"]] == "Pass")
#'   ) |>
#'   dplyr::pull(
#'     .data[["SampleID"]]
#'   ) |>
#'   unique() |>
#'   sample(
#'     size = 16L,
#'     replace = FALSE
#'   )
#' df2_samples <- npx_df2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::group_by(
#'     dplyr::across(
#'       dplyr::all_of("SampleID")
#'     )
#'   ) |>
#'   dplyr::filter(
#'     all(.data[["QC_Warning"]] == "Pass")
#'   ) |>
#'   dplyr::pull(
#'     .data[["SampleID"]]
#'   ) |>
#'   unique() |>
#'   sample(
#'     size = 16L,
#'     replace = FALSE
#'   )
#'
#' # create named list
#' subset_samples_list <- list("DF1" = df1_samples,
#'                             "DF2" = df2_samples)
#'
#' # check npx
#' df1_check_log <- OlinkAnalyze::check_npx(df = npx_df1)
#' df2_check_log <- OlinkAnalyze::check_npx(df = npx_df2)
#'
#' # Normalize
#' OlinkAnalyze::olink_normalization_subset(
#'   project_1_df = npx_df1,
#'   project_2_df = npx_df2,
#'   reference_samples = subset_samples_list,
#'   project_1_name = "P1",
#'   project_2_name = "P2",
#'   project_ref_name = "P1",
#'   project_1_check_log = df1_check_log,
#'   project_2_check_log = df2_check_log
#' )
#'
#'
#' #### Special case of subset normalization using all samples
#'
#' # datasets
#' npx_df1 <- npx_data1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#' npx_df2 <- npx_data2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::select(
#'     -dplyr::all_of("Project")
#'   ) |>
#'   dplyr::mutate(
#'     Normalization = "Intensity"
#'   )
#'
#' # Find a suitable subset of samples from both projects, but exclude Olink
#' # controls and samples that fail QC.
#' df1_samples_all <- npx_df1 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::group_by(
#'     dplyr::across(
#'       dplyr::all_of("SampleID")
#'     )
#'   ) |>
#'   dplyr::filter(
#'     all(.data[["QC_Warning"]] == "Pass")
#'   ) |>
#'   dplyr::pull(
#'     .data[["SampleID"]]
#'   ) |>
#'   unique()
#' df2_samples_all <- npx_df2 |>
#'   dplyr::filter(
#'     !stringr::str_detect(string = .data[["SampleID"]],
#'                          pattern = "CONTROL_")
#'   ) |>
#'   dplyr::group_by(
#'     dplyr::across(
#'       dplyr::all_of("SampleID")
#'     )
#'   ) |>
#'   dplyr::filter(
#'     all(.data[["QC_Warning"]] == "Pass")
#'   ) |>
#'   dplyr::pull(
#'     .data[["SampleID"]]
#'   ) |>
#'   unique()
#'
#' # create named list
#' subset_samples_all_list <- list("DF1" = df1_samples_all,
#'                                 "DF2" = df2_samples_all)
#'
#' # check npx
#' df1_check_log <- OlinkAnalyze::check_npx(df = npx_df1)
#' df2_check_log <- OlinkAnalyze::check_npx(df = npx_df2)
#'
#' # Normalize
#' OlinkAnalyze::olink_normalization_subset(
#'   project_1_df = npx_df1,
#'   project_2_df = npx_df2,
#'   reference_samples = subset_samples_all_list,
#'   project_1_name = "P1",
#'   project_2_name = "P2",
#'   project_ref_name = "P1",
#'   project_1_check_log = df1_check_log,
#'   project_2_check_log = df2_check_log
#' )
#' }
#'
olink_normalization_subset <- function(project_1_df,
                                       project_2_df,
                                       reference_samples,
                                       project_1_name = "P1",
                                       project_2_name = "P2",
                                       project_ref_name = "P1",
                                       format = FALSE,
                                       project_1_check_log = NULL,
                                       project_2_check_log = NULL) {

  check_project_name <- olink_normalization_project_name_check(
    project_1_name = project_1_name,
    project_2_name = project_2_name,
    project_ref_name = project_ref_name
  )
  if (check_project_name != "TRUE") {
    stop(check_project_name)
  }
  rm(check_project_name)

  check_reference_samples <- olink_normalization_sample_check(
    list_samples = reference_samples,
    check_mode = "subset",
    project_1_all_samples = unique(project_1_df$SampleID),
    project_2_all_samples = unique(project_2_df$SampleID)
  )
  if (check_reference_samples != "TRUE") {
    stop(check_reference_samples)
  }
  rm(check_reference_samples)

  norm_df <- olink_normalization(
    df1 = project_1_df,
    df2 = project_2_df,
    overlapping_samples_df1 = reference_samples$DF1,
    overlapping_samples_df2 = reference_samples$DF2,
    df1_project_nr = project_1_name,
    df2_project_nr = project_2_name,
    reference_project = project_ref_name,
    reference_medians = NULL,
    format = format,
    df1_check_log = project_1_check_log,
    df2_check_log = project_2_check_log
  )

  return(norm_df)
}

#' An internal function to perform checks on the input samples in the functions
#' olink_normalization_bridge and olink_normalization_subset. The function is
#' expected to run all checks on SampleID to make sure that normalization can
#' be performed smoothly. It should work independently of the function calling
#' it.
#'
#' @param list_samples Named list of 2 arrays containing SampleID of the
#' subset or bridge samples to be used for normalization. The names of the two
#' arrays should be DF1 and DF2 corresponding to projects 1 and 2, respectively.
#' (required)
#' @param check_mode Flag "bridge" or "subset" indicating the type of
#' normalization the check should be tailored to (required)
#' @param project_1_all_samples Array of all samples from project 1 (required)
#' @param project_2_all_samples Array of all samples from project 2 (required)
#'
#' @return a character message. If the message is "TRUE" then all checks passed,
#' otherwise an error message will be printed.
#'
#' @keywords internal
#'
olink_normalization_sample_check <- function(list_samples, # nolint: object_length_linter
                                             check_mode,
                                             project_1_all_samples,
                                             project_2_all_samples) {
  if (!(check_mode %in% c("bridge", "subset"))) {
    stop("\"check_mode\" should be \"bridge\" or \"subset\"")
  }

  if (check_mode == "bridge") {
    message_text <- "bridge_samples"
  } else if (check_mode == "subset") {
    message_text <- "reference_samples"
  }

  # check list_samples
  if (!is.list(list_samples)) {
    return(
      paste(message_text,
            "should be a list")
    )
  }

  if (length(list_samples) != 2L) {
    return(
      paste("length of ",
            message_text,
            " should exactly 2",
            sep = "")
    )
  }

  if (!identical(sort(names(list_samples)), c("DF1", "DF2"))) {
    return(
      paste(message_text,
            " should be a named list with names \"DF2\" and \"DF1\" referring",
            " to the data frame on this row and the one to normalize to,",
            " respectively",
            sep = "")
    )
  }

  if (!all(sapply(list_samples, is.character))) {
    return(
      paste(message_text,
            " should contain exclusively character vectors",
            sep = "")
    )
  }

  if (any(is.na(unlist(list_samples)))) {
    return(
      paste("character vectors in ",
            message_text,
            " should not contain NAs",
            sep = "")
    )
  }

  if (!all(list_samples$DF1 %in% project_1_all_samples)) {
    return(
      paste("not all SampleID in ",
            message_text,
            "$DF1 are present in \"project_1_df\"",
            sep = "")
    )
  }

  if (!all(list_samples$DF2 %in% project_2_all_samples)) {
    return(
      paste("not all SampleID in ",
            message_text,
            "$DF2 are present in \"project_2_df\"",
            sep = "")
    )
  }

  if (check_mode == "bridge") {
    if (length(unique(sapply(list_samples, length))) != 1L) {
      return(
        paste("character vectors in ",
              message_text,
              " should have the same length",
              sep = "")
      )
    }
  }

  return("TRUE")
}

#' An internal function to perform checks on the input project names in the
#' functions olink_normalization_bridge and olink_normalization_subset. The
#' function is expected to run all checks on project names to make sure that
#' normalization can be performed smoothly. It should work independently of the
#' function calling it.
#'
#' @param project_1_name Name of project 1 (required)
#' @param project_2_name Name of project 2 (required)
#' @param project_ref_name  Name of reference project (required)
#'
#' @return a character message. If the message is "TRUE" then all checks passed,
#' otherwise an error message will be printed.
#'
#' @keywords internal
#'
olink_normalization_project_name_check <- function(project_1_name, # nolint: object_length_linter
                                                   project_2_name,
                                                   project_ref_name) {

  project_names <- c(project_1_name,
                     project_2_name,
                     project_ref_name)

  # check all project names together
  if (!is.character(project_names)) {
    return(
      paste("\"project_1_name\", \"project_2_name\" and \"project_ref_name\"",
            " should be a character vectors",
            sep = "")
    )
  }

  if (length(project_names) != 3L) {
    return(
      paste("\"project_1_name\", \"project_2_name\" and \"project_ref_name\"",
            " should be a character vectors of length 1 each",
            sep = "")
    )
  }

  if (any(is.na(project_names))) {
    return(
      paste("\"project_1_name\", \"project_2_name\" or \"project_ref_name\"",
            " should not be NA",
            sep = "")
    )
  }

  # sanity check for individual project names
  if (project_1_name == project_2_name) {
    return(
      paste("\"project_1_name\" and \"project_2_name\" should differ from each",
            " other",
            sep = "")
    )
  }

  if (!(project_ref_name %in% c(project_1_name, project_2_name))) {
    return(
      paste("\"project_ref_name\" should be one of the \"project_1_name\" or",
            " \"project_2_name\"",
            sep = "")
    )
  }

  return("TRUE")
}

#' An internal function to perform checks on the input of the function
#' olink_normalization_n.
#'
#' @param norm_schema A tibble with more than 1 rows and (strictly) the
#' following columns: "order", "name", "data", "samples", "normalization_type",
#' "normalize_to". See above for details of the structure of the data frame. See
#' details in help for olink_normalization_n. (required)
#'
#' @return a character message. If the message is "TRUE" then all checks passed,
#' otherwise an error message will be printed.
#'
#' @keywords internal
#'
olink_normalization_n_check <- function(norm_schema) {

  # check input
  input_colnames <- c("order",
                      "name",
                      "data",
                      "samples",
                      "normalization_type",
                      "normalize_to")
  if (!all(input_colnames %in% colnames(norm_schema))) {
    miss_cols <- input_colnames[!input_colnames %in% colnames(norm_schema)]

    if (length(miss_cols == 1L)) {
      miss_cols_print <- miss_cols
    } else {
      miss_cols_print <- miss_cols |>
        utils::head(-1L) |>
        paste(collapse = ", ") |>
        paste("and", utils::tail(x = miss_cols, n = -1L))
    }

    return(
      paste("norm_schema input is missing columns ",
            miss_cols_print,
            sep = "")
    )
  }

  # Check order
  if (any(is.na(norm_schema$order))
      || any(is.infinite(norm_schema$order))) {
    return("order cannot contain NA and/or infinities!")
  }

  if (!is.numeric(norm_schema$order)
      && !is.integer(norm_schema$order)) {
    return("order has to be numeric or integer vector!")
  }

  if (!identical(as.integer(sort(norm_schema$order)),
                 seq_len(nrow(norm_schema)))) {
    return(
      paste("order has to be a sequence of unique integer or numeric",
            " identifiers of each row starting from 1. The sequence has to",
            " increase by 1",
            sep = "")
    )
  }

  # Check data
  if (!is.list(norm_schema$data)
      || !all(sapply(norm_schema$data, is.data.frame))) {
    return("data has to be a list of NPX data frames!")
  }

  if (any(sapply(norm_schema$data, function(x) "Project" %in% colnames(x)))) {
    check_project <- norm_schema$data |>
      sapply(function(x) "Project" %in% colnames(x)) |>
      names()

    if (length(check_project) == 1L) {
      check_project_print <- check_project
    } else {
      check_project_print <- check_project |>
        utils::head(-1L) |>
        paste(collapse = ", ") |>
        paste("and", utils::tail(x = check_project, n = -1L))
    }

    return(
      paste("datsets ",
            check_project_print,
            " contain the column \"Project\". Please remove and rerun!",
            sep = "")
    )
  }

  if (any(sapply(norm_schema$data, function(x) "Adj_factor" %in% colnames(x)))) { # nolint: line_length_linter
    check_project <- norm_schema$data |>
      sapply(function(x) "Adj_factor" %in% colnames(x)) |>
      names()

    if (length(check_project) == 1L) {
      check_project_print <- check_project
    } else {
      check_project_print <- check_project |>
        utils::head(-1L) |>
        paste(collapse = ", ") |>
        paste("and", utils::tail(x = check_project, n = -1L))
    }

    return(
      paste("datsets ",
            check_project_print,
            " contain the column \"Adj_factor\". Please remove and rerun!",
            sep = "")
    )
  }

  # Check samples
  if (!is.list(norm_schema$samples)) {
    return("samples has to be a list of character vectors!")
  }

  # Check normalization_type
  if (!is.character(norm_schema$normalization_type)) {
    return("normalization_type has to be a character vector!")
  }

  if (!all(norm_schema$normalization_type %in% c("Subset",
                                                 "Bridge",
                                                 NA))) {
    return("normalization_type may contain Subset, Bridge or NA!")
  }

  # Check normalize_to
  if (!is.character(norm_schema$normalize_to)) {
    return("normalize_to has to be a character array!")
  }

  if (!all(sort(as.integer(unique(unlist(strsplit(x = norm_schema$normalize_to, split = ",", fixed = TRUE))))) %in% as.integer(sort(norm_schema$order)))) { # nolint: line_length_linter
    return("all elements from normalize_to have to be a present in \"order\"!")
  }

  order_and_norm_to <- lapply(seq_len(nrow(norm_schema)), function(i) {
    order_tmp <- norm_schema |>
      dplyr::slice(i) |>
      dplyr::pull(order)

    norm_to_tmp <- norm_schema |>
      dplyr::slice(i) |>
      dplyr::pull(.data[["normalize_to"]]) |>
      strsplit(split = ",",
               fixed = TRUE) |>
      unlist()

    if (order_tmp %in% norm_to_tmp) {
      return(order_tmp)
    } else {
      return(NULL)
    }
  }) |>
    unlist()
  if (!is.null(order_and_norm_to)
      && length(order_and_norm_to) > 0L) {
    if (length(order_and_norm_to) == 1L) {
      order_and_norm_to_print <- order_and_norm_to
    } else {
      order_and_norm_to_print <- order_and_norm_to |>
        utils::head(-1L) |>
        paste(collapse = ", ") |>
        paste("and", utils::tail(x = order_and_norm_to, n = -1L))
    }

    return(
      paste("entries with order column identifiers ",
            order_and_norm_to_print,
            " contain their own identifier in the normalize_to column. This",
            " implies normalization to self. Remove self-references!",
            sep = "")
    )
  }

  return("TRUE")
}

Try the OlinkAnalyze package in your browser

Any scripts or data that you put into this service are public.

OlinkAnalyze documentation built on June 24, 2026, 1:06 a.m.