Sunday, August 2, 2026

A small experiment, but a strong case for joint estimation of discrete character dependent continuous trait evolution under a multi-regime OU model

A couple of days ago on this blog I posted an entry on comparing a discrete-character-dependent multi-regime joint OU model to OUwie when the regime history is known.

For those not following closely at home, the joint discrete & continuous trait model that I’m talking about uses a finite-space / discretized diffusion approximation as documented in prior posts to this blog (e.g., 1, 2, 3, 4, 5), following Boucher & Démery (2016; also see our in review bioRxiv pre-print).

A more typical workflow with OUwie (and other similar regime-based methods), is to generate a set of stochastic character maps of the discrete character regime following Huelsenbeck et al. (2003), fit a multi-regime OU model to each tree in the set, and then average the results across maps. Back in 2013, I pointed out that (for rate-heterogeneous models) this would tend to result in an underestimate of the difference in rates, \(\sigma^2\), between regimes. The same seems very likely to be true for multi-regime OU models, but (to my knowledge) this has not been studied explicitly. We might hope or expect that this bias would go away if we can jointly model the discrete & continuous characters.

The magnitude of this bias is also likely to vary as a function of the transition rate for the discrete trait. In other words, the higher the transition rates in Q of our discrete trait, the more uncertain our discrete character history and the more it will vary among stochastic character maps (and the more each map will differ from the unknown true history of our trait).

On a Sunday morning, I thought it would be interesting to engineer a simple comparison between this traditional OUwie workflow and fitmultiOU both for circumstances in which the transition rate of the discrete character is low & high.

To begin with, let’s load the packages we intend to use.

## load packages
library(phytools)
library(OUwie)

Now, I’m going to simulate a tree & a discrete character history on that tree with a low rate (keeping in mind, of course, that there is no special numeric value of \(q\) that is “low” or “high” – it all depends on the total depth of our tree).

phy<-pbtree(n=250,scale=10)
phy
## 
## Phylogenetic tree with 250 tips and 249 internal nodes.
## 
## Tip labels:
##   t16, t21, t123, t124, t38, t49, ...
## 
## Rooted; includes branch length(s).
q<-0.05
k<-2
low.Q<-matrix(q,k,k,dimnames=list(letters[1:k],letters[1:k]))
diag(low.Q)<-0
diag(low.Q)<--rowSums(low.Q)
low.Q
##       a     b
## a -0.05  0.05
## b  0.05 -0.05
low.sim_tree<-sim.history(phy,low.Q,
  anc=y0<-sample(letters[1:k],1))
## Done simulation(s).
low.sim_tree
## 
## Phylogenetic tree with 250 tips and 249 internal nodes.
## 
## Tip labels:
## 	t16, t21, t123, t124, t38, t49, ...
## 
## The tree includes a mapped, 2-state discrete character
## with states:
## 	a, b
## 
## Rooted; includes branch lengths.
## plot this tree (just for fun)
cols<-setNames(hcl.colors(n=k),letters[1:k])
plot(low.sim_tree,cols,ftype="off",lwd=1,
  direction="upwards",ylim=c(-1,10))
par(lend=1)
legend("bottomleft",letters[1:k],lwd=4,
  col=hcl.colors(n=k),cex=0.7,bty="n")

plot of chunk unnamed-chunk-166

Great. Clearly we’ve simulated a very low rate of discrete character evolution on this tree!

Now let’s simulate a multi-regime OU process conditioning on this true discrete character history. To do this we’ll use multiOU in phytools. Even though we’re using the same value of \(\alpha\) and \(\sigma^2\) for both of our discrete character regimes we need to set values of these two parameters for each of the two character levels.

## set alpha
alpha<-setNames(rep(0.6,k),letters[1:k])
alpha
##   a   b 
## 0.6 0.6
## set sigma-squared
sig2<-setNames(rep(0.3,k),letters[1:k])
sig2
##   a   b 
## 0.3 0.3

Now let’s set \(\theta_a\) and \(\theta_b\). I’m going to use values of \(0\) and \(5\) for the two different discrete character states.

# set theta
theta<-setNames(c(0,5),letters[1:k])
theta
## a b 
## 0 5

Now, I’ll simulate continuous trait data under the multi-regime model using our true discrete character history with phytools::multiOU. I’m going to assume the starting value is at \(\theta\) for the root regime. (This is where the a0=theta[y0] comes from!)

