|
![]() tidybreed
Breeding program simulation, backed by DuckDB, driven by |
📖 Documentation: https://austin-putz.github.io/tidybreed/
[!IMPORTANT]
tidybreedis very much in the “alpha testing” phase, I do not recommend building on it just yet. I am however looking for valuable feedback to finish the API prior to version 1.0.0
A pipe-friendly (%>% or |>) R package for breeding program simulation backed by DuckDB. Design large-scale genomic simulations without running out of memory — all data lives on disk in a DuckDB database and is queried lazily via dplyr.
It is easier to show than explain. The API often resembles the following steps:
pop |> # stores 'db_conn' connection to your .duckdb file
get_table("ind_meta") |> # stores 1 record per individual to track pedigree, sex, line, any user defined fields
filter(
sex == "M", # subset to males only
line_name == "Angus", # subset to Angus genetic line only
birth_date == Sys.Date(), # user added column to track birth dates
status == "calf" # user defined column to track animal 'status'
) |>
add_phenotype(
c("birth_weight", "stillborn"), # list of phenotypes to 'collect' on filtered individuals
phenotype_date = Sys.Date() # user added column, added to ind_phenotype table
)Steps:
-
1️⃣ User identifies a table to pass via
get_table() -
2️⃣ User filters/subsets the table to identify rows/records/animals
-
filter()is fromdplyr, users often already know this package well
-
-
3️⃣ User passes that table to either a
define_*()function oradd_*()function-
define_*()➡️ functions often add records as meta data -
add_*()➡️ functions often add data on individuals (e.g. TBV, EBV, Phenotype, Genotype, etc)
-
-
4️⃣ New records are stored on disk to your
.duckdbfile (database)- database allows for efficient storage and IO
- users allowed to insert rows themselves without me having to provide ‘helper functions’
[!NOTE]
tidybreeduses a lightweight S3 object as a handle to a DuckDB-backed breeding program. Rather than storing simulation state in R objects, the breeding program is represented as a persistent relational database. Functions operate by querying subsets of the database, performing calculations in R or C++, and writing results back to the database. The S3 object primarily manages access to the underlying database rather than storing biological state.
Motivation
I’ve both struggled to learn other simulation software and also found them extremely limiting for several reasons. tidybreed started as a fresh conceptual idea on simulation software to allow users to easy store custom data (tables + columns) and easily manipulate any data they want (metadata or otherwise).
I also try to make it as intuitive and easy to learn as possible. Users can utilize many functions they already know from tidyverse such as filter() or slice_*() functions (for selection for instance).
License
MIT License
tidybreed is released under the MIT License. You are free to use, modify, and distribute it, including in commercial and proprietary projects, provided the copyright notice is retained.
[!CAUTION] NO WARRANTY — This software is provided as-is, without warranty of any kind, express or implied. The authors accept no liability for any damages or losses arising from its use.
Design
| Main | Reason or Description | |
|---|---|---|
| R | → | Standard for most scientists; flexible design of custom breeding programs |
| DuckDB | → | Columnar, embedded, no server needed; handles datasets larger than RAM |
| Pipe everything | → | Filter individuals, add phenotypes/genotypes/EBVs/index with tidyverse verbs |
| Customizable | → | Add your own table, any column within any table with mutate_table(); query with standard dplyr and SQL will function in the background. DBI allows users to interact with the database directly in any way they want. |
-
Databases ✅ are efficient enough for our large simulations, both in speed and memory savings. Users can pull from different tables using the
get_table()function. We no longer need to fill up our RAM with data from generation 1 in a long term simulation pipeline. -
SQL ✅ can add any custom table or field/column of any type, allowing users to define a DATE that would allow almost near perfect “digital twins”. My understanding is DuckDB was written very efficiently with C++ mostly. Users can
DBItheir way to modifying any table at any time and often do not have to worry about “breaking”tidybreedif I build the functions correctly and truly modular. -
R ✅ is great for many things and has become a standard for us in research, however memory is a massive issue compared to python/julia/etc and it’s simply not efficient enough for massive datasets of this size. This will be moderated by the use of SQL and C++ with
Rcppand many operations will (hopefully) never be pulled into R, but can be orchestrated through R. -
Pipes ✅ allow users to insert
filtersteps that is critical for most operations to “point” to certain individuals or groups to calculate TBVs, EBVs, phenotypes, or to extract data such as genotypes/QTL.
What I try to AVOID:
-
❌ Storing metadata
- such a n_loci or such, a truly composable/modular system using SQL can easily calculate this from a table as needed with very little “cost” to timing of the simulation
- metadata is stored, just in tables such as the variance components but other fixed metadata is silly most of the time when it can be taken from a table at any moment in time cheaply
-
❌ helper/wrapper functions
- any function that exists, but the user could easily do it themselves
- e.g. a function to design a specific mating scheme
- users love them, developers love them, but they are almost always a bad idea leading to spaghetti code long term.
-
Why? 👉 There is a nearly infinite number of ways users could mate a list of dams to a list of sires, I cannot program every possible option for users, therefore I designed a simple tibble as input and users just list exactly what offspring they want per row (sire, dam, sex, etc). No helper function needed to create the mating design, you can do this yourself easily without a function using
sample(),rep(), etc. and building a tibble yourself. This also avoids me having to break your code later changing functions and arguments.
-
❌ generation 💀 🆘 🚫
- generation was a leftover archaic artifact from QMSim and the original AlphaSim I think
- generation has no useful application besides making some simulations easier to follow or design in a generic/fixed way
- generation only exists in simple simulations and selection experiments, not real breeding programs (for the most part)
- users can add a field/column called “gen” or “generation” within
tidybreedin every table they want, however I do not enforce you to use such a silly idea unless doing some simple simulations and need to create something quick/easy
Installation
Install pak then install tidybreed from GitHub:
WINDOWS People ➡️ YOU NEED RTools SETUP, please read below
install.packages("pak", repos = "https://packagemanager.posit.co/cran/latest")
library(pak)
pak::pak("austin-putz/tidybreed")
library(tidybreed)System requirements: a C++ compiler
As of v0.53.0, tidybreed ships compiled C++17 code (the recombination kernel used by add_offspring()). Installing from GitHub is a source install, so each machine needs a working C++ toolchain. tidybreed’s header-only build dependencies (Rcpp, dqrng, BH, sitmo) are pulled in automatically by pak — no system libraries are required — but the compiler itself must be present. If it is missing, the install stops with a platform-specific message telling you exactly what to install.
| Platform | What to install | Command / link |
|---|---|---|
| Windows 11 / 10 | Rtools matching your R version (Rtools45 for R 4.5.x, Rtools44 for R 4.4.x) | Download & run the installer from https://cran.r-project.org/bin/windows/Rtools/, then restart R. Rtools puts g++/make on PATH for you. |
| Ubuntu / Debian |
r-base-dev + build-essential
|
sudo apt-get update && sudo apt-get install -y r-base-dev build-essential |
| Fedora / RHEL / Rocky |
gcc-c++ + make
|
sudo dnf install -y gcc-c++ make |
| macOS |
Xcode Command Line Tools (provides clang++) |
xcode-select --install |
[!NOTE] macOS: the Command Line Tools alone are enough — you do not need the full Xcode app. If
clang++was already installed with a previous R/dev setup, no action is needed. (Apple Silicon and Intel are both supported; the kernel is portable C++17 with no architecture-specific code.)
Once the toolchain is in place, re-run the pak::pak(...) command above. To force a fresh recompile after adding a compiler:
pak::pak("austin-putz/tidybreed", upgrade = TRUE)To force the pure-R reference kernel (e.g. to cross-check results, or on a machine with no compiler where you install a binary build), set an environment variable before loading the package:
Sys.setenv(TIDYBREED_KERNEL = "r") # "auto" (default) uses the compiled C++ kernel[!WARNING] Pre-
v1.0.0packages are considered beta and subject to breaking changes. Pin your version to avoid surprises.
packageVersion("tidybreed") # check installed version, e.g. '0.46.1'
pak::pak("austin-putz/tidybreed@v0.46.1") # pin to a specific releaseBrowse all releases on the GitHub Releases page.
Global Options
Set package-wide options at the top of your script (or in a startup / config script) so simulations can be parameterized without changing function calls throughout the codebase. Every option has a built-in default and is entirely optional — set only the ones you need. Options prefixed tidybreed. are read by open_pop() (folder layout) and archive_replicate() (multi-replicate archiving).
options(
tidybreed.pop_name = "my_project",
tidybreed.base_dir = "~/path/to/project/",
tidybreed.output = "tidybreed_output",
tidybreed.scenario = "baseline_scenario",
tidybreed.tools = c("blupf90", "plink", "JWAS"),
tidybreed.db_name = "sim.duckdb",
tidybreed.replicate = 1L,
tidybreed.archive_path = "~/path/to/project/archive/",
tidybreed.db_name_archive = "baseline_scenario_all_reps.duckdb",
tidybreed.quiet = FALSE
)-
tidybreed.pop_name— Label stored on the pop object (pop$pop_name); shown inprint(pop). Default:"sim". Purely descriptive — does not affect file paths. -
tidybreed.base_dir— Root folder (layer 1); default:getwd() -
tidybreed.output— Output subfolder name (layer 2); default:"tidybreed_output" -
tidybreed.scenario— Scenario subfolder (layer 3). DefaultNULLauto-generates aYYYYMMDD_HHMMSSfolder so runs never overwrite each other. Set explicitly (e.g."baseline") to reuse the same folder, such as in an HPC array job. -
tidybreed.tools— Character vector of tool subfolders created at layer 4 (e.g.c("blupf90", "plink")). DefaultNULLskips tool folder creation. -
tidybreed.db_name— Working DuckDB file name; default:"sim.duckdb". Use":memory:"for an in-memory database (skips all folder creation — useful for tests). -
tidybreed.replicate— Integer replicate number stamped on archive rows; default:1L. Auto-increments after each successfularchive_replicate()call. -
tidybreed.archive_path— Directory for the archive DuckDB file. DefaultNULLplaces the archive next to the working database. -
tidybreed.db_name_archive— Archive DuckDB file name (e.g."all_reps.duckdb"). DefaultNULLskips archiving entirely. -
tidybreed.quiet— Suppress the startup banner onlibrary(tidybreed); default:FALSE.
[!NOTE] Archive path resolution (
archive_replicate()) — first non-NULLwins:
- Explicit
archive_pathargument passed toarchive_replicate().file.path(tidybreed.archive_path, tidybreed.db_name_archive)— when both options are set.file.path(dirname(pop$db_path), tidybreed.db_name_archive)— archive lands next to the working database.tidybreed.db_name_archiveisNULL— no archive written; only reset phases run.
Directory Layout
The recommended project structure separates scenario configs, per-scenario simulation databases, and the merged archive:
~/projects/swine/ ← tidybreed.base_dir
│
├── scenarios/ ← one YAML per scenario
│ ├── baseline.yaml
│ └── scenario_b.yaml ← next scenario to run
│
├── results/ ← merged multi-replicate archive
│ └── baseline_scenario_all_reps.duckdb ← written by archive_replicate()
│
└── tidybreed_output/ ← tidybreed.output
├── baseline/ ← tidybreed.scenario = "baseline"
│ ├── sim.duckdb ← tidybreed.db_name
│ ├── blupf90/ ← subfolder per tool in tidybreed.tools
| |-- JWAS/
│ └── plink/
│
└── scenario_b/ ← tidybreed.scenario = "scenario_b"
├── sim.duckdb
├── blupf90/
| |-- JWAS/
└── plink/
Scenario YAML format
Save scenario parameters in scenarios/baseline.yaml and read them at the top of your script with yaml::read_yaml():
# scenarios/baseline.yaml
pop_name: swine
base_dir: ~/projects/swine/
output: tidybreed_output
scenario: baseline
db_name: sim.duckdb
tools:
- blupf90
- plink
cfg <- yaml::read_yaml("scenarios/baseline.yaml")
options(
tidybreed.pop_name = cfg$pop_name,
tidybreed.base_dir = cfg$base_dir,
tidybreed.output = cfg$output,
tidybreed.scenario = cfg$scenario,
tidybreed.db_name = cfg$db_name,
tidybreed.tools = cfg$tools,
tidybreed.replicate = as.integer(Sys.getenv("REP", 1)) # set per run
)Swap scenarios by pointing to a different YAML file; the rest of the script stays identical.
[!TIP] I HIGHLY encourage the use of
purrr::chuck()to pull from the stored yaml structure (cfgabove). Unlike$, it throws an error when the element is missing instead of returningNULLand silently breaking your pipeline without a clear warning.
Core Concept
Every function accepts and returns a tidybreed_pop object — a thin wrapper around a DuckDB connection. Chain operations with |> (or %>%):
pop <- pop |>
get_table("ind_meta") |> # identify which DB table to work with
filter( # filter to the individuals you want
sex == "M", # filter to males
gen == 1L # filter to generation 1 (this was "user defined")
) |>
add_phenotype("ADG") # add records to ind_phenotypeAll tables are queryable at any time. Use collect() to pull results into R as a tibble.
Simulation Workflow
1. Open a population
open_pop() creates or re-opens a DuckDB-backed population object. Use clean = TRUE to start fresh.
2. Define the genome
pop <- pop |>
define_genome(
n_loci = 10000, # total number of loci (SNP + QTL)
n_chr = 18, # number of chromosomes
chr_len_Mb = 50, # physical length per chromosome, Mb (scalar or length n_chr)
cM_per_Mb = 1.0 # genetic-map rate (scalar or length n_chr)
)
pop |> get_table("genome_meta") # 1 row per locus — physical map (pos_bp)
pop |> get_table("genome_map") # 1 row per locus — genetic map (pos_cM)define_genome() is the single call that builds the genome. It writes seven tables in one transaction — genome_meta, genome_map, ind_haplotype, ind_genotype, ind_crossover, chr_inheritance, and chr_recombination — and rolls all of them back if anything fails, so a bad call leaves the database exactly as it found it and the same pop can be reused for a corrected call.
Physical and genetic coordinates live in separate tables. genome_meta.pos_bp is the single physical source of truth (BIGINT, 1-based, VCF/PLINK convention); pos_cM = pos_bp / 1e6 * cM_per_Mb is written to genome_map as the default map (sex = NULL, line_name = NULL, map_name = "default"). Sex- and line-specific maps are added later as extra genome_map rows — rows, never a schema change. Everything distance-driven (founder LD, recombination) reads the resolved map, so cM_per_Mb sets the crossover rate: a 50 Mb chromosome at cM_per_Mb = 1.0 is 50 cM, i.e. ~0.5 crossovers per meiosis.
Optional arguments:
| Argument | Purpose |
|---|---|
locus_names |
Custom locus names (length n_loci). Must be unique, non-NA, non-empty. Default Locus_1 … Locus_n. |
chr_names |
Custom chromosome names (length n_chr), same rules. Default "1" … "n". |
recombines_M / recombines_F
|
Genome-wide per-parent-sex recombination defaults (both TRUE). Set one to FALSE for a whole-genome achiasmatic sex (e.g. Drosophila males). Seeded into chr_recombination. |
[!IMPORTANT]
define_genome()may be called once per population. It errors if any of the seven genome tables already exists — there is no partial re-definition.n_lociandn_chrmust be whole numbers withn_loci >= n_chr, andchr_len_Mb/cM_per_Mbmust be finite and strictly positive. All of these are checked before anything is written.
Per-chromosome exceptions (sex chromosomes, organelles) are set afterwards with define_chromosome(), which writes chr_inheritance rows (keyed by offspring sex) or chr_recombination rows (keyed by producing-parent sex) — one concern per call.
3. Define founder haplotypes
Generate haplotype pools for each genetic line. Six methods are available: four that draw a per-locus allele frequency and sample alleles independently (no LD), and two that build LD along the genetic map.
# Uniform allele frequencies between min and max (no LD)
pop <- pop |>
define_founder_haplotypes(
line_name = "A",
n_haplotypes = 1000,
method = "uniform",
min_allele_freq = 0.01,
max_allele_freq = 0.99
)
# Fixed frequency — realized exactly at every locus (no LD)
pop <- pop |>
define_founder_haplotypes(line_name = "B", n_haplotypes = 1000,
method = "fixed", allele_freq = 0.5)
# Beta(0.5, 0.5) — U-shaped MAF spectrum, biologically realistic (no LD)
pop <- pop |>
define_founder_haplotypes(line_name = "C", n_haplotypes = 1000,
method = "beta", beta_shape1 = 0.5, beta_shape2 = 0.5)
# Balding-Nichols (FST-based drift around an ancestral mean; no LD)
pop <- pop |>
define_founder_haplotypes(line_name = "D", n_haplotypes = 1000,
method = "balding_nichols", fst = 0.1, mean_allele_freq = 0.5)
# Mosaic — LD via Li-Stephens haplotype-block copying
pop <- pop |>
define_founder_haplotypes(line_name = "E", n_haplotypes = 1000,
method = "mosaic", n_templates = 32, template_switch_rate = 1.0)
# Gaussian copula — LD via AR(1) latent normal (fast, unquantized MAF)
pop <- pop |>
define_founder_haplotypes(line_name = "F", n_haplotypes = 1000,
method = "gaussian_copula", ld_decay_rate = 0.25)Each method owns its own arguments, and passing one to the wrong method is a hard error naming the method it belongs to:
| Method | Arguments | LD |
|---|---|---|
"uniform" (default) |
min_allele_freq (0.01), max_allele_freq (0.99) |
none |
"fixed" |
allele_freq (0.5) |
none |
"beta" |
beta_shape1 (0.5), beta_shape2 (0.5) |
none |
"balding_nichols" |
fst (0.1), mean_allele_freq (0.5) |
none |
"mosaic" |
n_templates, template_switch_rate (1.0 per cM) |
blocks |
"gaussian_copula" |
ld_decay_rate (1.0; ρ = exp(−λ·d_cM)) |
AR(1) decay |
All four frequency-based methods also accept exact_freq. When TRUE, each locus gets exactly round(p × n_haplotypes) copies of the 1-allele on an independently drawn random subset of haplotypes, so the realized pool frequency equals the target with no binomial scatter (and still no LD — each locus draws its own subset). When FALSE, alleles are independent Bernoulli(p) draws and realized frequencies scatter around p with sd sqrt(p(1-p)/n_haplotypes).
exact_freq defaults to TRUE for method = "fixed" (a “fixed” frequency that drifts is not fixed) and FALSE for the distribution-based methods, where drawing a frequency and then sampling binomially is the correct generative model. Set it TRUE on those methods when you want a drift-free base frequency. Frequencies live on a 1 / n_haplotypes grid; for "fixed" an off-grid request warns and names the frequency actually used.
# Beta frequencies, realized exactly (no binomial scatter around the draw)
pop <- pop |>
define_founder_haplotypes(line_name = "G", n_haplotypes = 1000,
method = "beta", exact_freq = TRUE)Both LD methods read the genetic map (genome_map) resolved for that pool’s own line_name, so founder LD is built on the same map that later drives recombination for that line.
[!TIP] In
"mosaic",n_templatescontrols the MAF spectrum, not just block length. Templates are the only source of allelic variation, so a locus where all templates agree is monomorphic regardless ofn_haplotypes— roughly2 / (n_templates + 1)of loci, with MAF quantized to multiples of1 / n_templates. A warning fires when more than 10% of loci come out monomorphic, because QTL placed there contribute nothing tosum(2pq a²). Raisen_templates, or use"gaussian_copula"for a dense, unquantized MAF spectrum. Note also that the template re-draw is uniform over all templates including the current one (the standard Li-Stephens kernel — it makes realized LD invariant to marker density), so observable template changes occur attemplate_switch_rate × (n_templates − 1) / n_templates.
Calling with line_name = NULL (the default) stores one shared pool that add_founders() falls back to when no named pool exists for the line it is building. Calling again with a line_name that already has a pool — or again with NULL once a NULL-line pool exists — is an error.
Every call also writes a founder_allele_freq column to genome_meta holding the per-locus frequency of the pool written most recently.
[!NOTE]
founder_allele_freqis informational only — no other tidybreed function reads it, and each call rewrites it for every locus, so in a multi-line setup it describes only the last pool written. For per-line Falconer centering usebase = "current_pop"with a line-filteredbase_tblindefine_additive_effects();base = "founder_haplotypes"recomputes the base frequency by pooling all lines together (which overstates within-line heterozygosity — Wahlund — and under-scalestarget_add_var).
4. Add founder individuals
Sample haplotypes for founders. Pass any custom ind_meta column as ... arguments — they are written atomically with the new rows.
pop <- pop |>
get_table("founder_haplotypes") |> # pass this table only
filter(line_name == "A") |> # neat way to filter and pass only haplotypes related to founder line "A"
add_founders(
n_males = 400,
n_females = 1600,
line_name = "A", # required
birth_date = sampled_birth_dates, # user sampled birth dates earlier
alive = TRUE, # user defined - show as alive to filter by later
active = FALSE # user defined - or set 'status' with character/VARCHAR
)5. Add custom columns with mutate_table()
The real power of tidybreed is freely adding columns to any table so your simulation state is stored in the database — no parallel R objects to maintain.
# Add user-defined fields to ind_meta (declare schema before data arrives)
pop |>
get_table("ind_meta") |> # stores 1 row / individual to store pedigree (sire/dam) and sex
mutate_table(
status = NA_character_, # VARCHAR: production status (update with `mutate_table()`)
birth_date = as.Date(NA), # DATE, initialize column with missing
puberty_date = as.Date(NA),
mate_date = as.Date(NA),
farrow_date = as.Date(NA),
wean_date = as.Date(NA),
cull_date = as.Date(NA),
off_test_date = as.Date(NA),
alive = TRUE, # BOOLEAN with default value
active = FALSE,
.set_default = TRUE # if TRUE -> write this value as the column default when creating new rows
)Types are inferred from R values:
| R value | DuckDB type |
|---|---|
0L, NA_integer_
|
INTEGER |
0.0, NA_real_
|
DOUBLE |
TRUE/FALSE
|
BOOLEAN |
"text", NA_character_
|
VARCHAR |
as.Date(...) |
DATE |
Sys.time() |
TIMESTAMP |
Users can then update the ‘status’ of an animal to closely mimic a real program
pop |>
get_table("ind_meta") |> # stores 1 row / individual to store pedigree (sire/dam) and sex
filter(
sex == "M",
off_test_date == current_date
) |>
mutate_table(
status = "after-test-boar"
) Which now replaces the old ‘status’ of “on-test” perhaps. The next filter you set can filter out “after-test-boar” animals only to perform selection in any way you want.
Add descriptions to columns for documentation:
pop |>
get_table("ind_meta") |>
define_schema_description("status", "Production status (e.g. gestation, lactation)") |>
define_schema_description("birth_date", "Date of birth") |>
define_schema_description("alive", "Is the animal alive?")[!TIP] If you get lost with tables (and you will…) please use
schema(pop),summary(pop), andpop |> describe_table("table_name")to get your feet back.
schema(pop) # grouped by pipeline stage, empty tables collapsed
schema(pop, show_empty = TRUE) # one row per table
schema(pop, order = "rows") # flat, biggest first
schema(pop, order = "size", sizes = TRUE) # on-disk bytes (issues a CHECKPOINT)schema() prints tables in the order a population is built — Genome, Founders, Individuals, Genetic model, Observation model, Selection, Results — with the database size in the header. The grouping is also returned as a table_group column, so it can be filtered like any other data.
Pick out one table to focus on:
pop |> describe_table("ind_meta") # view all column descriptions6. Define a SNP chip
Filter genome_meta to the loci you want, then call define_chip(). Adds an is_<chip_name> boolean column to genome_meta.
# Random 9,000-locus chip
pop |>
get_table("genome_meta") |>
slice_sample(n = 9000) |> # simple dplyr function you already know
define_chip(chip_name = "9k") # just name the chip, now `is_9k` field in `genome_meta`
# QTL are defined as loci NOT on the chip
pop |>
get_table("genome_meta") |>
filter(is_9k != TRUE) |> # filter out SNP chip loci to become QTL
define_additive_effects(...)This power architecture allows users to basically pass whatever they want and since you can define your own field to genome_meta and filter yourself, the possibilities are quite advanced and you know exactly what is going on.
7. Define variance components
Use a single entry point for all variance/covariance matrices. effect_name = "gen_add" routes to trait_var_comp; "residual" and named random effects (e.g. "pen") route to phenotype_var_comp.
# Additive genetic (co)variance — 3 traits (ADG, WWD, WWM)
vars.mat.add <- matrix(c(
0.0045, 0.00, 0.00,
0.00, 0.04, 0.00,
0.00, 0.00, 0.13),
nrow = 3, byrow = TRUE,
dimnames = list(c("ADG", "WWD", "WWM"),
c("ADG", "WWD", "WWM")))
# store matrix in a table called 'trait_var_comp'
pop <- pop |>
define_effect_cov_matrix(effect_name = "gen_add", cov_matrix = vars.mat.add)
# Residual (co)variance — 2 phenotypes (ADG, WW)
vars.mat.res <- matrix(c(
0.0067, 0.00,
0.00, 0.45),
nrow = 2, byrow = TRUE,
dimnames = list(c("ADG", "WW"),
c("ADG", "WW")))
# store matrix in 'phenotype_var_comp' table
pop <- pop |>
define_effect_cov_matrix(effect_name = "residual", cov_matrix = vars.mat.res)
# Named random effect — pen effect on ADG only (1x1)
vars.mat.pen <- matrix(0.0005, nrow = 1, byrow = TRUE,
dimnames = list("ADG", "ADG"))
# add pen covariances to the 'phenotype_var_comp' table
pop <- pop |>
define_effect_cov_matrix(effect_name = "pen", cov_matrix = vars.mat.pen)8. Define traits and phenotypes (two-layer design)
Genetic layer (define_trait) — one row per underlying genetic “trait”:
pop <- pop |>
define_trait(
trait_name = "ADG",
description = "Average Daily Gain",
units = "kg/d",
target_add_mean = 0, # TBV mean in base population
overwrite = TRUE
)Observation layer (define_phenotype) — what animals actually receive records for:
pop <- pop |>
define_phenotype(
phenotype_name = "ADG",
type = "continuous", # "continuous", "count", "categorical", "derived_formula"
mean = 0.92, # phenotypic population mean
expressed_sex = "both", # "both", "M", or "F"
min_value = 0,
overwrite = TRUE
)Why the split? - composite traits exist that can be made up of multiple underlying “traits”, while the phenotype is physically observed. Some phenotypes are actually a combination of 3 “traits” such as the direct/maternal/nurse dam model from Egbert Knols research. Or social genetic effects where the phenotype is a combination of individual genetics but also the effects from different pen mates genetics.
For composite phenotypes (e.g. weaning weight = direct + maternal):
pop <- pop |>
define_trait(
trait_name = "WWD",
description = "Weaning Weight - Direct",
units = "kg",
target_add_mean = 0, # TBV mean in base population
overwrite = TRUE
) |>
define_trait(
trait_name = "WWM",
description = "Weaning Weight - Maternal",
units = "kg",
target_add_mean = 0, # TBV mean in base population
overwrite = TRUE
)
pop <- pop |>
define_phenotype(
phenotype_name = "WW",
type = "continuous", # simple continuous phenotype,
formula_tbv = "WWD + dam(WWM)", # DSL: self + dam TBV for 'MILK'
mean = 6.0,
expressed_sex = "both",
missing_component_action = "skip", # skip founders with no dam record
overwrite = TRUE
)Derived phenotypes computed from other phenotypes:
pop <- pop |>
define_phenotype(
phenotype_name = "FCR",
type = "derived_formula", # specify to make sure it builds this phenotype relative to other phenotypes observed
formula = "ADFI / ADG", # DSL allows for more than just division, but this is most common
expressed_sex = "both",
overwrite = TRUE
)Add QTL effects for a trait (or multiple correlated traits at once):
# Single trait
pop |>
get_table("genome_meta") |>
filter(is_9k != TRUE) |> # QTL are non-SNP-chip loci
define_additive_effects(
trait_name = "ADG",
distribution = "normal", # mostly set to normal for now until I can figure out other ways to sample multivariate
scale_to_target = TRUE, # scale to target additive variance
base = "current_pop" # standardize to current animals
)
# Multiple correlated traits in one call (draws from MVN with G matrix)
pop |>
get_table("genome_meta") |>
filter(is_9k != TRUE) |>
define_additive_effects(
trait_name = c("WWD", "WWM"), # need to add together since they are genetically correlated
distribution = "normal",
scale_to_target = TRUE,
base = "current_pop"
)Add fixed effects:
pop |>
define_effect_fixed_class(
phenotype_name = "ADG",
effect_name = "sex",
source_column = "sex",
levels = c(M = 0.08, F = 0), # male advantage in ADG
source_table = "ind_meta",
overwrite = TRUE
)9. Calculate True Breeding Values
# All animals in ind_meta
pop <- pop |>
get_table("ind_meta") |> # pass table with no filter, so "select all"
add_tbv(trait_name = "ADG") # calculate TBV for all individuals (notice we use "traitn_name" here)Then the real great part of the package is to simply extract any table, and utilize the functions you already know to verify it worked (or didn’t…).
# Check means by trait
pop |>
get_table("ind_tbv") |>
collect() |> # pull into R memory
group_by(trait_name) |>
summarise(MeanTBV = mean(tbv_value))Define a selection index first, then compute true index values from TBVs (ground truth for monitoring genetic trend):
# Define the index weights (must exist before add_tbv uses index_names)
pop |>
define_index(
index_name = "maternal",
trait_names = c("ADG", "WWD", "WWM"),
index_wts = c(ADG = 0.5, WWD = 0.3, WWM = 0.2),
economic_wts = c(ADG = 0.5, WWD = 0.3, WWM = 0.2)
)
# Compute true index values from TBVs and write to ind_true_index
pop |>
get_table("ind_meta") |>
add_tbv(index_names = "maternal") # will save the true breeding value index in 'ind_true_index' table)
pop |>
get_table("ind_true_index") |>
collect()10. Add Genotypes
# Add 9k genotypes to ONLY CURRENT MALES
pop |>
get_table("ind_meta") |>
filter(
sex == "M"
) |>
add_genotypes(chip_name = "9k")
# Extract genotypes for downstream analysis
pop |>
get_table("ind_meta") |>
filter(
sex == "M" # extract only for males
) |>
extract_genotypes(chip_name = "9k")Of course, you can all use pull(id_ind) to extract a character vector and use that to filter rows as well to get a more precise set of animals for genotyping, phenotyping, or whatever you want.
11. Add Phenotypes
[!IMPORTANT]
tidybreedis explicit in the separation of “trait” vs “phenotype”.
trait➡️ is linked to the genome via QTL effectsphenotype➡️ is linked to the observed phenotype
This allows us to separate something like weaning weight into 2️⃣ components weaning weight direct and weaning weight maternal.
# Phenotype male animals that reached off-test today
pop |>
get_table("ind_meta") |>
filter(
sex == "M",
off_test_date == cur_date # power to loop over dates allows real breeding program dynamics
) |>
add_phenotype(
phenotype_name = c("ADG", "BF", "IMF"), # add ADG, Backfat, and IMF for these filtered animals
pheno_date = cur_date # this is a user defined column in the 'ind_phenotype' table, pass it here
)
# Phenotype all females for age at puberty (sex-limited trait)
pop |>
get_table("ind_meta") |>
filter(sex == "F") |> # only females get age at puberty
add_phenotype(phenotype_name = "AP")Check counts:
12. Run Evaluations (EBVs)
[!WARNING] This is probably the sketchiest part of any simulation software as it’s nearly impossible to accurately solve all models you can simulate. Simulation is far easier than solving equations. If users define a very complex structure to simulation, we cannot guarantee there is a solver out there for it. BLUPF90 is nice and easy, however extremely limited and we get what we get from it. Safest always to run user defined parameter files and programs. PLEASE BE CAREFUL!
Run BLUPF90 to estimate breeding values:
pop <- pop |>
get_table("ind_meta") |>
add_ebv(
"ADG",
software = "blupf90",
model = "blup",
phenotype = pop |> # this was critical to implement as some records will need to be sampled "early"
get_table("ind_phenotype") |>
filter(pheno_date < cur_date | is.na(pheno_date)), # exclude future records for evaluations
eval_date = cur_date
)
pop |> get_table("ind_ebv") |> filter(trait_name == "ADG")13. Define and Calculate a Selection Index
# Define a multi-trait selection index
pop |>
define_index(
index_name = "maternal",
trait_names = c("ADG", "WWD", "WWM"),
index_wts = c(1,2,3), # simple vector of index weights
economic_wts = c(4,5,6) # we can use later to establish profitability
)
# Calculate index values from latest EBVs (run after add_ebv)
pop |>
get_table("ind_ebv") |>
filter(
eval_date == cur_date # only extract the latest EBVs to calc INDEX
) |>
add_index("maternal", index_date = cur_date)
pop |> get_table("ind_index")Of course in crossbreeding, each line may have their own index, just name it and pass only those line animals to the add_index() function and boom, you have your custom indexes per line.
14. Select Parents
Pull candidate IDs and then select from any table:
pull() may be new to you, it pulls a vector back into the R object to use within filter() downstream
# Step 1: identify candidates
male_candidates <- pop |>
get_table("ind_meta") |>
filter(
sex == "M",
status %in% c("after-test-boar", "breeding-boar")
) |>
pull(id_ind)
# Step 2: select top animals by index
selected_males <- pop |>
get_table("ind_index") |>
filter(
id_ind %in% male_candidates,
index_date == latest_index_date
) |>
slice_max(index_value, n = 10) |>
pull(id_ind)
# Update status
pop |>
get_table("ind_meta") |>
filter(id_ind %in% selected_males) |>
mutate_table(status = "breeding-boar")15. Add Offspring
Build a mating plan as a tibble (one row per offspring), then call add_offspring():
(again I hate helper functions as I cannot imagine the infinite ways all these combinations could arise, for now just use the full tibble and learn how to use rep and samle yourself)
data.matings <- tibble(
id_parent_1 = rep(sampled_sires, times = nw_per_dam), # sire IDs
id_parent_2 = rep(selected_dams, times = nw_per_dam), # dam IDs
line_name = "A",
sex = sample(c("M", "F"), size = n_offspring, replace = TRUE),
conc_date = cur_date,
birth_date = cur_date + 116L,
on_test_date = cur_date + 116L + 70L,
off_test_date = cur_date + 116L + 160L
)
pop |> add_offspring(data.matings)116L refers to the average gestation length of sows these days, and 70/160 refers to roughly the on-test and off-test ages of animals tested (weights, feed intake, ultrasounds, etc).
16. Utility: mutate_group helpers
I made a few exceptions to my “I hate helper functions” mentality, you can use some mutate_group_*() functions to create groups.
Add sequence numbers, named labels, or concatenated strings within groups:
# Sequential number within each litter (group by dam ID)
pop |>
get_table("ind_meta") |>
filter(birth_date == cur_date) |>
mutate_group_seq(
group_col = "id_parent_2", # group by dam
new_col = "piglet_number"
)
# Named labels within a group
pop |>
get_table("ind_meta") |>
mutate_group_named(
group_col = "id_parent_2",
name_col = "id_ind",
new_col = "litter_members"
)17. Archive and Restore
Save a replicate’s DuckDB to an archive database for multi-replicate analysis:
archive_replicate(pop, rep = 1L)[!IMPORTANT]
options(tidybreed.replicate)will increase by 1 automatically after each successfularchive_replicate()call.
Restore a population from an existing DuckDB file (e.g. to resume a run):
pop <- restore_pop(db_path = "~/path/to/project/tidybreed_output/sim.duckdb")Database Tables
| Table | Rows | Description |
|---|---|---|
genome_meta |
1 per locus | Locus positions; user chip columns added via define_chip()
|
founder_haplotypes |
1 per (haplotype × locus) | Haplotype pool sampled by add_founders()
|
ind_haplotype |
2 per (individual × locus) | Phased haplotypes, long format (paternal / maternal) |
ind_genotype |
1 per (individual × locus) | 0/1/2 dosages, long format; on-demand cache filled by add_dosage()
|
genome_effects |
1 per (locus × trait × effect type) | Additive QTL effect sizes |
ind_meta |
1 per individual | Pedigree, sex, line; user date/status columns added via mutate_table()
|
ind_phenotype |
1 per (individual × phenotype record) | Long-format phenotype records |
ind_tbv |
1 per (individual × trait) | True breeding values (simulation ground truth) |
ind_true_index |
1 per (individual × index × weight type) | True index values from TBVs |
ind_ebv |
1 per (individual × trait × evaluation) | Estimated breeding values from BLUP/GBLUP |
ind_index |
1 per (individual × index × run) | Computed selection index values |
trait_meta |
1 per genetic trait | Genetic-layer configuration (variance target, units) |
trait_var_comp |
1 per (effect × trait pair) | Additive genetic (co)variance matrix entries |
phenotype_effects |
1 per (phenotype × effect) | Fixed and random model effects |
phenotype_random_effects |
1 per (phenotype × effect × level) | Sampled random effect deviations |
phenotype_meta |
1 per observed phenotype | Observation-layer config (mean, type, sex expression) |
phenotype_components |
1 per (phenotype × component) | Composite phenotype wiring (self/dam/sire/group) |
phenotype_var_comp |
1 per (effect × phenotype pair) | Residual and random effect (co)variance entries |
index_meta |
1 per (index × trait) | Selection index weight definitions |
Function Overview
Population & Genome
| Function | Purpose |
|---|---|
open_pop() |
Open (or create) a DuckDB-backed population object |
define_genome() |
Build the genome in one transaction: physical map (genome_meta), genetic map (genome_map), and default chromosome rules (chr_inheritance, chr_recombination) |
define_chromosome() |
Override inheritance (by offspring sex) or recombination (by parent sex) for one chromosome — sex chromosomes, organelles |
define_founder_haplotypes() |
Generate haplotype pools per line (uniform, fixed, beta, balding_nichols, mosaic, gaussian_copula; exact_freq for drift-free frequencies) |
restore_pop() |
Reopen an existing population from a DuckDB file |
close_pop() |
Safely close the DuckDB connection |
print.tidybreed_pop() |
Print population summary |
Individuals
| Function | Purpose |
|---|---|
add_founders() |
Sample haplotypes and create founder rows in ind_meta
|
add_offspring() |
Add progeny given a mating-plan tibble (1 row per offspring) |
Tables & Queries
| Function | Purpose |
|---|---|
get_table() |
Return a lazy dplyr tbl from any database table |
mutate_table() |
Add or update columns in any table (scalar or vector) |
remove_rows() |
Delete rows from a table by filter (with safety confirmation) |
schema() |
Print all table schemas |
describe_table() |
Print column descriptions for a table |
define_schema_description() |
Register a description string for a user-defined column |
Genome & Chips
| Function | Purpose |
|---|---|
define_chip() |
Mark filtered loci as members of a named SNP chip |
add_genotypes() |
adds a TRUE within has_<chip_name> field of ‘ind_meta’ |
extract_genotypes() |
Pull genotypes into R for a chip or set of QTL effects |
Traits & Model Configuration
| Function | Purpose |
|---|---|
define_trait() |
Register a genetic-layer trait in trait_meta
|
define_trait_simple() |
Convenience wrapper: define_trait() + define_additive_effects()
|
define_phenotype() |
Register an observed phenotype in phenotype_meta
|
define_additive_effects() |
Assign QTL effects to filtered loci (single or correlated multi-trait) |
define_effect_cov_matrix() |
Load a (co)variance matrix into trait_var_comp or phenotype_var_comp
|
define_effect_fixed_class() |
Add a discrete fixed-effect level-to-shift mapping |
define_effect_fixed_cov() |
Add a linear regression fixed covariate |
define_effect_random() |
Add a named random effect |
define_effect_intercept() |
Set the overall phenotypic mean (intercept) |
define_residual_cov() |
Write residual (co)variance entries to phenotype_var_comp
|
Simulation Output
| Function | Purpose |
|---|---|
add_tbv() |
Compute and store true breeding values in ind_tbv
|
add_phenotype() |
Sample and store phenotype records in ind_phenotype
|
add_ebv() |
Run BLUPF90 or parent average; store results in ind_ebv
|
add_index() |
Compute weighted index from ind_ebv (or any table); store in ind_index
|
Selection Index
| Function | Purpose |
|---|---|
define_index() |
Register index weights in index_meta
|
add_index() |
Calculate and store index values from EBVs or TBVs |
Group / Litter Utilities
| Function | Purpose |
|---|---|
mutate_group_seq() |
Add a within-group sequence number column |
mutate_group_named() |
Add a column with named labels within groups |
mutate_group_concatenate() |
Concatenate values within groups into a string column |
Replication & Archiving
| Function | Purpose |
|---|---|
archive_replicate() |
Save a replicate’s database to an archive DuckDB |
summary_pop() |
Print a structured summary of the population state |
