Skip to content

Modifying data

While FlexiChain and FlexiSummary are intended to be immutable, there are scenarios where you may want to modify the data that they contain.

FlexiChains provides generic, high-level functions that allow you to modify either the keys or the values stored inside a FlexiChain or FlexiSummary.

All of these functions return a new chain (or summary) and avoid mutating the original chain (or summary).

Specialised applications

These functions are meant to be general, in that you can use them to perform any transformation you like. This means that for 'simple' applications such as renaming a single key they can be quite verbose. If you have a specific use case that would benefit from a more specialised function, please do feel free to open an issue or submit a pull request!

Modifying keys

Let's set up a chain first:

julia
using FlexiChains: FlexiChain, Parameter, Extra

data = Dict(
    Parameter(:x) => randn(10, 3),
    Parameter(:y) => randn(10, 3),
    Extra(:a) => randn(10, 3),
)
chain = FlexiChain{Symbol}(10, 3, data)
╭─FlexiChain (10 iterations, 3 chains) ────────────────────────────────────────
 ↓ iter  = 1:10
 → chain = 1:3

 Parameters (2) ── Symbol
  Float64  y, x                                                               

 Extras (1)
  Float64  a                                                                  
╰──────────────────────────────────────────────────────────────────────────────╯

If you want to change the keys stored inside a FlexiChain or FlexiSummary, you can use the map_keys function:

julia
using FlexiChains: get_name, map_keys

# Define a function that takes the old key and returns a new key.
f(p::Parameter) = Parameter(Symbol("new_", get_name(p)))
f(p::Extra) = p

map_keys(f, chain)
╭─FlexiChain (10 iterations, 3 chains) ────────────────────────────────────────
 ↓ iter  = 1:10
 → chain = 1:3

 Parameters (2) ── Symbol
  Float64  new_y, new_x                                                       

 Extras (1)
  Float64  a                                                                  
╰──────────────────────────────────────────────────────────────────────────────╯

Often for the most part you will only want to modify Parameters, in which case you can use map_parameters, and can skip the wrapping/unwrapping in Parameter:

julia
using FlexiChains: map_parameters

g(s::Symbol) = Symbol("new_", s)

map_parameters(g, chain)
╭─FlexiChain (10 iterations, 3 chains) ────────────────────────────────────────
 ↓ iter  = 1:10
 → chain = 1:3

 Parameters (2) ── Symbol
  Float64  new_y, new_x                                                       

 Extras (1)
  Float64  a                                                                  
╰──────────────────────────────────────────────────────────────────────────────╯

Note that both map_keys and map_parameters work with FlexiSummary as well.

Modifying values

It is also possible to modify the values stored inside a FlexiChain (but not a FlexiSummary). This is done with the transform_values function:

julia
using FlexiChains: FlexiChain, VarName, @varname, Parameter, Extra, transform_values
data = Dict(
    Parameter(@varname(x)) => randn(10, 3),
    Parameter(@varname(y)) => randn(10, 3),
    Extra(:a) => randn(10, 3),
)
chain = FlexiChain{VarName}(10, 3, data)

chain2 = transform_values(
    chain,
    @varname(x) => (i -> i + 1),
    @varname(y) => (i -> i * 2) => @varname(new_y),
)
╭─FlexiChain (10 iterations, 3 chains) ────────────────────────────────────────
 ↓ iter  = 1:10
 → chain = 1:3

 Parameters (3) ── VarName
  Float64  y, x, new_y                                                        

 Extras (1)
  Float64  a                                                                  
╰──────────────────────────────────────────────────────────────────────────────╯

The above example:

  • adds 1 to each element of chain[@varname(x)]; and

  • multiplies each element of chain[@varname(y)] by 2, and stores the result in a new key @varname(new_y).

julia
chain2[@varname(x)] .- chain[@varname(x)]   # Should be all 1.
10×3 DimArray{Float64, 2}
├────────────────────────────┴───────────────────────── dims ┐
iter Sampled{Int64} 1:10 ForwardOrdered Regular Points,
chain Sampled{Int64} 1:3 ForwardOrdered Regular Points
└────────────────────────────────────────────────────────────┘
  1    2    3
  1    1.0  1.0  1.0
  2    1.0  1.0  1.0
  3    1.0  1.0  1.0
  4    1.0  1.0  1.0

  7    1.0  1.0  1.0
  8    1.0  1.0  1.0
  9    1.0  1.0  1.0
 10    1.0  1.0  1.0

You can pass as many transformations as you like. The syntax is designed to be similar to that of DataFrames.transform, but has some slight differences: notably, the function being applied acts on individual draws from chain[@varname(x)] rather than the matrix as a whole.