## simulate continuous trait
low.x<-multiOU(low.sim_tree,alpha,sig2,theta,a0=theta[y0])
head(low.x)
##        t16        t21       t123       t124        t38        t49 
##  0.6548297  0.7918578 -0.1488759  0.4502061  0.3959087  0.2302126

Terrific. So far so good.

Now we’re about to imagine that our discrete character history is unobserved, so let’s pull of just the tip values of our discrete trait from low.sim_tree as follows.

low.y<-as.factor(getStates(low.sim_tree,type="tips"))
head(low.y)
##  t16  t21 t123 t124  t38  t49 
##    a    a    a    a    a    a 
## Levels: a b

Next, let’s generate a set of stochastic character histories for our discrete state using phytools::simmap.

low.mk_fit<-fitMk(phy,low.y,model="ER",
  pi="fitzjohn")
low.mk_fit
## Object of class "fitMk".
## 
## Fitted (or set) value of Q:
##           a         b
## a -0.036172  0.036172
## b  0.036172 -0.036172
## 
## Fitted (or set) value of pi:
##       a       b 
## 0.32705 0.67295 
## due to treating the root prior as (a) nuisance.
## 
## Log-likelihood: -76.278772 
## 
## Optimization method used was "nlminb"
## 
## R thinks it has found the ML solution.
low.smps<-simmap(low.mk_fit)
low.smps
## 100 phylogenetic trees with mapped discrete characters

Let’s plot a few of these.

par(mfrow=c(5,5))
nulo<-sapply(low.smps[1:25],plot,lwd=1,col=cols,ftype="off",
  direction="upwards")

plot of chunk unnamed-chunk-174

Something that readers will probably notice immediately is that all of these discrete character histories fairly closely resemble each other & the true history of our trait.

Next, let’s fit OUwie models to each of them. That’ll be fun. To speed it along, I’m going to parallelize across stochastic map trees using the foreach package.

## make our OUwie data frame
low.ouwie_data<-data.frame(
  Genus_species=names(low.x),
  Reg=low.y,
  X=low.x)
head(low.ouwie_data)
##      Genus_species Reg          X
## t16            t16   a  0.6548297
## t21            t21   a  0.7918578
## t123          t123   a -0.1488759
## t124          t124   a  0.4502061
## t38            t38   a  0.3959087
## t49            t49   a  0.2302126
## load foreach and doParallel
library(foreach)
library(doParallel)
## set up our cluster
ncores<-detectCores()-2
ncores
## [1] 14
mc<-makeCluster(ncores,type="PSOCK")
mc
## socket cluster with 14 nodes on host 'localhost'
registerDoParallel(cl=mc)
## optimize across all 100 stochastic maps
low.ouwie_fits<-foreach(i=1:length(low.smps))%dopar%{
  OUwie::OUwie(low.smps[[i]],low.ouwie_data,model="OUM",
    simmap.tree=TRUE,root.station=FALSE)
}
stopCluster(mc)

The object we’ve created is a long list of fitted “OUwie” models. We can pull out just the specific results that we’re interested in to make it easier to see the general pattern.

foo<-function(x) setNames(
  c(
    x$theta[,1],
    x$solution[,1],
    x$loglik
  ),
  c(
    "the[a]","the[b]","alpha","sigsq","log(L)"
  )
)
low.ouwie_results<-t(sapply(low.ouwie_fits,foo))
rownames(low.ouwie_results)<-1:nrow(low.ouwie_results)
options(scipen=5) ## just for printing
round(low.ouwie_results[1:25,],digits=2)
##    the[a] the[b] alpha sigsq  log(L)
## 1    0.08   4.84  0.66  0.42 -182.89
## 2    0.11   4.95  0.49  0.36 -194.30
## 3    0.19   5.07  0.41  0.41 -224.99
## 4    0.20   4.95  0.63  0.48 -206.92
## 5    0.00   4.75  0.67  0.52 -209.01
## 6    0.11   4.95  0.44  0.40 -216.47
## 7    0.08   4.87  0.47  0.41 -214.46
## 8    0.05   4.86  0.48  0.36 -192.84
## 9    0.01   4.91  0.40  0.41 -227.67
## 10   0.03   4.82  0.62  0.41 -186.94
## 11   0.07   4.89  0.50  0.36 -193.14
## 12  -0.10   4.86  0.33  0.38 -232.95
## 13   0.03   4.86  0.59  0.37 -178.45
## 14  -0.03   4.78  0.43  0.38 -213.53
## 15   0.03   4.88  0.48  0.31 -175.21
## 16   0.10   4.97  0.50  0.33 -180.42
## 17   0.17   4.95  0.49  0.48 -228.08
## 18   0.12   4.92  0.60  0.48 -210.78
## 19   0.04   4.82  0.48  0.37 -200.46
## 20  -0.07   4.78  0.42  0.34 -199.99
## 21   0.21   5.29  0.34  0.41 -239.15
## 22   0.05   4.87  0.57  0.40 -193.95
## 23   0.14   5.06  0.47  0.39 -207.78
## 24   0.22   5.21  0.37  0.39 -227.25
## 25   0.10   4.90  0.61  0.43 -196.33
options(scipen=0)

