From 8ee335913c7ce7d03b2fdc04b2ff5cd41c190ab9 Mon Sep 17 00:00:00 2001 From: David Rach Date: Sun, 31 May 2026 21:41:23 -0400 Subject: [PATCH] Homework folders week 10 and 11 (thanks Lynn!), plus extended version for Concatenate. --- Schedule.qmd | 39 +- _quarto.yml | 4 + course/10_Downsampling/concatenate.qmd | 2199 ++++++++++++++- course/10_Downsampling/homeworks/README.md | 5 + course/10_Downsampling/images/TakeAway2.jpg | Bin 0 -> 48316 bytes course/11_DataForStats/data/AllThePlots.pdf | Bin 25218 -> 25218 bytes course/11_DataForStats/data/Patchwork.pdf | Bin 24951 -> 24951 bytes course/11_DataForStats/homeworks/README.md | 5 + docs/Schedule.html | 40 +- .../00_BonusContent/Immport/images/index.html | 19 +- .../PullConflicts/UpdatedPullRequest.html | 19 +- .../00_BonusContent/PullConflicts/index.html | 19 +- docs/course/00_BonusContent/index.html | 19 +- docs/course/00_Floreada/index.html | 19 +- docs/course/00_Git/index.html | 19 +- docs/course/00_GitHub/index.html | 19 +- docs/course/00_Homeworks/index.html | 19 +- docs/course/00_Positron/index.html | 19 +- docs/course/00_Quarto/index.html | 19 +- docs/course/00_WorkstationSetup/Linux.html | 19 +- docs/course/00_WorkstationSetup/MacOS.html | 19 +- docs/course/00_WorkstationSetup/Windows.html | 19 +- docs/course/00_WorkstationSetup/index.html | 19 +- docs/course/01_InstallingRPackages/index.html | 19 +- docs/course/02_FilePaths/Downsampler.html | 19 +- docs/course/02_FilePaths/index.html | 19 +- docs/course/03_InsideFCSFile/index.html | 27 +- docs/course/03_InsideFCSFile/slides.html | 8 +- .../04_IntroToTidyverse/BonusContent.html | 19 +- docs/course/04_IntroToTidyverse/index.html | 19 +- docs/course/05_GatingSets/Downsampling.html | 19 +- docs/course/05_GatingSets/index.html | 31 +- docs/course/05_GatingSets/slides.html | 10 +- docs/course/06_Visualizing/index.html | 19 +- docs/course/07_Transformations/index.html | 39 +- .../figure-html/unnamed-chunk-61-1.png | Bin 50677 -> 51809 bytes .../figure-html/unnamed-chunk-66-1.png | Bin 107237 -> 106552 bytes .../figure-html/unnamed-chunk-69-1.png | Bin 107494 -> 107537 bytes .../figure-html/unnamed-chunk-71-1.png | Bin 106283 -> 107854 bytes .../figure-html/unnamed-chunk-72-1.png | Bin 113278 -> 112657 bytes .../figure-html/unnamed-chunk-73-1.png | Bin 82106 -> 81764 bytes .../figure-html/unnamed-chunk-74-1.png | Bin 100858 -> 101437 bytes .../figure-html/unnamed-chunk-75-1.png | Bin 110379 -> 110832 bytes .../figure-html/unnamed-chunk-76-1.png | Bin 107512 -> 107813 bytes .../figure-html/unnamed-chunk-77-1.png | Bin 109114 -> 107986 bytes .../figure-html/unnamed-chunk-78-1.png | Bin 106549 -> 107740 bytes .../figure-html/unnamed-chunk-79-1.png | Bin 109329 -> 108608 bytes docs/course/07_Transformations/slides.html | 16 +- .../figure-revealjs/unnamed-chunk-100-1.png | Bin 118922 -> 118738 bytes .../figure-revealjs/unnamed-chunk-102-1.png | Bin 120700 -> 120805 bytes .../figure-revealjs/unnamed-chunk-74-1.png | Bin 55888 -> 57687 bytes .../figure-revealjs/unnamed-chunk-79-1.png | Bin 119201 -> 119150 bytes .../figure-revealjs/unnamed-chunk-83-1.png | Bin 119965 -> 118363 bytes .../figure-revealjs/unnamed-chunk-86-1.png | Bin 119204 -> 119636 bytes .../figure-revealjs/unnamed-chunk-88-1.png | Bin 126427 -> 125133 bytes .../figure-revealjs/unnamed-chunk-90-1.png | Bin 91857 -> 91974 bytes .../figure-revealjs/unnamed-chunk-92-1.png | Bin 110014 -> 110201 bytes .../figure-revealjs/unnamed-chunk-94-1.png | Bin 123949 -> 125506 bytes .../figure-revealjs/unnamed-chunk-96-1.png | Bin 119472 -> 119589 bytes .../figure-revealjs/unnamed-chunk-98-1.png | Bin 122034 -> 121043 bytes .../07_Transformations/slides_inperson.html | 16 +- .../figure-revealjs/unnamed-chunk-60-1.png | Bin 57266 -> 62880 bytes .../figure-revealjs/unnamed-chunk-65-1.png | Bin 118417 -> 119246 bytes .../figure-revealjs/unnamed-chunk-69-1.png | Bin 119768 -> 119575 bytes .../figure-revealjs/unnamed-chunk-72-1.png | Bin 119972 -> 118932 bytes .../figure-revealjs/unnamed-chunk-74-1.png | Bin 125596 -> 125220 bytes .../figure-revealjs/unnamed-chunk-76-1.png | Bin 93223 -> 91300 bytes .../figure-revealjs/unnamed-chunk-78-1.png | Bin 110081 -> 111913 bytes .../figure-revealjs/unnamed-chunk-80-1.png | Bin 124842 -> 124224 bytes .../figure-revealjs/unnamed-chunk-82-1.png | Bin 118786 -> 118554 bytes .../figure-revealjs/unnamed-chunk-84-1.png | Bin 120087 -> 119929 bytes .../figure-revealjs/unnamed-chunk-86-1.png | Bin 119413 -> 119805 bytes .../figure-revealjs/unnamed-chunk-88-1.png | Bin 120375 -> 121732 bytes docs/course/08_WaysToGate/GStoFlowJo.html | 19 +- docs/course/08_WaysToGate/index.html | 19 +- docs/course/09_Functions/index.html | 23 +- docs/course/09_Functions/slides.html | 4 +- docs/course/10_Downsampling/concatenate.html | 2469 ++++++++++++++++- .../figure-html/unnamed-chunk-4-1.png | Bin 0 -> 29281 bytes .../10_Downsampling/images/TakeAway2.jpg | Bin 0 -> 48316 bytes docs/course/10_Downsampling/index.html | 227 +- docs/course/10_Downsampling/slides.html | 208 +- docs/course/11_DataForStats/index.html | 19 +- .../figure-html/unnamed-chunk-11-1.png | Bin 152375 -> 152422 bytes docs/course/TakeAwayGallery.html | 19 +- docs/course/community/AutoSpectral/index.html | 29 +- docs/course/community/TRU-OLS/FCSCleanUp.html | 19 +- .../community/TRU-OLS/InstallingJulia.html | 19 +- docs/course/community/TRU-OLS/index.html | 23 +- docs/course/community/index.html | 19 +- docs/course/index.html | 19 +- docs/search.json | 253 +- 92 files changed, 5867 insertions(+), 359 deletions(-) create mode 100644 course/10_Downsampling/homeworks/README.md create mode 100644 course/10_Downsampling/images/TakeAway2.jpg create mode 100644 course/11_DataForStats/homeworks/README.md create mode 100644 docs/course/10_Downsampling/concatenate_files/figure-html/unnamed-chunk-4-1.png create mode 100644 docs/course/10_Downsampling/images/TakeAway2.jpg diff --git a/Schedule.qmd b/Schedule.qmd index 82a90556..834c17f9 100644 --- a/Schedule.qmd +++ b/Schedule.qmd @@ -96,7 +96,7 @@ Course materials can be found [here](/course/00_GitHub/index.qmd) or via the Cou ### Conference Break 1 -No class week of March 30, 2026. If you are attending the [ABRF conference](https://web.cvent.com/event/6aeb3907-0f0b-418d-a0d5-91f4de72c144/summary?RefId=ABRF%202026%20Annual%20Meeting%20Home%20Page), track me down at the [Complex Data Analysis in Flow Cytometry: Navigating the Landscape](https://web.cvent.com/event/6aeb3907-0f0b-418d-a0d5-91f4de72c144/websitePage:89d4bbd7-0f7c-4235-a335-97866af9506b) talk on Monday, March 30th at 4:30 PM. +No class week of March 30, 2026. If you are attending the [ABRF conference](https://web.cvent.com/event/6aeb3907-0f0b-418d-a0d5-91f4de72c144/summary?RefId=ABRF%202026%20Annual%20Meeting%20Home%20Page), track me down at the [Complex Data Analysis in Flow Cytometry: Navigating the Landscape](https://youtu.be/l9LQZ52gv3k?t=3678) talk on Monday, March 30th at 4:30 PM.