You can also pass binary (or n-ary) functions to transform_values to combine multiple keys. Again, this is similar to DataFrames.transform, but the function combines individual draws from chain[@varname(x)] and chain[@varname(y)] rather than the matrices themselves.

julia
chain3 = transform_values(chain, [@varname(x), @varname(y)] => (+) => @varname(sum_xy))
╭─FlexiChain (10 iterations, 3 chains) ────────────────────────────────────────
 ↓ iter  = 1:10
 → chain = 1:3

 Parameters (3) ── VarName
  Float64  y, x, sum_xy                                                       

 Extras (1)
  Float64  a                                                                  
╰──────────────────────────────────────────────────────────────────────────────╯
julia
chain3[@varname(sum_xy)] == chain[@varname(x)] .+ chain[@varname(y)]
true

Attaching labels to data

A common use case for transform_values is to attach labels to data, for example, converting a Vector of parameters into a DimVector. For example, consider our (now familiar) eight-schools model.

julia
using Turing, FlexiChains

y = [28, 8, -3, 7, -1, 1, 18, 12]
sigma = [15, 10, 16, 11, 9, 11, 10, 18]
@model function eight_schools(y, sigma)
    mu ~ Normal(0, 5)
    tau ~ truncated(Cauchy(0, 5); lower=0)
    theta ~ MvNormal(fill(mu, length(y)), tau^2 * I)
    for i in eachindex(y)
        y[i] ~ Normal(theta[i], sigma[i])
    end
    return (mu=mu, tau=tau)
end
model = eight_schools(y, sigma)
chain = sample(model, NUTS(), 3; chain_type=VNChain)
╭─FlexiChain (3 iterations, 1 chain) ──────────────────────────────────────────
 ↓ iter  = 2:4
 → chain = 1:1

 Parameters (3) ── VarName
  Float64          mu, tau                                                    
  Vector{Float64}  theta (8,)

 Extras (14)
  Int64    n_steps, tree_depth                                                
  Bool     is_accept, numerical_error                                         
  Float64  acceptance_rate, log_density, hamiltonian_energy,                  
           hamiltonian_energy_error, max_hamiltonian_energy_error, step_size, 
           nom_step_size, logprior, loglikelihood, logjoint                   
╰──────────────────────────────────────────────────────────────────────────────╯

In this chain, theta has one entry per school, but is stored as a plain Vector. While this is fine for many applications, attaching labels can help make it easier to analyse the data.

julia
using DimensionalData: DimArray, Dim

school_names = [
    "Choate",
    "Deerfield",
    "Phillips Andover",
    "Phillips Exeter",
    "Hotchkiss",
    "Lawrenceville",
    "St. Paul's",
    "Mt. Hermon",
]
add_labels(v::Vector{<:Real}) = DimArray(v, Dim{:school}(school_names))