This is pretty cool because it shows that all of our fitted models are pretty close to the generating conditions of \(\mathbf{\theta} = [0, 5]\).

colMeans(low.ouwie_results)
##        the[a]        the[b]         alpha         sigsq        log(L) 
##    0.06588211    4.91247554    0.49325269    0.40862951 -208.88237689

We can also compare this to phytools::fitmultiOU, which I predict will get a similar model.

init<-setNames(
  c(
    mean(low.x[low.y==levels(low.y)[1]]),
    mean(low.x[low.y==levels(low.y)[2]]),
    log(2)/max(nodeHeights(phy)),
    var(low.x)/max(nodeHeights(phy)),
    fitMk(phy,low.y,model="ER")$rates),
  c("theta[a]","theta[b]",
    "alpha","sigsq","q[1]"))
init
##   theta[a]   theta[b]      alpha      sigsq       q[1] 
## 0.14104895 4.40690078 0.06931472 0.52619893 0.03687111
low.fit_mou<-fitmultiOU(phy,low.x,low.y,model="ER",
  levs=100,parallel=TRUE,ncores=ncores,root="mle",
  trace=1,maxit=2000,init=init)
## iter	the[a]	the[b]	alpha	sigsq	q[1]	log(L)
## 0	0.1410	4.4069	0.0693	0.5262	0.0369	-378.6136 
## 100	-0.3327	4.7524	0.3626	0.1910	0.0282	-258.4337 
## 200	0.0864	4.8329	0.5574	0.2065	0.0202	-247.0498 
## 300	0.1137	4.9956	0.5351	0.2351	0.0203	-245.0731 
## 400	0.0581	4.8858	0.6185	0.2743	0.0387	-242.3865 
## 500	0.0999	4.9000	0.6364	0.2739	0.0330	-241.9284 
## 600	0.1005	4.9045	0.6323	0.2720	0.0331	-241.9258 
## 666	0.1009	4.9043	0.6320	0.2719	0.0331	-241.9258 
## Done optimizing.
low.fit_mou
## Object of class "fitmultiOU" based on
##     a discretization with k = 100 levels.
## 
## Fitted multi-theta OU model parameters:
##  levels: [ a, b ]
##   theta: [ 0.1009, 4.9043 ]
##   alpha: 0.632 
##   sigsq: 0.2719 
## 
## Estimated Q matrix:
##             a           b
## a -0.03312624  0.03312624
## b  0.03312624 -0.03312624
## 
## Log-likelihood: -241.9258 
## 
## R thinks it has found the ML solution.

Even though both analyses gave as parameter estimates quite close to the generating values, joint estimation seems to be even more accurate... particularly with regard to \(\alpha\) and \(\sigma^2\). That's cool.

Now let’s consider the case of a high rate of transition for our discrete character.

q<-0.8
k<-2
high.Q<-matrix(q,k,k,dimnames=list(letters[1:k],
  letters[1:k]))
diag(high.Q)<-0
diag(high.Q)<--rowSums(high.Q)
high.Q
##      a    b
## a -0.8  0.8
## b  0.8 -0.8

Now the discrete character is expected to change a lot in the true history.

high.sim_tree<-sim.history(phy,high.Q,
  anc=y0)
## Done simulation(s).
high.sim_tree
## 
## Phylogenetic tree with 250 tips and 249 internal nodes.
## 
## Tip labels:
## 	t16, t21, t123, t124, t38, t49, ...
## 
## The tree includes a mapped, 2-state discrete character
## with states:
## 	a, b
## 
## Rooted; includes branch lengths.
## plot this tree (just for fun)
cols<-setNames(hcl.colors(n=k),letters[1:k])
plot(high.sim_tree,cols,ftype="off",lwd=1,
  direction="upwards",ylim=c(-1,10))