@@ -126,20 +126,29 @@ No class week of March 30, 2026. If you are attending the [ABRF conference](http ![](https://www.ibm.com/content/dam/connectedassets-adobe-cms/worldwide-content/creative-assets/s-migr/ul/g/7d/10/downsampling-near-miss-v3.png){width=50%} -[**Week 10: April 20, 2026**]{.underline} Within this [tenth](/course/10_Downsampling/index.qmd) session, we will expand on our growing understanding of GatingSets, functions and fcs file internals to write a function to downsample your fcs files to a desired number (or percentage) of cells for a given cell population. We will additionally learn how to concatenate these downsampled files together, and save them to a new .fcs file in ways that the metadata can be read by commercial software without the scaling being widely thrown off. +[**Week 10: April 27, 2026**]{.underline} Within this [tenth](/course/10_Downsampling/index.qmd) session, we will expand on our growing understanding of GatingSets, functions and fcs file internals to write a function to downsample your fcs files to a desired number (or percentage) of cells for a given cell population. We will additionally learn how to concatenate these downsampled files together, and save them to a new .fcs file in ways that the metadata can be read by commercial software without the scaling being widely thrown off.

-### Retrieving data for Statistics +### Retrieving Data for Statistics ![](images/TidyData.png){width=33%} -[**Week 11: April 27, 2026**]{.underline} Leveraging the increased familiarity working with the various packages this far in the course, in this [eleventh](/course/11_DataForStats/index.qmd) session we will retrieve summary statistics for the gates within our GatingSet, and programmatically derrive out tidy data.frames for use in statistical analyses typically used by many Immunologist. In the process, we add a couple additional plot types to our ggplot2 arsenal to hold in reserve should Prism prices go up again. +[**Week 11: May 04, 2026**]{.underline} Leveraging the increased familiarity working with the various packages this far in the course, in this [eleventh](/course/11_DataForStats/index.qmd) session we will retrieve summary statistics for the gates within our GatingSet, and programmatically derrive out tidy data.frames for use in statistical analyses typically used by many Immunologist. In the process, we add a couple additional plot types to our ggplot2 arsenal to hold in reserve should Prism prices go up again.

+### Conference Break 2 + +If you are attending the [Cyto conference](https://www.cytoconference.org/?gad_source=1&gad_campaignid=20633392465&gbraid=0AAAAADoJzsvHaLZAq9tqn_aTAQGEzIk_V&gclid=CjwKCAiA-sXMBhAOEiwAGGw6LJFFV69xaAU3s7bElL86RdnRNFwAYqOQO78MrIYQuG1qvRU6HTN3ZRoCGmAQAvD_BwE), track me down at my talks ([Open-Source automation](https://davidrach.github.io/abstracts.html#cyto-2026---flow-awarenesss) on June 7, 10:30-11:30AM at Grand Ballroom; +and [Semi-supervised pipeline](https://davidrach.github.io/abstracts.html#cyto-2026---alpha-beta) on June 9, 10:30-11:45AM atRoom 2DEF) or poster (grab some Cytometry in R course hex stickers!) + +
+
+ + ### Spectral Signatures ![](images/Signatures.png){width=75%} @@ -185,13 +194,6 @@ No class week of March 30, 2026. If you are attending the [ABRF conference](http

-### Conference Break 2 - -No class week of June 8, 2026. If you are attending the [Cyto conference](https://www.cytoconference.org/?gad_source=1&gad_campaignid=20633392465&gbraid=0AAAAADoJzsvHaLZAq9tqn_aTAQGEzIk_V&gclid=CjwKCAiA-sXMBhAOEiwAGGw6LJFFV69xaAU3s7bElL86RdnRNFwAYqOQO78MrIYQuG1qvRU6HTN3ZRoCGmAQAvD_BwE), track me down at my talks ([Open-Source automation](https://davidrach.github.io/abstracts.html#cyto-2026---flow-awarenesss) on June 7, 10:30-11:30AM at Grand Ballroom; -and [Semi-supervised pipeline](https://davidrach.github.io/abstracts.html#cyto-2026---alpha-beta) on June 9, 10:30-11:45AM atRoom 2DEF) or poster (grab some Cytometry in R course hex stickers!) - -
-
### Normalization: Batch Effect or Real Biology @@ -220,6 +222,14 @@ and [Semi-supervised pipeline](https://davidrach.github.io/abstracts.html#cyto-2

+### Conference Break 3 + +No class week of August 10, 2026. If you are attending the [BioC conference](https://bioc2026.bioconductor.org/), track me down at my [short talk](https://bioc2026.bioconductor.org/schedule/) on Monday, August 10th from 11:00-12:15pm. + +
+
+ + ### The Art of GitHub Diving ![](https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTE3IHi5Y2itTmH60RF81y5b8JnSeeJvTTATA&s){width=100%} @@ -265,13 +275,6 @@ and [Semi-supervised pipeline](https://davidrach.github.io/abstracts.html#cyto-2

-### Conference Break 3 - -No class week of August 10, 2026. If you are attending the [BioC conference](https://bioc2026.bioconductor.org/), track me down at my talk/poster. - -
-
- ### Reproducibility and Replicability ![](https://media.springernature.com/lw1200/springer-static/image/art%3A10.1038%2F533452a/MediaObjects/41586_2016_BF533452a_Fige_HTML.jpg){width=50%} diff --git a/_quarto.yml b/_quarto.yml index c5e0d7dd..05c29d16 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -108,6 +108,10 @@ website: href: Schedule.qmd - section: "The World is your Oyster" href: Schedule.qmd + - section: "Bonus Content" + contents: + - text: "Concatenate - Extended Cut" + href: course/10_Downsampling/concatenate.qmd - section: "Community" contents: - text: "AutoSpectral" diff --git a/course/10_Downsampling/concatenate.qmd b/course/10_Downsampling/concatenate.qmd index d11f5603..7c5e54d7 100644 --- a/course/10_Downsampling/concatenate.qmd +++ b/course/10_Downsampling/concatenate.qmd @@ -1,7 +1,7 @@ --- title: "Walk-through: Concatenate" author: "David Rach" -date: 04-28-2026 +date: 05-31-2026 format: html toc: true toc-depth: 5 @@ -19,4 +19,2199 @@ For screen-shot slides, click [here](/course/10_Downsampling/slides.qmd)
---- \ No newline at end of file +--- + +# Background + +## Rationale + +This walk-through is an extension of the [Week 10](/course/10_Downsampling/index.qmd) lesson, additionally documenting how the `Concatenate()` function was assembled from start to finish. This walk-through is optional, and has been separated out from the [Downsampling](/course/10_Downsampling/index.qmd) lesson mainly in a desire to avoid those who are just starting to learn functions from burning out due to too-many-examples-too-quickly. + +If however you want to see additional examples of how a slightly-more complicated function is crafted (or just really enjoy learning about .fcs file internals in R), then this walk-through is for you, and feel free to continue reading :D + +## Journey Thus Far + +At this point in the [Week 10](/course/10_Downsampling/index.qmd) lesson, we had just wrapped up creating the `Downsampling` function. While down-sampling by itself can be useful under certain situations, it is more often used as part of a workflow where the downsampled outputs are combined together into a single .fcs file (**concatenation**) for use in unsupervised workflows (like [dimensionality visualization](/Schedule.qmd#dimensionality-visualization)). + +Unlike `Downsampling()`, which works within an individual .fcs files (i.e. one at a time), if our goal is to build out a `Concatenate()` function, we would need to first iterate through every .fcs file to retrieve the needed downsampled outputs, then combine them together, before finally outputting them as a new .fcs file. + +While nesting `Downsampling()` within `Concatenate()` to create a nested function is a simple enough process, in practice the tricky part is that when we concatenate .fcs files together, we typically want to add keywords to allow us distinguish within the concatenated file the original .fcs file from which a cell derrived. + +For .fcs files, these keywords end up being integrated in as additional numeric columns in the ['exprs'](/course/03_InsideFCSFile/index.qmd) matrix, which results in the need to not only modify the exprs slot, but subsequently additional modifications to the parameters and description slots before we can create the new .fcs file. + +It is because of all these numerous moving parts that showing the full-walkthrough for `Concatenate()` within [Week 10](/course/10_Downsampling/index.qmd) was not feasible, and why we resorted to just sharing the fully assembled `Concatenate()` function and its various nested helper functions in the R folder so that the focus could remain on their actual implementation within a workflow. + +However, if you are not yet burnt out on function building, the walk-through documenting the process via which `Concatenate` was created can be seen below. + +## Set Up + +Seeing as this portion of the original walk-through (index.qmd) has now been relocated to its own separate .qmd file (concatenate.qmd), we will need to reload in all the different components we had previously assembled to replicate the working environment contents that should have been present at the point of the workflow we started to assemble `Concatenate()`. + +Lets re-attach the required R packages to our local environment via the `library()` call. + +```{r} +library(flowWorkspace) +library(flowGate) +library(dplyr) +library(ggplot2) +library(purrr) +``` + +Next up, lets re-establish the file path to our data folder + +```{r} +#StorageLocation <- file.path("course", "10_Downsampling", "data") # Interacting directly +StorageLocation <- file.path("data") #For Quarto Rendering +``` + +And then proceed to load in our saved gated GatingSet from [Downsample](/course/10_Downsampling/index.qmd) via `load_gs` + +```{r} +GatingSetStorageLocation <- file.path(StorageLocation, "SFCGatingSet.gs") + +SFC_GatingSet <- load_gs(GatingSetStorageLocation) +``` + +```{r} +plot(SFC_GatingSet) +``` + +Finally, where we left off, we had `Downsampling()` fully assembled and active in our enviroment. Since we will need it when building out `Concatenate()`, we can `source()` the final version of `Downsampling()` function that was stored within an "R" folder in our working environment at the end of the [Week 10 walk-through](/course/10_Downsampling/index.qmd). The `source()` function is the equivalent of calling `library()` on an R package, but at the level of individual .R files, returning their generated contents to your local environment. + +```{r} +#DownsamplingRFilePath <- file.path("course", "10_Downsampling", "R", "Downsampling.R") # For Interactive +DownsamplingRFilePath <- file.path("R", "Downsampling.R") # For Quarto Rendering +source(DownsamplingRFilePath) +``` + +Once `source()` is run, we should see the functions present within the "R" folder become active, as we can see in the right secondary side-bar. + +![](images/000_UpdatedVariables.png) + +At this point, we have re-assembled everything that we previously had and will need to continue creating the `Concatenate()` function. If you remain interested, please continue with the walk-through below. If you want to return to where you were at, click [here](/course/10_Downsampling/index.qmd#concatenate-code). + + +# Walk-through + +## Sketching a Plan + +Our broad goal is to take the .fcs files currently within our GatingSet, use our newly constructed `Downsampling()` function to retrieve a desired number of cells from our target gate, and then combine them together to form a concatenated .fcs file that we can use for downstream [unsupervised analysis](/Schedule.qmd#dimensionality-visualization). + +With the `Downsampling()` function written, the initial components needed for our `Concatenate()` function are ready to go. To get the individual outputs back from the GatingSet, we can use `map()` from the `purrr` package to iterate through our outputs. We will then need to figure out the actual concatenation code, after which we can utilize the `flowCore` packages `write.FCS()` to export our combined file out as a new .fcs file to a designated folder. + +As we sketch out our mental plan, it's this middle-late section where we can anticipate encountering some complexity (and therefore additional troubleshooting). Previously, with `Downsampling()`, we were simply swapping in place of the original `exprs()` matrix a smaller `exprs()` matrix, while keeping all other components of the original .fcs file intact. When we concatenate, a new keyword column typically gets added to the `exprs()` matrix, which allows us distinguish for each cell the original .fcs file it came from before everything was concatenated together. This new column also permits us to gate a group of cells of interest, and by visualizing the keyword on one axis, separate out cells on basis of these groups. + +Because this new column is located in the `exprs()` matrix, it needs to be 'numeric' (not a 'character' or 'logical' type value). So if our metadata/keywords are character values, we will need to convert them over to some form of numeric-based keyword values, while also providing a means to back-translate these numeric values to their original character form (likely via adding a new keyword within the description/keyword slot to serve as a dictionary). + +Consequently, as we saw back during [Week 03](/course/03_InsideFCSFile/index.qmd#parameters), the simple addition of a new column to `exprs()` means we will also need to add entries for it to the `parameter()` data.frame, as well as the description/keywords list. This means that from-the-get-go, we will need to mess around with more .fcs file internals than we did with `Downsampling()`, if our goal is to return a fully commercial-software compatible .fcs file. + +Before getting started, its worth remembering that this is just one approach to writing a `Concatenate()` function. As with everything in R, there are multiple routes one can take to get to the same outcome. You are welcome to extend/modify/alter the existing code beyond what we show to better fit your own requirements in the future. + +## Nesting Downsampling + +Lets get started by creating a skeleton for our new function. Since `Concatenate()` will need to work/orchestrate things at the level of the entire GatingSet, we will use "gs" as our first argument. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' +Concatenate <- function(gs){ + # Code goes here +} +``` + +However, we are not just combining entire .fcs files together, rather combining cells present within the same designated gate. So we can go ahead and provide a subset argument, and copy over the roxygen skeleton documentation we used for it in `Downsampling` to avoid needing to rewrite it. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' +Concatenate <- function(gs, subset){ + # Code goes here +} +``` + +Speaking of, the line of code we ran to retrieve our `Downsampling()` outputs would likely be one of the first lines of code we will need to run within `Concatenate()`. Let's go ahead and also copy-paste-it inside the function. We can then see what arguments it needed, and update `Concantenate()` with their respective documentation entries we had previously written for `Downsampling()`. + +When copying-and-pasting in code inside a new function you are creating, remember to check that any expected variable/object mentioned by that line of code matches an argument present within "function()" to avoid encountering errors later on when the function is not able to find that variable in the functions environment. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' +#' @importFrom purrr map +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame"){ + + flowFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="flowFrame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + # All argument values in the code above need to match an argument + # inside the function() in order to avoid having a "missing argument" returned + +} +``` + +With these initial changes made to our `Concatenate()` function, we can update/refresh it in our local environment by re-running the function code (easiest approach is to select the "Run Cell" option for the code chunk), and then test it to make sure we are getting back the expected code back (the iterating `Downsampling()` output ending up in "flowFrameList" in this particular case). + +Typically, I will set up a code chunk directly below my function code chunk, and circle back and forth between the function and the run-code chunk throughout the process of function building. We ultimately want to make sure we are getting the expected output, and not any error or warning notifications. + +```{r} +Values <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000) +``` + +```{r} +nrow(Values[[1]]) +``` + +Having successfully retrieved a flowFrame object matching our desired downsample count, we know that our attempt to nest `Downsampling()` inside `Concatenate()` has been successful. + +## Conditional Return + +At this point, we have a nested function. However, when creating `Downsampling()`, we set up a "returnType" argument to allow us to retrieve various desired output types ("fcs", "flowFrame", "data.frame"). By adding a "returnType" argument and then setting up a conditional (using 'if' or 'else') we can enable `Concatenate` to also return these multiple output types. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' +#' @importFrom purrr map +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame"){ + + if (returnType == "data.frame"){ + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + } else { + flowFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, DownsampleCount=DownsampleCount, addon=addon, returnType="flowFrame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + } +} +``` + +With our edits complete, we can re-run the function code-chunk to update it in our local environment (replacing the older active variant), and run our output line again to make sure our changes were implemented correctly. + +```{r} +Values <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, returnType="data.frame") +``` + +```{r} +head(Values[[1]], 3) +``` + +Having retrieved back a "data.frame" style, lets switch "returnType" argument back and make sure we can still also retrieve a "flowFrame" object (which means our conditional statement is implemented correctly). + +```{r} +Values <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, returnType="flowFrame") +``` + +```{r} +Values[[1]] +``` + +With our "returnType" argument returning the correct objects when we specify "flowFrame" or "data.frame", our conditional statement is correctly implemented and we are ready to proceed. + +## Considerations + +At this point, within `Concatenate()`, we are able to retrieve lists of either "data.frame" or "flowFrames" objects, the length corresponding to the number of specimens present in our 'GatingSet' object. Ultimately, regardless of what returnType we get back, we will want to `mutate()` in a new column to the individual `exprs()` matrices contained within each list, appending the corresponding metadata keyword that will permit us to distinguish the original .fcs files an individual cell came from once everything gets concatenated together. + +While both returnTypes will provide us with `exprs()` matrices, using a "flowFrame" means we would also have direct access to the .fcs file associated metadata (something that the "data.frame" option would not permit). So at this point in building the `Concatenate()` function, we have reached a fork in the coding path, with a couple options to decide between before proceeding. + +If we choose returnType = "flowFrame", for the invidiual flowframes, we could dive back in and retrieve out from the internal [description/keyword list](/course/03_InsideFCSFile/index.qmd#description) a keyword for use in differentiating cells from that flowframe from those of the other flowframes. We could then retrieve the `exprs()` matrix, and append on the designated keyword as a new column via the `mutate()` function. Subsequently, all individual data.frames originating from the list of flowframes could be combined together (through either `rbind()` or `bind_rows()`) to create a combined data.frame. This concatenated "data.frame" object could then be converted into an .fcs file via similar steps that we previously employed for `Downsampling()`. + +Alternatively, if we chose returnType = "data.frame" route, we would already have retrieved via `Downsampling()` the individual `exprs()` "data.frames" for each specimen in our GatingSet. Since no additional metadata is present at this stage, we would need to separately retrieve out keywords for each specimen from our existing GatingSet object, and pass the retrieved keyword value to its corresponding individual's data.frame. At that point, we could append it on as a new column via the `mutate()` function. From there, the process becomes essentially identical to the last steps described above for returnType = "flowFrame". + +As you can see, we end up at a similar place using either approach, but need different steps to get there depending on our choice. This is typical scenario when coding your own functions, which is why I wanted to highlight it rather than just simply picking one option and moving forward without explanation why. + +I ultimately decided on going via the returnType = "data.frame" approach. The main reason was that even though more steps are involved in linking keywords to already retrieved data.frames, it is [relatively easy](/course/07_Transformations/index.qmd#metadata) to update a GatingSet's metadata. This allows extra flexibility in providing additional values that can then be integrated as keyword metadata in the concatenated .fcs file. + +By contrast, returnType = "flowFrame" would mean we are either restricted to the keywords that are already present as internal .fcs file keywords; or subsequently will need to write additional code to allow for external inputs, which would pretty much be the same code we would have written if we had used the returnType="data.frame" option from the get-go. + + +## Metadata + +### Updating Metadata + +Having decided to go the returnType="data.frame" route, lets first check to see what the existing metadata for our GatingSet object currently looks like. + +```{r} +CurrentMetadata <- pData(SFC_GatingSet) +colnames(CurrentMetadata) +``` + +As you can see, we have only the standard name column. We can combine in addition metadata for our dataset from a .csv file by repeating the steps we first saw back during [Week 7](/course/07_Transformations/index.qmd#updating-metadata). + +```{r} +TheCSV <- list.files(StorageLocation, pattern=".csv", full.names=TRUE) +AdditionalMetadata <- read.csv(TheCSV, check.names=FALSE) +colnames(AdditionalMetadata) +``` + +Seeing as both "CurrentMetadata" and "AdditionalMetadata" share a column (and importantly, the name values contained within each column have corresponding matches in the other data.frame), we can use the `dplyr` packages `left_join()` function to merge both data.frames into a larger one on the basis of the shared column. After updating the rownames (which the GatingSet object is expecting to be present) we can then assign this updated metadata back to our GatingSet via `pData()`. + +```{r} +UpdatedMetadata <- left_join(CurrentMetadata, AdditionalMetadata, by="name") +rownames(UpdatedMetadata) <- UpdatedMetadata$name +pData(SFC_GatingSet) <- UpdatedMetadata +pData(SFC_GatingSet) +``` + +### Retrieving Metadata + +At this point, our GatingSet now has updated metadata, containing additional keywords by which we can classify our specimens. Because of this, we have the potential to incorporate up to four keyword columns in our futuren concatenated .fcs file ("name", "condition", "infant_sex", and "HEU_status"). + +When writing out our function code, one thing we should attempt to avoid is ["hard-coding"](https://en.wikipedia.org/wiki/Hard_coding) the column names, since ideally, we want to iterate in the metadata colum names, so that regardless of what the individual keyword column is named, or if we want to integrate 1 keyword or 20 keywords, that these will all be correctly handled and integrated into our final .fcs file without needing to write additional lines of code. + +Having committed to the returnType = "data.frame" approach, lets remove from `Concatenate()` the conditional statement, keeping only the "data.frame" option code lines. We can then re-run/re-fresh the `Concatenate()` function, and run the output code line to make sure that it remains operational (in this case, returning `length()` of our "dataFrameList" intermediate). + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' +#' @importFrom purrr map +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame"){ + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + length(dataFrameList) + +} +``` + +```{r} +Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000) +``` + +At this point, we will need to set up the code to retrieve the metadata for subsequent data.frame integration. Since we may not want to integrate all the metadata columns into our concatenated .fcs file as keywords, we should provide an additional argument to designate which metadata columns to actually incorporate. + +Lets call this new argument "desiredCols", adding it within 'function()', and adding an entry for the argument to the roxygen2 documentation. Likewise, we can modify our final line of code to `print()` the provided "desiredCols" to validate everything is working post changes. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' +#' @importFrom purrr map +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols){ + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + print(desiredCols) +} +``` + +After re-running/re-freshing our function, we can modify our check-the-output code-chunk, to make sure we get back the final function output ("print(desiredCols)" in this case). +```{r} +Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) +``` + +With this done, let's go ahead and retrieve the GatingSet metadata via `pData()`. Since this returns as a "data.frame" object, we can subset for just our columns of interest (as designated via "desiredCols") using `dplyr`'s `select()` function. Since we are passing in a vector of external character strings, we will need to place `select()` inside the `tidyselect` packages `all_of()` function in order to avoid having a warning message be returned. We can then modify our final return line to return the subsetted metadata, enabling troubleshooting after we re-run/re-fresh our function. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select +#' @importFrom tidyselect all_of +#' @importFrom purrr map +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + print(DesiredMetadata) + +} +``` + +```{r} +Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) +``` + +Getting back the correct output, lets modify the desiredCols arguments by removing a couple column names to make sure it wasn't a fluke. + +```{r} +Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition")) +``` + +Nope, still working as intended :D So at this point, `Concatenate()` will extract and subset the GatingSet metadata. Within, each row corresponds to an individual specimen, with each keyword column being a value we will want to append on to the specimens `exprs()` data.frame as a new column. + +However, at the moment, our "dataFrameList" object inside `Concatenate()` is a list of "data.frames". Our main task before proceeding is to figure out a way, for each specimen, we will need to isolate its respective row from the metadata data.frame ("DesiredMetadata"), and append the values contained in as new columns for the indivuiduals corresponding data.frame (duplicating the same values for every row present). + +### Helper Function - Keyword Append + +Since we will need to handle for each specimen two objects (the metadata row and the data.frame), we are going to need to write a new helper function to handle the various steps involved. Lets go ahead and call this one `KeywordAppend()` after its intended purpose. + +From the current internally generated variables/objects within `Concatenate()`, it is likely we will need for starters 3 arguments: "x" (the iterated in specimen name that will be used to filter out the correct metadata row), "y" (the simultaneously iterated in `exprs()` data.frame for the corresponding specimen), and metadata (i.e. the metadata data.frame that we will be isolating the single row from using "x"). + +So our initial function/roxygen2 skeleton would look like this + +```{r} +#' Internal for Concatenate, appends the metadata columns +#' to the corresponding data.frame +#' +#' @param x The name value being used to identify the correct +#' row in the metadata data.frame +#' @param y The iterated in exprs data.frames +#' @param metadata The metadata data.frame +#' +KeywordAppend <- function(x, y, metadata) { + # Code goes here +} +``` + +With our arguments set up, we can start figuring out how to tackle the first of the two moving (i.e. iterated in) pieces, "x", which will be the specimen name we will use to `filter()` the "metadata" to get back the specimen specific role for use downstream. Our initial code line for `KeywordAppend()` therefore would look like this + +```{r} +#' Internal for Concatenate, appends the metadata columns +#' to the corresponding data.frame +#' +#' @param x The name value being used to identify the correct +#' row in the metadata data.frame +#' @param y The iterated in exprs data.frames +#' @param metadata The metadata data.frame +#' +#' @importFrom dplyr filter +#' +KeywordAppend <- function(x, y, metadata) { + rownames(metadata) <- NULL # Removing row names since not needed + AddThisRow <- metadata |> filter(name %in% x) +} +``` + +At this point, "AddThisRow" would consist of a single row, containing just the keyword columns that were selected (via "desiredCols" argument) we want to integrate in as new keyword columns. + +Having successfully what we need to append, we can tackle the second moving (i.e. interated in piece), the "y" argument, which will correspond to the specimen's `exprs()` data.frame. + +Remember, within `Concatenate()`, the list of `exprs()` data.frames is currently contained within the "dataFrameList" object. Each object in a list is typically displayed as [[1]], [[2]], etc. When we iterate, .x=dataFrameList would be the equivalent of x <- dataFrameList[[1]]. + +To help troubleshoot that things are being iterated in correctly (and not ending up one level up or below somewhere in a nested list), I will typically assign them to an internal variable for troubleshooting purposes. Since our iterated in `exprs()` data.frame as y, we could save it to a "df" variable inside the function for later evaluation. + +```{r} +#' Internal for Concatenate, appends the metadata columns +#' to the corresponding data.frame +#' +#' @param x The name value being used to identify the correct +#' row in the metadata data.frame +#' @param y The iterated in exprs data.frames +#' @param metadata The metadata data.frame +#' +#' @importFrom dplyr filter +#' +KeywordAppend <- function(x, y, metadata) { + df <- y + rownames(metadata) <- NULL # Removing row names since not needed + AddThisRow <- metadata |> filter(name %in% x) + return(df) +} +``` + +After checking that "df" is indeed just a data.frame (via `str()` function), we can proceed to combine the isolated row containing the desired keywords as new columns. This can be done using `dplyr`'s `bind_cols()` function. Since "AddThisRow" is just an individual row, while "df" contains multiple rows, `bind_cols()` argument will end up duplicating the single metadata row the necessary number of times to match the rows present in "df". + +```{r} +#' Internal for Concatenate, appends the metadata columns +#' to the corresponding data.frame +#' +#' @param x The name value being used to identify the correct +#' row in the metadata data.frame +#' @param y The iterated in exprs data.frames +#' @param metadata The metadata data.frame +#' +#' @importFrom dplyr filter bind_cols +#' +KeywordAppend <- function(x, y, metadata) { + df <- y + rownames(metadata) <- NULL # Removing row names since not needed + AddThisRow <- metadata |> filter(name %in% x) + ExpandedData <- bind_cols(df, AddThisRow) + return(ExpandedData) +} +``` + +At this point, `KeywordAppend()` has carried out it's task, and we have an expanded data.frame that contains the new keyword columns. We now just need to integrate this completed helper function within our `Concatenate()` function. + +### Appending Metadata + +For `KeywordAppend()`, we are intending to iterate in two separate arguments ("x" and "y"). "x" corresponds to the name of the specimen used to `filter()` the correspond row in "metadata". Since we have not yet generated a vector of specimen names for this task, we can achieve this by `pull()`ing "metadata"s name column, ending up with a vector of names. + +```{r} + +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull +#' @importFrom tidyselect all_of +#' @importFrom purrr map +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + return(TheFileNames) +} +``` + +We now have a vector ("TheFileNames") to pass to "x", and a list of data.frames ("dataFrameList") to pass to "y" for iteration. Since the `purrr` packages `map()` function is only able to handle 1 iterating argument at a time, we will need to use the related `map2()` function to handle simultaneously iterating through the "x" and "y" arguments. + +As a result, the line of code to iterate both "x" and "y" to `KeywordAppend()` would be as follows + + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + return(ExpandedDataframes) +} +``` + +`Concatenate()` should now be set up to retrieve and append the keyword columns to the individual data.frames. We should re-run/re-fresh the function to update it in our local environment, and run the output line to verify we are getting back a list of data.frames containing the appended designated columns. + +```{r} +Data <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) +``` + +```{r} +length(Data) +``` + +```{r} +colnames(Data[[1]]) +``` + +And as we can see, we still get back the same number of data.frames we were expecting for our 3 specimens, and these data.frames now contain the four keyword columns we had designated. Woooh! We have made substantial progress through the initial mental-sketch-plan. + +## Concatenation + +With the individual `exprs` data.frames having been updated with the specimen specific keyword values (and thus now distinguishable), it is safe to combine/concatenate them together into a single larger data.frame object. We can do this by passing our list of data.frames to `dplyr`'s `bind_rows()` function. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + return(CombinedData) +} +``` + +We can then re-run/re-fresh the function, run our output line. Since everything should now be in one data.frame, we can `pull()` the name keyword column and identify the `unique()` values to ensure we still have the three specimens we started with. + +```{r} +Data <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) + +Data |> pull(name) |> unique() +``` + +## Halfway There + +If we were only planning to work with the data in R, we could leave `Concatenate()` as is and call it a day. However, since we are cytometrist, getting this concatenated data.frame back into a fully compliant .fcs file is likely our desired outcome. Consequently, lets check back to our [starting notes](/course/10_Downsampling/concatenate.qmd#sketching-a-plan) and plan out our next steps. + +With the additional columns now in our concatenated data.frame, we will need to get these back into an `exprs()` matrix format. That will require converting any "character" strings to "numeric" values, which also means we should create a new keyword in the [description](/course/03_InsideFCSFile/index.qmd#description) list containing a dictionary lookup (with column containing the BeforeValue, and another column containing the AfterValue) to allow us to revert back later if needed. + +Likewise, new `exprs()` columns will require updating the [parameter slot](/course/03_InsideFCSFile/index.qmd#parameters) data.frame to contain new rows (designated with `rownames()` in the "$P30" style format), which in turn will spawn several additional description keywords for each '$P30' style row added ("$P30N", "$P30V", "P30DISPLAY", etc.). + +## Numeric Keywords + +Since we are trying to generalize the code (to avoid hard-coding), let's start by differentiating what data.frame columns were added, vs. which were original, using `all_of()` and `select()` to subset for or subset against the column names that were specified in "desiredCols" + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + return(NewData) +} +``` + +```{r} +Data <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) + +colnames(Data) +``` + + +### Helper Function - ColumnToKeyword + +We can now start planning out a new helper function to help us go from "character" keyword columns to "numeric"-containing keyword columns. In this case, we would want to iterate through each individual new column (i.e. those in "desiredCols"). We would first need to evaluate whether the column is already numeric. If yes, we could skip to the next column. If not (in the case of being a "character" or "logical" type value, we would need to provide a substitute numeric value for each unique value in the column. + +Lets go ahead and call this helper function `ColumnToKeyword()` and write out an initial function skeleton for it. We would be iterating in new columns present in "NewData", via their column names (designated in "desiredCols"), so we could use the typical "x" and "data" style arguments we have encountered before. + +```{r} +#' Internal for Concatenate, swaps character style values +#' from a column for numeric style values, returning two +#' columns for use in a lookup dictionary +#' +#' @param x An iterated in column name +#' @param data The underlying data.frame containing the +#' new keyword columns +#' +#' @importFrom dplyr select +#' @importFrom tidyselect all_of +#' +ColumnToKeyword <- function(x, data){ + IndividualColumn <- data |> select(all_of(x)) + return(IndividualColumn) +} +``` + +At this point, `ColumnToKeyword()` will have isolated out the individual iterated in column. We can at this point set up a conditional using `is.numeric()` to check the type of values contained within, and handle subsequent steps depending on the answer. + +```{r} +#' Internal for Concatenate, swaps character style values +#' from a column for numeric style values, returning two +#' columns for use in a lookup dictionary +#' +#' @param x An iterated in column name +#' @param data The underlying data.frame containing the +#' new keyword columns +#' +#' @importFrom dplyr select +#' @importFrom tidyselect all_of +#' +ColumnToKeyword <- function(x, data){ + IndividualColumn <- data |> select(all_of(x)) + + if (!is.numeric(IndividualColumn)){ + message("Darn, it's our problem now") + } else { + message("ITS NUMERIC! SKIP!") + } + + return(IndividualColumn) +} +``` + +With the conditional setup, we can now start filling in what to do depending on whether the column already is numeric or not. Lets handle a result of `is.numeric` == FALSE. First off, we should identify what unique values are present (since with keywords, there are likely to be many rows of duplicated values) + +```{r} +#' Internal for Concatenate, swaps character style values +#' from a column for numeric style values, returning two +#' columns for use in a lookup dictionary +#' +#' @param x An iterated in column name +#' @param data The underlying data.frame containing the +#' new keyword columns +#' +#' @importFrom dplyr select pull +#' @importFrom tidyselect all_of +#' +ColumnToKeyword <- function(x, data){ + IndividualColumn <- data |> select(all_of(x)) + + if (!is.numeric(IndividualColumn)){ + message("Darn, it's our problem now") + Values <- IndividualColumn |> pull(x) |> unique() + + } else { + message("ITS NUMERIC! SKIP!") + } + + return(IndividualColumn) +} +``` + +We now have a vector of original unique non-numeric "Values". We now need to create numeric standins for each. Typically, for keywords visualized via commercial software, we want to provide enough space between the numbers to gate safely. We can create a new vector ("Values_Key") the `length()` of our unique "Values" that sequentially increases by 1000. + +We can pass both "Values" and "Values_Key" to the `tibble()` function to create a "tibble" that can serve as the Dictionary look-up for this keyword (In general, `tibbles` and `data.frames` objects are similar, but differ slightly in [some of their behavior](https://jtr13.github.io/cc21fall1/tibble-vs.-dataframe.html)) + +```{r} +#' Internal for Concatenate, swaps character style values +#' from a column for numeric style values, returning two +#' columns for use in a lookup dictionary +#' +#' @param x An iterated in column name +#' @param data The underlying data.frame containing the +#' new keyword columns +#' +#' @importFrom dplyr select pull +#' @importFrom tidyselect all_of +#' @importFrom tibble tibble +#' +ColumnToKeyword <- function(x, data){ + IndividualColumn <- data |> select(all_of(x)) + + if (!is.numeric(IndividualColumn)){ + message("Darn, it's our problem now") + Values <- IndividualColumn |> pull(x) |> unique() + + Dictionary <- tibble(Values = Values, + Values_Key = seq(1000, by = 1000, length.out = length(Values))) + + } else { + message("ITS NUMERIC! SKIP!") + } + + return(IndividualColumn) +} +``` + +Next, our column names are going to be "Values" and "Values_Key". Ideally, we would want to substitute in the actual column name (currently held within "x"), or as we iterate we are just going to end up with repeated number of columns called "Values" and "Values_Key". We can improvise this using the `gsub()` function. + +At that point, `ColumnToKeyword()` has generated the Dictionary Lookup, so we can close out by passing the assembled "tibble" to `return()`, closing out the function code when a non-numeric column is encountered. + +```{r} +#' Internal for Concatenate, swaps character style values +#' from a column for numeric style values, returning two +#' columns for use in a lookup dictionary +#' +#' @param x An iterated in column name +#' @param data The underlying data.frame containing the +#' new keyword columns +#' +#' @importFrom dplyr select pull +#' @importFrom tidyselect all_of +#' @importFrom tibble tibble +#' +ColumnToKeyword <- function(x, data){ + IndividualColumn <- data |> select(all_of(x)) + + if (!is.numeric(IndividualColumn)){ + Values <- IndividualColumn |> pull(x) |> unique() + + Dictionary <- tibble(Values = Values, + Values_Key = seq(1000, by = 1000, length.out = length(Values))) + + colnames(Dictionary) <- gsub("Values", x, colnames(Dictionary)) + return(Dictionary) + + } else { + message("ITS NUMERIC! SKIP!") + } + + return(IndividualColumn) +} +``` + +With the non-numeric columns now being correctly parsed into a Dictionary tibble, we can repeat the process to generate an equivalent for the numeric columns. In this case, we don't need to change anything, so we can get away with just providing the "Values" vector to both "Values" and "Values_Key" when creating the dictionary. + +```{r} +#' Internal for Concatenate, swaps character style values +#' from a column for numeric style values, returning two +#' columns for use in a lookup dictionary +#' +#' @param x An iterated in column name +#' @param data The underlying data.frame containing the +#' new keyword columns +#' +#' @importFrom dplyr select pull +#' @importFrom tidyselect all_of +#' @importFrom tibble tibble +#' +ColumnToKeyword <- function(x, data){ + IndividualColumn <- data |> select(all_of(x)) + + if(!is.numeric(IndividualColumn)){ # Is not numeric + Values <- IndividualColumn |> pull(x) |> unique() + + Dictionary <- tibble(Values = Values, + Values_Key = seq(1000, by = 1000, length.out = length(Values))) + colnames(Dictionary) <- gsub("Values", x, colnames(Dictionary)) + return(Dictionary) + } else { + Values <- IndividualColumn |> pull(x) |> unique() + Dictionary <- tibble(Values = Values, + Values_Key = Values) + colnames(Dictionary) <- gsub("Values", x, colnames(Dictionary)) + return(Dictionary) + } +} +``` + +At this point, the code for our `ColumnToKeyword()` helper function is complete, so we can go ahead and integrate it into `Concatenate()`, passing in our "NewData" columns, and ending up with a list of "DictionaryLookup" tibbles that can subsequently get added into the [description/keyword list](/course/03_InsideFCSFile/index.qmd#description). + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + return(Dictionaries) +} +``` + +```{r} +Data <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) + +Data[[1]] +``` + +## Original Parameters + +Having derived the dictionary translations we will need for converting back-and-forth our non-numeric keywords to numeric, lets gather some of the other components we still need to create our final .fcs file before we go and translate over the corresponding columns in the `exprs()` data.frame to numeric. + +Rather than try to create an entire .fcs framework from scratch, it is far easier to simply copy the contents of the `paramaters()` and `keyword()` slots from one of the specimens in our GatingSet, and subsequently modify outputs as needed. Since the underlying data we will retrieve will vary depending on which .fcs file in our GatingSet we select, we should provide an argument ("SpecimenIndex") that will allow us to designate which specimen to use for this purpose. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + + return(EventsInTheGate) +} +``` + +Once we have accessed the correct specimen, we will need to make sure we retrieve the underlying data via a [flowframe](/course/05_GatingSets/index.qmd#flowframe)(i.e. in RAM) instead of as a cytoframe (i.e accessed via a pointer). Consequently, we can copy in and modify some of the code we have encountered previously during [Week 09](/course/09_Functions/index.qmd) for this purpose. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 +#' @importFrom flowCore parameters keyword +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + return(OriginalParameters) +} +``` + +```{r} +Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) +``` + +## Dictionary Keywords + +Add this point, we have retrieved the contents of the original parameters and description/keyword slots, and have them stored as variables within our `Concatenate()` function. Let's turn our attention at extracting the DictionaryLookup tibbles from their list, and integrating them as keywords in the [description/keyword](/course/03_InsideFCSFile/index.qmd#description) list. + +This process is actually rather simple. Namely, we need to remove one level of the list layering (which we can achieve via the `purrr` packages `flatten()` function), at which point we can just combine the individual named keywords in with the original ones using `c()` + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 flatten +#' @importFrom flowCore parameters keyword +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + NewKeywords <- flatten(Dictionaries) + NewDescriptions <- c(OriginalDescription, NewKeywords) + + return(NewDescriptions) +} +``` + +```{r} +DescriptionList <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) + +tail(DescriptionList, 10) +``` + +Our keyword lookup dictionaries are now present in the description/keyword list variable, where they can be used to convert and revert back between numeric and non-numeric values. With these dictionaries now safely stored away, lets proceed to convert over the values currently within the `exprs()` data.frame from their non-numeric values to matrix-appropiate numeric values. + +### Helper Function - Keyword Translate + +To start the conversion from non-numeric to numeric, we will need to retrieve the DictionaryLookup tibble (which contains a column with the unique original "Values" and a column of their respective numeric "Values_Key"), and our data.frame with the columns that need to be translated over on a per-keyword basis. + +While an iteration approach for each column could be attempted, in this case, I chose to instead implement a [for-loop]() within a new helper function `KeywordTranslate()` to handle the conversion. Consequently, only two arguments are needed for setup, the "DictionaryList" and "data". The for-loop starts off by retrieving the names of the first and second column (original and key values) + +```{r} +#' Internal for Concatenate, replaces non-numeric column +#' with numeric equivalents on the basis of the DictionaryLookup +#' dataframe. +#' +KeywordTranslate <- function(DictionaryList, data) { + + for (Entry in DictionaryList) { + ColumnName <- names(Entry)[1] + KeyName <- names(Entry)[2] + } + + return(data) +} +``` + +With the names of the original and key columns in the DictionaryList object retrieved, the new column `exprs()` data.frame and DictionaryList `tibble` are merged by pipeing to `left_join` on basis of the original column name. + +Then, the original column is removed, leaving only the numeric key column, and the leftover key column is renamed to take on the previous original column's name. This completes the substitution in of the numeric values for that cycle of the for-loop, which then restarts for the next DictionaryList entry. + + +```{r} +#' Internal for Concatenate, replaces non-numeric column +#' with numeric equivalents on the basis of the DictionaryLookup +#' dataframe. +#' +#' @importFrom dplyr left_join select rename +#' @importFrom tidyselect all_of +#' @importFrom rlang !! := sym +#' +KeywordTranslate <- function(DictionaryList, data) { + + for (Entry in DictionaryList) { + ColumnName <- names(Entry)[1] + KeyName <- names(Entry)[2] + + data <- data |> left_join(Entry, by = ColumnName) |> + select(-all_of(ColumnName)) |> rename(!!ColumnName := !!sym(KeyName)) + } + + return(data) +} +``` + +### Converting to Numeric + +At the completion of the for-loop, `KeywordTranslate()` has converted over all the provided DictionaryLookup keywords, and the contents of the appended keyword columns are all numeric values, allowing for integration back into an`exprs()` matrix. With this now setup, we can add it to `Concatenate()` + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 flatten +#' @importFrom flowCore parameters keyword +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + NewKeywords <- flatten(Dictionaries) + NewDescriptions <- c(OriginalDescription, NewKeywords) + + TranslatedNewData <- KeywordTranslate(data=NewData, DictionaryList=Dictionaries) + + return(TranslatedNewData) +} +``` + +```{r} +Data <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) + +str(Data) +``` + +## Reassembling flowFrame + +Having made changes to both `exprs()` and `description()`, lets go ahead assemble the components we currently have on hand into a new 'flowframe' object before proceeding to tackle updating `parameters()`. + +We can start by converting both the 'TranslatedNewData' and 'OldData' data.frames back into matrix format. We can then bundle 'OldData', 'OriginalParameters' along with our modified NewDescriptions (containing DictionaryLookups) together into a new 'flowframe object' + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 flatten +#' @importFrom flowCore parameters keyword +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + NewKeywords <- flatten(Dictionaries) + NewDescriptions <- c(OriginalDescription, NewKeywords) + + TranslatedNewData <- KeywordTranslate(data=NewData, DictionaryList=Dictionaries) + + NewDataMatrix <- as.matrix(TranslatedNewData) + OldDataMatrix <- as.matrix(OldData) + + new_fcs <- new("flowFrame", exprs=OldDataMatrix, parameters=OriginalParameters, + description=NewDescriptions) + + return(new_fcs) +} +``` + +```{r} +Data <- Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) + +Data +``` + +## Calm before the Storm + +Looking at the flowFrame output, we can see that the exprs has updated as the flowframe print output shows 6000 cells are present. Likewise, description now list having 480 keywords. However, the old parameters slot (with all it's quirky $P01-style numbering) remains intact. + +Glancing back at our [starting-mental-sketch](/course/10_Downsampling/concatenate.qmd#sketching-a-plan), we know that each of our new keyword columns should have a corresponding $Pvalue row entry in `parameters()` data slot, ideally starting off the last existing one to avoid accidentally overwriting existing data. + +From our exploration during [Week 03](/course/03_InsideFCSFile/index.qmd#parameters), we saw that for each of these '$P30'-style rownames, several description/keywords were added that provide information on voltage, range, name, whether LIN or LOG, etc. These in turn are important if we want minimal issue opening our concatenated .fcs files after the fact with commercial software. + +## Helper Function - ParameterUpdate + +Having done a bit of cleanup within `Concatenate()` our two existing objects include a reassembled flowframe ('new_fcs') and our NewDataMatrix we want to append to finish appending to `exprs()` (and update the `parameters()` and `keywords()` slots as appropiate) + +To get started on this last major hurdle, lets write out a new helper function, `ParameterUpdate()`, and create arguments to take both these objects as the starting inputs for the function. + +```{r} +#' Internal for Concatenate, creates the new parameter +#' data rows needed to properly integrate new keyword columns +#' in exprs matrix +#' +#' @param flowFrame A flowframe object (source of the +#' original parameters that will be modified) +#' @param NewColumns A matrix containing the new keyword columns +#' that will be appended to the exprs matrix, that need +#' to be represented in parameters as new row entries. +ParameterUpdate <- function(flowFrame, NewColumns){ + # Code Goes Here, hurray? +} +``` + +Our first order (especially if our goal is to generalize the new column integration as rows in parameters data slot) is to identify how many columns we are working with, their names, and how many existing rows are present within `parameters()` data slot. + +```{r} +#' Internal for Concatenate, creates the new parameter +#' data rows needed to properly integrate new keyword columns +#' in exprs matrix +#' +#' @param flowFrame A flowframe object (source of the +#' original parameters that will be modified) +#' @param NewColumns A matrix containing the new keyword columns +#' that will be appended to the exprs matrix, that need +#' to be represented in parameters as new row entries. +#' +#' @importFrom Biobase pData +#' +ParameterUpdate <- function(flowFrame, NewColumns){ + NewColumnLength <- ncol(NewColumns) + NewColumnNames <- colnames(NewColumns) + OldParameters <- pData(parameters(flowFrame)) + return(OldParameters) +} +``` + +Having retrieved the OldParameters, using `rownames()` we can retrieve the existing '$P30' style names. We can remove the '$P' portion using `gsub()`, convert back into a numeric via `as.integer()`, and run `max()` to get back the current final row. From there, we can just add 1 to get the row number we will need to use for the rowname for our first new keyword that needs to be added in. + +```{r} +#' Internal for Concatenate, creates the new parameter +#' data rows needed to properly integrate new keyword columns +#' in exprs matrix +#' +#' @param flowFrame A flowframe object (source of the +#' original parameters that will be modified) +#' @param NewColumns A matrix containing the new keyword columns +#' that will be appended to the exprs matrix, that need +#' to be represented in parameters as new row entries. +#' +#' @importFrom Biobase pData +#' +ParameterUpdate <- function(flowFrame, NewColumns){ + NewColumnLength <- ncol(NewColumns) + NewColumnNames <- colnames(NewColumns) + OldParameters <- pData(parameters(flowFrame)) + NewParameter <- max(as.integer(gsub("\\$P", "", rownames(OldParameters)))) + 1 + + return(NewParameter) +} +``` + +With the first of the new row numbers identified, we can extend this sequence out via `seq()` with a length corresponding to our number of columns. We can then modify this vector by `paste0()` in the '$P' characters infront of each number. + +```{r} +#' Internal for Concatenate, creates the new parameter +#' data rows needed to properly integrate new keyword columns +#' in exprs matrix +#' +#' @param flowFrame A flowframe object (source of the +#' original parameters that will be modified) +#' @param NewColumns A matrix containing the new keyword columns +#' that will be appended to the exprs matrix, that need +#' to be represented in parameters as new row entries. +#' +#' @importFrom Biobase pData +#' +ParameterUpdate <- function(flowFrame, NewColumns){ + NewColumnLength <- ncol(NewColumns) + NewColumnNames <- colnames(NewColumns) + OldParameters <- pData(parameters(flowFrame)) + NewParameter <- max(as.integer(gsub("\\$P", "", rownames(OldParameters)))) + 1 + NewParameter <- seq(NewParameter, length.out = NewColumnLength) + NewParameter <- paste0("$P", NewParameter) + + return(NewParameter) +} +``` + +Finally, we can iterate through the NewColumns, and create a corresponding `parameter()` data entry (with the information for 'name', 'desc', 'range', 'minRange' and 'maxRange'). + +The following code borrows from the `flowCore` packages base R implementation of the process. + +`lapply()` is functionally similar to `map()`, iterating in the NewColumnNames vector. The name is used to subset the according column, with the existing numeric data used to calculate out range, and the min/max range values. This is then assembled into a data.frame row. + +Then `do.call()` in combination with `rbind()` binds all these together into a single data.frame. At this point we modify the `rownames()` by passing it the vector of '$P30' entries starting with the number we had determined. + +```{r} +#' Internal for Concatenate, creates the new parameter +#' data rows needed to properly integrate new keyword columns +#' in exprs matrix +#' +#' @param flowFrame A flowframe object (source of the +#' original parameters that will be modified) +#' @param NewColumns A matrix containing the new keyword columns +#' that will be appended to the exprs matrix, that need +#' to be represented in parameters as new row entries. +#' +#' @importFrom Biobase pData +#' +ParameterUpdate <- function(flowFrame, NewColumns){ + NewColumnLength <- ncol(NewColumns) + NewColumnNames <- colnames(NewColumns) + OldParameters <- pData(parameters(flowFrame)) + NewParameter <- max(as.integer(gsub("\\$P", "", rownames(OldParameters)))) + 1 + NewParameter <- seq(NewParameter, length.out = NewColumnLength) + NewParameter <- paste0("$P", NewParameter) + + UpdatedParameters <- do.call(rbind, lapply(NewColumnNames, function(i){ + vec <- NewColumns[,i] + rg <- range(vec) + data.frame(name = i, + desc = NA, + range = diff(rg) + 1, + minRange = rg[1], + maxRange = rg[2]) + })) + + rownames(UpdatedParameters) <- NewParameter + return(UpdatedParameters) +} +``` + +With this process complete, we now have retrieved the new data.frame rows that we will need to append to the OriginalParameters to account for the new `exprs()` columns. Lets go ahead and integrate `ParameterUpdate()` into `Concatenate()`. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 flatten +#' @importFrom flowCore parameters keyword +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + NewKeywords <- flatten(Dictionaries) + NewDescriptions <- c(OriginalDescription, NewKeywords) + + TranslatedNewData <- KeywordTranslate(data=NewData, DictionaryList=Dictionaries) + + NewDataMatrix <- as.matrix(TranslatedNewData) + OldDataMatrix <- as.matrix(OldData) + + new_fcs <- new("flowFrame", exprs=OldDataMatrix, parameters=OriginalParameters, + description=NewDescriptions) + + NewParameters <- ParameterUpdate(flowFrame=new_fcs, NewColumns=NewDataMatrix) + + return(NewParameters) +} +``` + +```{r} +Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) +``` + +## Appending New Parameters + +With the initial new parameter values generated, the process of integrating these in with existing `parameters()` data is an extension of what we have seen previously. + +From our assembled flowframe (new_fcs), we can extract out the parameters data slot via `parameters()` and `pData()`. We can bind on additional rows using either `rbind()` or `bind_rows()`, before passing the complete version back to new_fcs, overwriting the previous one. + +We similarly do the same adding the new numeric keyword columns to the `exprs()` slot before passing it back to new_fcs as well (accessing its slot via '@'). + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 flatten +#' @importFrom flowCore parameters keyword exprs +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + NewKeywords <- flatten(Dictionaries) + NewDescriptions <- c(OriginalDescription, NewKeywords) + + TranslatedNewData <- KeywordTranslate(data=NewData, DictionaryList=Dictionaries) + + NewDataMatrix <- as.matrix(TranslatedNewData) + OldDataMatrix <- as.matrix(OldData) + + new_fcs <- new("flowFrame", exprs=OldDataMatrix, parameters=OriginalParameters, + description=NewDescriptions) + + NewParameters <- ParameterUpdate(flowFrame=new_fcs, NewColumns=NewDataMatrix) + + pd <- pData(parameters(new_fcs)) + pd <- rbind(pd, NewParameters) + new_fcs@exprs <- cbind(exprs(new_fcs), NewDataMatrix) + pData(parameters(new_fcs)) <- pd + + return(new_fcs) +} +``` + +```{r} +Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) +``` + +As we can see from the output, now both the `exprs()` and `parameters()` slots have been appropiately updated with the new keyword columns. + +## Final Keywords + +Now, all that is left to do is for each new '$P30'-style row number we added to `parameters()`, we need to add in the necessary new $P - style keywords to the `keywords()`/description list. + +Since these were last calculated within `ParameterUpdate()`, we don't already have that information as a vector inside `Concatenate()` function. Consequently, lets retrieve `rownames()` and `keywords()` to have them available for editing. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 flatten +#' @importFrom flowCore parameters keyword exprs +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + NewKeywords <- flatten(Dictionaries) + NewDescriptions <- c(OriginalDescription, NewKeywords) + + TranslatedNewData <- KeywordTranslate(data=NewData, DictionaryList=Dictionaries) + + NewDataMatrix <- as.matrix(TranslatedNewData) + OldDataMatrix <- as.matrix(OldData) + + new_fcs <- new("flowFrame", exprs=OldDataMatrix, parameters=OriginalParameters, + description=NewDescriptions) + + NewParameters <- ParameterUpdate(flowFrame=new_fcs, NewColumns=NewDataMatrix) + + pd <- pData(parameters(new_fcs)) + pd <- rbind(pd, NewParameters) + new_fcs@exprs <- cbind(exprs(new_fcs), NewDataMatrix) + pData(parameters(new_fcs)) <- pd + + new_pid <- rownames(pd) + new_kw <- new_fcs@description + + return(new_pid) +} +``` + +So, for each new '$P' rowname, we need to generate all its respective keyword variants, and integrate them into our 'new_kw' list. + +The easiest way to do this is via a for-loop, creating 'B', 'E', 'N', 'R', 'DISPLAY' and 'TYPE' (and the two flowCore ones) in rapid succession, so that each new_kw has the same keyword variants as any other column. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 flatten +#' @importFrom flowCore parameters keyword exprs +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + NewKeywords <- flatten(Dictionaries) + NewDescriptions <- c(OriginalDescription, NewKeywords) + + TranslatedNewData <- KeywordTranslate(data=NewData, DictionaryList=Dictionaries) + + NewDataMatrix <- as.matrix(TranslatedNewData) + OldDataMatrix <- as.matrix(OldData) + + new_fcs <- new("flowFrame", exprs=OldDataMatrix, parameters=OriginalParameters, + description=NewDescriptions) + + NewParameters <- ParameterUpdate(flowFrame=new_fcs, NewColumns=NewDataMatrix) + + pd <- pData(parameters(new_fcs)) + pd <- rbind(pd, NewParameters) + new_fcs@exprs <- cbind(exprs(new_fcs), NewDataMatrix) + pData(parameters(new_fcs)) <- pd + + new_pid <- rownames(pd) + new_kw <- new_fcs@description + + for (i in new_pid){ + new_kw[paste0(i,"B")] <- new_kw["$P1B"] + new_kw[paste0(i,"E")] <- "0,0" + new_kw[paste0(i,"N")] <- pd[[i,1]] + #new_kw[paste0(i,"V")] <- new_kw["$P1V"] + new_kw[paste0(i,"R")] <- pd[[i,5]] + new_kw[paste0(i,"DISPLAY")] <- "LIN" + new_kw[paste0(i,"TYPE")] <- "Identity" + new_kw[paste0("flowCore_", i,"Rmax")] <- pd[[i,5]] + new_kw[paste0("flowCore_", i,"Rmin")] <- pd[[i,4]] + } + + return(new_kw) +} +``` + +Finally, we can gather the updated `exprs()`, `parameter()` and 'new_kw' objects we modified in this last push, and use them to create a new fully-updated flowframe. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 flatten +#' @importFrom flowCore parameters keyword exprs +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + NewKeywords <- flatten(Dictionaries) + NewDescriptions <- c(OriginalDescription, NewKeywords) + + TranslatedNewData <- KeywordTranslate(data=NewData, DictionaryList=Dictionaries) + + NewDataMatrix <- as.matrix(TranslatedNewData) + OldDataMatrix <- as.matrix(OldData) + + new_fcs <- new("flowFrame", exprs=OldDataMatrix, parameters=OriginalParameters, + description=NewDescriptions) + + NewParameters <- ParameterUpdate(flowFrame=new_fcs, NewColumns=NewDataMatrix) + + pd <- pData(parameters(new_fcs)) + pd <- rbind(pd, NewParameters) + new_fcs@exprs <- cbind(exprs(new_fcs), NewDataMatrix) + pData(parameters(new_fcs)) <- pd + + new_pid <- rownames(pd) + new_kw <- new_fcs@description + + for (i in new_pid){ + new_kw[paste0(i,"B")] <- new_kw["$P1B"] + new_kw[paste0(i,"E")] <- "0,0" + new_kw[paste0(i,"N")] <- pd[[i,1]] + #new_kw[paste0(i,"V")] <- new_kw["$P1V"] + new_kw[paste0(i,"R")] <- pd[[i,5]] + new_kw[paste0(i,"DISPLAY")] <- "LIN" + new_kw[paste0(i,"TYPE")] <- "Identity" + new_kw[paste0("flowCore_", i,"Rmax")] <- pd[[i,5]] + new_kw[paste0("flowCore_", i,"Rmin")] <- pd[[i,4]] + } + + UpdatedParameters <- parameters(new_fcs) + UpdatedExprs <- exprs(new_fcs) + + UpdatedFCS <- new("flowFrame", exprs=UpdatedExprs, + parameters=UpdatedParameters, description=new_kw) + + return(UpdatedFCS) +} +``` + +```{r} +Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status")) +``` + +Congratulations! You have fully-modified a flowframe to integrate in your keywords correctly. All that remains is minor cleanup. + +## Out to FCS + +With our new flowframe built, all that remains is to make a few modifications to any desired keywords (allowing us to tell our concatenated file apart from the original source file that provided these keywords). + +At that point, we can borrow from our previous returnType code for `Downsampling()` to allow us to dictate whether the flowframe should be returned as is, as a data.frame, or written out as a new .fcs file to our storage folder of interest. + +```{r} +#' Concatenates together .fcs files present in the GatingSet on the +#' basis of a given gate +#' +#' @param gs A GatingSet object +#' @param subset The gate from which to retrieve cell counts from +#' @param inverse.transform Whether to revert values back to their +#' original untransformed values before export as an .fcs file, default +#' is set to TRUE +#' @param DownsampleCount The desired number of cells to downsample from +#' each gated population. If value is less than 1, subsets out the +#' equivalent proportion from that specimen +#' @param addon An additional character value to add before .fcs in the GUID +#' keyword to tell the downsampled file apart from the original. +#' @param StorageLocation A file.path to the folder you want to store the new downsampled +#' fcs file to. Default NULL results in .fcs file being stored in current working directory +#' @param returnType Whether to return as a "fcs" file (default), or "flowFrame" or "data.frame" +#' @param desiredCols A vector containing the names of the columns from the pData metadata +#' that need to be added as keywords to the concatenated .fcs file. +#' @param specimenIndex Which specimen in the GatingSet to use as the metadata +#' framework for the new fcs file. Default is set to 1. +#' @param filename Desired name for the concatenated file, default is MyConcatenatedFCS +#' +#' @importFrom Biobase pData +#' @importFrom dplyr select pull bind_rows +#' @importFrom tidyselect all_of +#' @importFrom purrr map map2 flatten +#' @importFrom flowCore parameters keyword exprs +#' @importFrom flowWorkspace gs_pop_get_data +#' +Concatenate <- function(gs, subset, inverse.transform=TRUE, DownsampleCount, + addon, StorageLocation=NULL, returnType="flowFrame", desiredCols, + specimenIndex=1, filename="MyConcatenatedFCS"){ + + Metadata <- pData(gs) + DesiredMetadata <- Metadata |> select(all_of(desiredCols)) + + dataFrameList <- purrr::map(.x=gs, subset=subset, .f=Downsampling, + DownsampleCount=DownsampleCount, addon=addon, returnType="data.frame", + inverse.transform=inverse.transform, StorageLocation=StorageLocation) + + TheFileNames <- DesiredMetadata |> pull(name) + + ExpandedDataframes <- map2(.x=TheFileNames, .y=dataFrameList, + .f=KeywordAppend, metadata=DesiredMetadata) + + CombinedData <- bind_rows(ExpandedDataframes) + + NewData <- CombinedData |> select(all_of(desiredCols)) + OldData <- CombinedData |> select(!all_of(desiredCols)) + + Dictionaries <- map(.x=desiredCols, .f=ColumnToKeyword, data=NewData) + + EventsInTheGate <- flowWorkspace::gs_pop_get_data(gs[[specimenIndex]], subset, + inverse.transform=inverse.transform) + flowFrame <- EventsInTheGate[[1, returnType = "flowFrame"]] + OriginalParameters <- flowCore::parameters(flowFrame) + OriginalDescription <- flowCore::keyword(flowFrame) + + NewKeywords <- flatten(Dictionaries) + NewDescriptions <- c(OriginalDescription, NewKeywords) + + TranslatedNewData <- KeywordTranslate(data=NewData, DictionaryList=Dictionaries) + + NewDataMatrix <- as.matrix(TranslatedNewData) + OldDataMatrix <- as.matrix(OldData) + + new_fcs <- new("flowFrame", exprs=OldDataMatrix, parameters=OriginalParameters, + description=NewDescriptions) + + NewParameters <- ParameterUpdate(flowFrame=new_fcs, NewColumns=NewDataMatrix) + + pd <- pData(parameters(new_fcs)) + pd <- rbind(pd, NewParameters) + new_fcs@exprs <- cbind(exprs(new_fcs), NewDataMatrix) + pData(parameters(new_fcs)) <- pd + new_pid <- rownames(pd) + new_kw <- new_fcs@description + + for (i in new_pid){ + new_kw[paste0(i,"B")] <- new_kw["$P1B"] + new_kw[paste0(i,"E")] <- "0,0" + new_kw[paste0(i,"N")] <- pd[[i,1]] + #new_kw[paste0(i,"V")] <- new_kw["$P1V"] + new_kw[paste0(i,"R")] <- pd[[i,5]] + new_kw[paste0(i,"DISPLAY")] <- "LIN" + new_kw[paste0(i,"TYPE")] <- "Identity" + new_kw[paste0("flowCore_", i,"Rmax")] <- pd[[i,5]] + new_kw[paste0("flowCore_", i,"Rmin")] <- pd[[i,4]] + } + + UpdatedParameters <- parameters(new_fcs) + UpdatedExprs <- exprs(new_fcs) + + UpdatedFCS <- new("flowFrame", exprs=UpdatedExprs, + parameters=UpdatedParameters, description=new_kw) + + AssembledName <- paste0(filename, ".fcs") + UpdatedFCS@description$GUID <- AssembledName + UpdatedFCS@description$`$FIL` <- AssembledName + #UpdatedFCS@description$CREATOR <- "CytometryInR_2026" + #UpdatedFCS@description$GROUPNAME <- filename + #UpdatedFCS@description$TUBENAME <- filename + #UpdatedFCS@description$USERSETTINGNAME <- filename + #Date <- Sys.time() + #Date <- as.Date(Date) + #UpdatedFCS@description$`$DATE` <- Date + + if (is.null(StorageLocation)){StorageLocation <- getwd()} + + StoreFCSFileHere <- file.path(StorageLocation, AssembledName) + + if (returnType == "fcs"){ + flowCore::write.FCS(UpdatedFCS, + filename = StoreFCSFileHere, delimiter="#") # Write out .fcs file + } else if (returnType == "data.frame"){ + return(Downsampled_DataFrame) #Return data.frame without metadata + } else { + return(UpdatedFCS) #All other criterias return a flowFrame with metadata + } +} +``` + +```{r} +#| eval: FALSE +Concatenate(gs=SFC_GatingSet, subset="CD4+", addon="CD4", DownsampleCount=2000, + desiredCols=c("name", "condition", "infant_sex", "HEU_status"), returnType="fcs") +``` + +Y colorín, colorado, este cuento se ha acabado. This was the process by which `Concatenate()` was written. To see how it was implemented in a workflow, click here to return to [Week 10](/course/10_Downsampling/index.qmd#concatenate-code) + +# Take Away + +First off, congratulations, you made it through this extensive walk-through. As you can tell, .fcs file internals as implemented within a flowframe are not for the faint-of-heart, with there multiple moving pieces across the three slots, and the small army of helper functions needed for interconversions. + +However, there is value in at least understanding the basic ideas and interconnections between them. In the current implementation, everything remains correctly matched, which allows for the .fcs file you generate to seamlessly switch between R and commercial cytometry software without taking a performance hit by needing to reset transformations and scales. + +Additionally, the ability to encode important information on a cell-by-cell basis and back-translate from the description list will prove immensely useful [later on](/Schedule.qmd#validating-algorthmic-tools) when we kick-the-tires of the various unsupervised analysis algorithms and try to determine how they actually work behind the scenes. + +![](images/TakeAway2.jpg) + +::: {style="text-align: right;"} +[![AGPL-3.0](https://www.gnu.org/graphics/agplv3-with-text-162x68.png)](https://www.gnu.org/licenses/agpl-3.0.en.html) [![CC BY-SA 4.0](https://licensebuttons.net/l/by-sa/4.0/88x31.png)](http://creativecommons.org/licenses/by-sa/4.0/) +::: \ No newline at end of file diff --git a/course/10_Downsampling/homeworks/README.md b/course/10_Downsampling/homeworks/README.md new file mode 100644 index 00000000..2818b5ef --- /dev/null +++ b/course/10_Downsampling/homeworks/README.md @@ -0,0 +1,5 @@ +# Turning In Optional Take-Home Problems + +This folder is for the use of submitting your completed Take-Home Problems for evaluation by course instructors. Please see [Getting Help](/course/00_Homeworks/index.qmd) walkthrough for more detailed instructions. + +Within your branch, inside this "homeworks" folder, create a new folder (name it with your GitHub username). Then copy all files you will be submitting within your folder. Then commit the change to git, and push to GitHub. See [Getting Help](/course/00_Homeworks/index.qmd)for details on submitting the pull request to the UMGCCCFCSR/CytometryInR homework branch. \ No newline at end of file diff --git a/course/10_Downsampling/images/TakeAway2.jpg b/course/10_Downsampling/images/TakeAway2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c2270523c0746e7fc7ec229574f0125b35305ca0 GIT binary patch literal 48316 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 literal 0 HcmV?d00001 diff --git a/course/11_DataForStats/data/AllThePlots.pdf b/course/11_DataForStats/data/AllThePlots.pdf index a3227a258ed474b628c85670d733a4230d9fb30e..12d65fbb99d46c05f6f340ef64b58cfe2ca642dd 100644 GIT binary patch delta 49 ycmZoV%Gh+2ae}#uv7wQnv4ydbCYQc%eu_(CNveW|i=bcMrH=4Fu9G=bcMrH=4Fu9G - + @@ -175,22 +175,22 @@

On this page

  • Manual and Automated Gating
  • It’s Raining Functions!
  • Downsampling and Concatenation
  • -
  • Retrieving data for Statistics
  • +
  • Retrieving Data for Statistics
  • +
  • Conference Break 2
  • Spectral Signatures
  • Similarities and Hotspots
  • Unmixing in R
  • Cleaning Algorithms
  • Clustering Algorithms
  • -
  • Conference Break 2
  • Normalization: Batch Effect or Real Biology
  • Dimensionality Visualization
  • Annotating Unsupervised Clusters
  • +
  • Conference Break 3
  • The Art of GitHub Diving
  • XML Files All The Way Down
  • Utilizing Bioconductor packages
  • Building your First R package
  • Everyone Get’s a Quarto Website
  • -
  • Conference Break 3
  • Reproducibility and Replicability
  • Open Source Licenses
  • Validating Algorthmic Tools
  • @@ -266,7 +266,7 @@

    Applying Transfor

    Conference Break 1

    -

    No class week of March 30, 2026. If you are attending the ABRF conference, track me down at the Complex Data Analysis in Flow Cytometry: Navigating the Landscape talk on Monday, March 30th at 4:30 PM.

    +

    No class week of March 30, 2026. If you are attending the ABRF conference, track me down at the Complex Data Analysis in Flow Cytometry: Navigating the Landscape talk on Monday, March 30th at 4:30 PM.



    @@ -284,13 +284,18 @@

    It’s Raining Funct

    Downsampling and Concatenation

    -

    Week 10: April 20, 2026 Within this tenth session, we will expand on our growing understanding of GatingSets, functions and fcs file internals to write a function to downsample your fcs files to a desired number (or percentage) of cells for a given cell population. We will additionally learn how to concatenate these downsampled files together, and save them to a new .fcs file in ways that the metadata can be read by commercial software without the scaling being widely thrown off.

    +

    Week 10: April 27, 2026 Within this tenth session, we will expand on our growing understanding of GatingSets, functions and fcs file internals to write a function to downsample your fcs files to a desired number (or percentage) of cells for a given cell population. We will additionally learn how to concatenate these downsampled files together, and save them to a new .fcs file in ways that the metadata can be read by commercial software without the scaling being widely thrown off.



    -

    Retrieving data for Statistics

    +

    Retrieving Data for Statistics

    -

    Week 11: April 27, 2026 Leveraging the increased familiarity working with the various packages this far in the course, in this eleventh session we will retrieve summary statistics for the gates within our GatingSet, and programmatically derrive out tidy data.frames for use in statistical analyses typically used by many Immunologist. In the process, we add a couple additional plot types to our ggplot2 arsenal to hold in reserve should Prism prices go up again.

    +

    Week 11: May 04, 2026 Leveraging the increased familiarity working with the various packages this far in the course, in this eleventh session we will retrieve summary statistics for the gates within our GatingSet, and programmatically derrive out tidy data.frames for use in statistical analyses typically used by many Immunologist. In the process, we add a couple additional plot types to our ggplot2 arsenal to hold in reserve should Prism prices go up again.

    +



    +
    +
    +

    Conference Break 2

    +

    If you are attending the Cyto conference, track me down at my talks (Open-Source automation on June 7, 10:30-11:30AM at Grand Ballroom; and Semi-supervised pipeline on June 9, 10:30-11:45AM atRoom 2DEF) or poster (grab some Cytometry in R course hex stickers!)



    @@ -323,11 +328,6 @@

    Clustering Algorithm

    Week 16: As part of this session, we venture away from supervised and semi-supervised analyses to explore unsupervised clustering approaches, namely FlowSOM and Phenograph. We will compare outcomes depending markers included, transformations applied, and panel used to gain a greater familiarity with how they work. We wrap up by investigating ways to visualize marker expression of cells ending up in each cluster, and how to backgate them to our manual gates.



    -
    -

    Conference Break 2

    -

    No class week of June 8, 2026. If you are attending the Cyto conference, track me down at my talks (Open-Source automation on June 7, 10:30-11:30AM at Grand Ballroom; and Semi-supervised pipeline on June 9, 10:30-11:45AM atRoom 2DEF) or poster (grab some Cytometry in R course hex stickers!)

    -



    -

    Normalization: Batch Effect or Real Biology

    @@ -346,6 +346,11 @@

    Annotatin

    Week 19: In the course of this session, we explore ways to scale our efficiency in figuring out what an unsupervised cluster of cells may be, by employing several annotation packages. We explore how these work under the hood in their decision making process, and how to link them to reference data from external repositories for additional evaluation.



    +
    +

    Conference Break 3

    +

    No class week of August 10, 2026. If you are attending the BioC conference, track me down at my short talk on Monday, August 10th from 11:00-12:15pm.

    +



    +

    The Art of GitHub Diving

    @@ -376,11 +381,6 @@

    Everyone Ge

    Week 24: In this session, we will extend the knowledge of .R and .qmd files you have gained from the course and extend them to create your own website using Quarto. We discuss the additional files that are required, how to customize and render the website locally, and finally set up Quarto Pub or GitHub Pages website that we are to access online.



    -
    -

    Conference Break 3

    -

    No class week of August 10, 2026. If you are attending the BioC conference, track me down at my talk/poster.

    -



    -

    Reproducibility and Replicability

    @@ -830,8 +830,8 @@

    Future Directions

    diff --git a/docs/course/00_BonusContent/Immport/images/index.html b/docs/course/00_BonusContent/Immport/images/index.html index 90cd2fcf..2bf4b809 100644 --- a/docs/course/00_BonusContent/Immport/images/index.html +++ b/docs/course/00_BonusContent/Immport/images/index.html @@ -321,7 +321,7 @@ +
    @@ -820,7 +837,7 @@

    cytoset

    Formal class 'cytoset' [package "flowWorkspace"] with 3 slots
       ..@ pointer  :<externalptr> 
    -  ..@ frames   :<environment: 0x55821271b6f0> 
    +  ..@ frames   :<environment: 0x55d54790e490> 
       ..@ phenoData:Formal class 'AnnotatedDataFrame' [package "Biobase"] with 4 slots
       .. .. ..@ varMetadata      :'data.frame': 0 obs. of  1 variable:
       .. .. .. ..$ labelDescription: chr(0) 
    @@ -870,7 +887,7 @@ 

    Interconverting

    ConvertedToCytoframe <- flowFrame_to_cytoframe(flowFrame)
     ConvertedToCytoframe
    -
    cytoframe object 'file494632298458'
    +
    cytoframe object 'file501a57eb125b'
     with 10000 cells and 43 observables:
                    name      desc       range  minRange    maxRange
     $P1            Time        NA     878.809         0     878.809
    @@ -1098,7 +1115,7 @@ 

    System Time

    })
       user  system elapsed 
    -  0.493   0.000   0.494 
    + 0.546 0.000 0.548

    Alternatively, if we install the bench package, we can use the mark function to evaluate how long it takes on average across numerous iterations.

    @@ -1115,7 +1132,7 @@

    System Time

    # A tibble: 1 × 6
       expression                             min median `itr/sec` mem_alloc `gc/sec`
       <bch:expr>                           <bch> <bch:>     <dbl> <bch:byt>    <dbl>
    -1 Test <- flowjo_to_gatingset(ws = ws… 491ms  494ms      2.02    13.3KB        0
    +1 Test <- flowjo_to_gatingset(ws = ws… 496ms 503ms 1.92 13.3KB 0

    diff --git a/docs/course/05_GatingSets/slides.html b/docs/course/05_GatingSets/slides.html index 9d0ecfa1..24aee3aa 100644 --- a/docs/course/05_GatingSets/slides.html +++ b/docs/course/05_GatingSets/slides.html @@ -664,7 +664,7 @@

    flowSet

    str(flowSet)
    Formal class 'flowSet' [package "flowCore"] with 2 slots
    -  ..@ frames   :<environment: 0x560eddddebb0> 
    +  ..@ frames   :<environment: 0x563e34904918> 
       ..@ phenoData:Formal class 'AnnotatedDataFrame' [package "Biobase"] with 4 slots
       .. .. ..@ varMetadata      :'data.frame': 1 obs. of  1 variable:
       .. .. .. ..$ labelDescription: chr "Name"
    @@ -1127,7 +1127,7 @@ 

    cytoset

    Formal class 'cytoset' [package "flowWorkspace"] with 3 slots
       ..@ pointer  :<externalptr> 
    -  ..@ frames   :<environment: 0x560ee0878ec8> 
    +  ..@ frames   :<environment: 0x563e3739bcd0> 
       ..@ phenoData:Formal class 'AnnotatedDataFrame' [package "Biobase"] with 4 slots
       .. .. ..@ varMetadata      :'data.frame': 0 obs. of  1 variable:
       .. .. .. ..$ labelDescription: chr(0) 
    @@ -1300,7 +1300,7 @@ 

    Interconverting

    ConvertedToCytoframe <- flowFrame_to_cytoframe(flowFrame)
     ConvertedToCytoframe
    -
    cytoframe object 'file489834d193d5'
    +
    cytoframe object 'file4f8332417c60'
     with 10000 cells and 43 observables:
                    name      desc       range  minRange    maxRange
     $P1            Time        NA     878.809         0     878.809
    @@ -1952,7 +1952,7 @@ 

    System Time

    })
       user  system elapsed 
    -  0.507   0.012   0.519 
    + 0.523 0.008 0.530
    @@ -1992,7 +1992,7 @@

    System Time

    # A tibble: 1 × 6
       expression                             min median `itr/sec` mem_alloc `gc/sec`
       <bch:expr>                           <bch> <bch:>     <dbl> <bch:byt>    <dbl>
    -1 Test <- flowjo_to_gatingset(ws = ws… 510ms  516ms      1.94    13.3KB        0
    +1 Test <- flowjo_to_gatingset(ws = ws… 499ms 506ms 1.94 13.3KB 0
    diff --git a/docs/course/06_Visualizing/index.html b/docs/course/06_Visualizing/index.html index fb69b6be..44b397a9 100644 --- a/docs/course/06_Visualizing/index.html +++ b/docs/course/06_Visualizing/index.html @@ -358,7 +358,7 @@ +