chain = transform_values(chain, :theta => add_labels)
╭─FlexiChain (3 iterations, 1 chain) ──────────────────────────────────────────
 ↓ iter  = 2:4
 → chain = 1:1

 Parameters (3) ── VarName
  Float64                                   mu, tau                           
  DimensionalData.DimVector{Float64, Tupl…  theta (8,)

 Extras (14)
  Int64    n_steps, tree_depth                                                
  Bool     is_accept, numerical_error                                         
  Float64  acceptance_rate, log_density, hamiltonian_energy,                  
           hamiltonian_energy_error, max_hamiltonian_energy_error, step_size, 
           nom_step_size, logprior, loglikelihood, logjoint                   
╰──────────────────────────────────────────────────────────────────────────────╯

For example, DimVector parameters get special labels when plotting:

julia
using CairoMakie

FlexiChains.Makie.traceplot(chain, @varname(theta); layout=(4, 2))

Having the labels also benefits downstream analysis of any data extracted from the chain. For example, you can obtain the same functionality as tidybayes' spread_draws:

julia
using DataFrames

# Create a 3D DimArray (iter x chain x school)
dimarr = chain[:theta, stack=true]

# Convert it to a DataFrame (long-format by default)
df = DataFrame(dimarr)
24×4 DataFrame
RowiterchainschoolParameter(theta)
Int64Int64StringFloat64
121Choate-0.981272
231Choate-0.981272
341Choate-0.981272
421Deerfield1.62537
531Deerfield1.62537
641Deerfield1.62537
721Phillips Andover0.569466
831Phillips Andover0.569466
941Phillips Andover0.569466
1021Phillips Exeter-1.91917
1131Phillips Exeter-1.91917
1241Phillips Exeter-1.91917
1321Hotchkiss-0.164009
1431Hotchkiss-0.164009
1541Hotchkiss-0.164009
1621Lawrenceville1.17927
1731Lawrenceville1.17927
1841Lawrenceville1.17927
1921St. Paul's-0.895922
2031St. Paul's-0.895922
2141St. Paul's-0.895922
2221Mt. Hermon-1.97548
2331Mt. Hermon-1.97548
2441Mt. Hermon-1.97548

If you want a wide-format table, you can tap into the functionality in DimensionalData.jl (please see their docs for full info):

julia
using DimensionalData: DimTable

df = DataFrame(DimTable(dimarr; layersfrom=:school))
3×10 DataFrame
Rowiterchainschool_Choateschool_Deerfieldschool_Phillips Andoverschool_Phillips Exeterschool_Hotchkissschool_Lawrencevilleschool_St. Paul'sschool_Mt. Hermon
Int64Int64Float64Float64Float64Float64Float64Float64Float64Float64
121-0.9812721.625370.569466-1.91917-0.1640091.17927-0.895922-1.97548
231-0.9812721.625370.569466-1.91917-0.1640091.17927-0.895922-1.97548
341-0.9812721.625370.569466-1.91917-0.1640091.17927-0.895922-1.97548

Standardisation

Another use case for transforming values is to (un)standardise parameter values.

For example, consider this simple linear regression (in principle X should also be standardised, but we'll skip it here).

julia
X = randn(100, 3)
y = randn(100) .* 4.0 .+ 2.0

using StatsBase: fit, ZScoreTransform, transform
zs = fit(ZScoreTransform, y)
y_scaled = transform(zs, y)

mean(y_scaled), std(y_scaled)  # ≈ 0 and 1
(-4.218847493575595e-17, 1.0)
julia
@model function linear_regression(X)
    beta ~ filldist(Normal(), size(X, 2))
    mu := X * beta
    y ~ MvNormal(mu, I)
end
model = linear_regression(X) | (; y=y_scaled)

chain = sample(model, NUTS(), 1000; chain_type=VNChain)
╭─FlexiChain (1000 iterations, 1 chain) ───────────────────────────────────────
 ↓ iter  = 501:1500
 → chain = 1:1

 Parameters (2) ── VarName
  Vector{Float64}  beta (3,), mu (100,)

 Extras (14)
  Int64    n_steps, tree_depth                                                
  Bool     is_accept, numerical_error                                         
  Float64  acceptance_rate, log_density, hamiltonian_energy,                  
           hamiltonian_energy_error, max_hamiltonian_energy_error, step_size, 
           nom_step_size, logprior, loglikelihood, logjoint                   
╰──────────────────────────────────────────────────────────────────────────────╯

In the resulting chain, we have values of mu but these are standardised according to the same ZScoreTransform we fitted to y.

julia
mean(chain[:mu, stack=true])  # (probably) close to 0.
0.015075031743273612

We can unstandardise them by applying the inverse transformation.

julia
using StatsBase: reconstruct

chain = transform_values(chain, :mu => (i -> reconstruct(zs, i)))

mean(chain[:mu, stack=true])
1.9324659080723643

Docstrings

FlexiChains.map_keys Function
julia
FlexiChains.map_keys(f, cs::ChainOrSummary{T})::ChainOrSummary{S} where {T,S}

Change the keys of a FlexiChain or FlexiSummary by applying the function f to each key.

f must have the signature f(::ParameterOrExtra{<:T}) -> ParameterOrExtra{<:S}. It must return a unique key for each input key.

source
FlexiChains.map_parameters Function
julia
FlexiChains.map_parameters(f, cs::ChainOrSummary{T})::ChainOrSummary{S} where {T,S}

Change the parameters of a FlexiChain or FlexiSummary by applying the function f to each parameter name.

f must have the signature f(::T) -> S. It must return a unique parameter for each input parameter.

source
FlexiChains.transform_values Function
julia
FlexiChains.transform_values(chn::FlexiChain{T}, args...)

Perform one or more transformations on the values inside a FlexiChain.

args can be one or more of the following:

  • key => f => new_key Applies f to each draw of key and stores the result in new_key.

  • key => f Shorthand for {key} => f => {key}, i.e., applies f to each draw of key and stores the result back in key.

  • [key1, key2, ..., keyN] => f => new_key Calculates f(key1, key2, ..., keyN) for each draw and stores the result in new_key. Note that the LHS must be an AbstractVector; other iterables like Tuples are not accepted.

key accepts any value that can be used to index into a FlexiChain, including Symbols when unambiguous. However, new_key is more restricted: it must be either FlexiChains.Extra, FlexiChains.Parameter{<:T}, or just a T (in which case it is assumed to be a parameter).

source