par(lend=1)
legend("bottomleft",letters[1:k],lwd=4,
  col=hcl.colors(n=k),cex=0.7,bty="n")

plot of chunk unnamed-chunk-191

(Let’s generate continuous trait data under the same \(\alpha\), \(\sigma^2\) and \(\mathbf{\theta}}=[0,5]\) as before, but with our new, high Q history.)

## simulate continuous trait
high.x<-multiOU(high.sim_tree,alpha,sig2,theta,
  a0=theta[y0])
head(high.x)
##       t16       t21      t123      t124       t38       t49 
##  1.142639  1.634035  1.209871  1.537554  2.871243 -1.072081

Of course, now we might expect our stochastic character histories to differ more from each other and from the true history.

high.y<-as.factor(getStates(high.sim_tree,type="tips"))
head(high.y)
##  t16  t21 t123 t124  t38  t49 
##    a    a    a    a    a    a 
## Levels: a b
high.mk_fit<-fitMk(phy,high.y,model="ER",
  pi="fitzjohn")
high.mk_fit
## Object of class "fitMk".
## 
## Fitted (or set) value of Q:
##           a         b
## a -0.458785  0.458785
## b  0.458785 -0.458785
## 
## Fitted (or set) value of pi:
##        a        b 
## 0.499556 0.500444 
## due to treating the root prior as (a) nuisance.
## 
## Log-likelihood: -159.945824 
## 
## Optimization method used was "nlminb"
## 
## R thinks it has found the ML solution.
high.smps<-simmap(high.mk_fit)
high.smps
## 100 phylogenetic trees with mapped discrete characters

Much as we did earlier, let’s plot a few of these just to see what we’ve got!

par(mfrow=c(5,5))
nulo<-sapply(high.smps[1:25],plot,lwd=1,col=cols,
  ftype="off",direction="upwards")

plot of chunk unnamed-chunk-196

It should be pretty evident, I think, that (even though they seem to have arisen under the same process) in this case the specific details of our discrete character histories vary quite widely one from the other, as well as from our generating history.

I expect that the consequence of this will be that each one of our stochastic character histories is likely to add error (and perhaps bias) to the estimating of the continuous trait multi-regime OU process. Let’s see if that’s true.

## make our OUwie data frame
high.ouwie_data<-data.frame(
  Genus_species=names(high.x),
  Reg=high.y,
  X=high.x)
head(high.ouwie_data)
##      Genus_species Reg         X
## t16            t16   a  1.142639
## t21            t21   a  1.634035
## t123          t123   a  1.209871
## t124          t124   a  1.537554
## t38            t38   a  2.871243
## t49            t49   a -1.072081

Once again, we’ll parallelize across maps using foreach::foreach.

mc<-makeCluster(ncores,type="PSOCK")
registerDoParallel(cl=mc)
## optimize across all 100 stochastic maps
high.ouwie_fits<-foreach(i=1:length(high.smps))%dopar%{
  OUwie::OUwie(high.smps[[i]],high.ouwie_data,model="OUM",
    simmap.tree=TRUE,root.station=FALSE)
}
stopCluster(mc)

Let’s summarize our results.

foo<-function(x) setNames(
  c(
    x$theta[,1],
    x$solution[,1],
    x$loglik
  ),
  c(
    "the[a]","the[b]","alpha","sigsq","log(L)"
  )
)
high.ouwie_results<-t(sapply(high.ouwie_fits,foo))
rownames(high.ouwie_results)<-1:nrow(high.ouwie_results)

(Here’s just the first 25, again.)

options(scipen=5) ## just for printing
round(high.ouwie_results[1:25,],digits=2)
##    the[a] the[b] alpha sigsq  log(L)
## 1    0.67   3.66  0.20  0.73 -350.92
## 2    0.64   3.55  0.19  0.74 -352.68
## 3    0.55   3.73  0.19  0.71 -349.64
## 4    1.11   3.40  0.19  0.75 -355.78
## 5    0.59   3.63  0.19  0.72 -350.10
## 6    1.24   3.54  0.22  0.80 -354.80
## 7    1.51   3.03  0.19  0.78 -360.86
## 8    1.02   3.40  0.21  0.78 -354.09
## 9    1.37   4.09  0.21  0.76 -350.52
## 10   0.94   3.56  0.20  0.75 -353.15
## 11   0.94   4.29  0.21  0.71 -342.30
## 12   1.14   3.25  0.22  0.81 -356.49
## 13   1.66   3.62  0.17  0.74 -359.53
## 14   1.46   3.64  0.20  0.77 -356.67
## 15   1.02   3.13  0.16  0.72 -358.30
## 16   0.31   3.84  0.22  0.72 -341.27
## 17   1.54   3.59  0.19  0.77 -358.10
## 18   1.43   4.06  0.18  0.73 -355.63
## 19   1.53   3.73  0.21  0.78 -355.44
## 20   1.35   4.16  0.18  0.72 -352.88
## 21   1.53   3.08  0.18  0.77 -360.42
## 22   0.42   3.49  0.19  0.73 -350.55
## 23   1.42   3.85  0.21  0.79 -354.72
## 24   0.23   3.70  0.17  0.68 -350.52
## 25   1.02   3.36  0.20  0.77 -355.54
options(scipen=0)

Now let’s get an average across trees.

colMeans(high.ouwie_results)
##       the[a]       the[b]        alpha        sigsq       log(L) 
##    1.0776599    3.7045410    0.2024869    0.7556554 -352.7203710

As we predicted, the estimates of \(\theta\)), in particular, are biased towards each other -- but we also see that \(\alpha\) is biased downwards and \(\sigma^2\) upwards.

Finally, let’s compare this to phytools::fitmultiOU.

init<-setNames(
  c(
    mean(high.x[high.y==levels(high.y)[1]]),
    mean(high.x[high.y==levels(high.y)[2]]),
    log(2)/max(nodeHeights(phy)),
    var(high.x)/max(nodeHeights(phy)),
    fitMk(phy,high.y,model="ER")$rates),
  c("theta[a]","theta[b]",
    "alpha","sigsq","q[1]"))
high.fit_mou<-fitmultiOU(phy,high.x,high.y,model="ER",
  levs=100,parallel=TRUE,ncores=ncores,root="mle",
  trace=1,maxit=2000,init=init)
## iter	the[a]	the[b]	alpha	sigsq	q[1]	log(L)
## 0	1.9220	3.2096	0.0693	0.1930	0.4588	-622.5731 
## 100	1.1219	4.2495	0.4753	0.6045	0.5548	-490.4077 
## 200	-0.6367	4.8417	0.4181	0.2777	0.6418	-473.6047 
## 300	-0.4362	4.8024	0.4288	0.2951	0.6848	-473.2549 
## 400	-0.5098	5.0866	0.4541	0.1942	0.7713	-472.2389 
## 500	-0.4734	5.0244	0.4641	0.2102	0.7708	-472.1732 
## 600	-0.4698	5.0304	0.4678	0.2093	0.7854	-472.1688 
## 700	-0.4622	5.0253	0.4686	0.2081	0.7829	-472.1673 
## 732	-0.4625	5.0253	0.4682	0.2082	0.7824	-472.1673 
## Done optimizing.
high.fit_mou
## Object of class "fitmultiOU" based on
##     a discretization with k = 100 levels.
## 
## Fitted multi-theta OU model parameters:
##  levels: [ a, b ]
##   theta: [ -0.4625, 5.0253 ]
##   alpha: 0.4682 
##   sigsq: 0.2082 
## 
## Estimated Q matrix:
##            a          b
## a -0.7823842  0.7823842
## b  0.7823842 -0.7823842
## 
## Log-likelihood: -472.1673 
## 
## R thinks it has found the ML solution.

This is pretty astonishing & seems to make quite a convincing case for joint estimation.

Admittedly, this is a very small experiment, of course – however, it does seem to confirm the thesis that if our discrete character changes infrequently, then the traditional two-step process of generating stochastic character maps & then fitting a fixed regime model to each map should work just fine. On the other hand, it also generally affirms Revell (2013, which perhaps ought to be a bit better cited) that as the rate of transition in our character goes up, the two-step process becomes biased and tends to underestimate the difference between the regimes. (This also may affect the measurement of \(\alpha\), which is underestimated; and \(\sigma^2\), which is overestimated.)

This bias goes away when we jointly model \(x\) and \(y\) using our new finite-space approximation, which is very cool IMO.

OK, more on this later!