4

I want to efficiently solve a degree-7 polynomial in k.

For example, with the following set of 7 unconditional probabilities,

p <- c(0.0496772, 0.04584501, 0.04210299, 0.04026439, 0.03844668, 0.03487194, 0.03137491)

the overall event probability is approximately 25% :

> 1 - prod(1 - p)
[1] 0.2506676

And if I want to approximate a constant k to proportionally change all elements of p so that the overall event probability is now approximately 30%, I can do this using an equation solver (such as Wolfram Alpha), which may use Newton's method or bisection to approximate k in:

1- \prod_{i=1}^7 (1-k p_i) = 0.30

here, k is approximately 1.23:

> 1 - prod(1 - 1.23*p)
[1] 0.3000173

But what if I want to solve this for many different overall event probabilities, how can I efficiently do this in R?

I've looked at the function SMfzero in the package NLRoot, but it's still not clear to me how I can achieve it.

EDIT I've benchmarked the solutions so far. On the toy data p above:

Unit: nanoseconds
              expr     min      lq      mean  median      uq      max neval
 approximation_fun     800    1700    3306.7    3100    4400    39500  1000
       polynom_fun 1583800 1748600 2067028.6 1846300 2036300 16332600  1000
      polyroot_fun  596800  658300  863454.2  716250  792100 44709000  1000
         bsoln_fun   48800   59800   87029.6   85100  102350   613300  1000
        find_k_fun   48500   60700   86657.4   85250  103050   262600  1000

NB, I'm not sure if its fair to compare the approximation_fun with the others but I did ask for an approximate solution so it does meet the brief.

The real problem is a degree-52 polynomial in k. Benchmarking on the real data:

Unit: microseconds
              expr     min       lq       mean   median       uq     max neval
 approximation_fun     1.9     3.20     7.8745     5.50    14.50    55.5  1000
       polynom_fun 10177.2 10965.20 12542.4195 11268.45 12149.95 80230.9  1000
         bsoln_fun    52.3    60.95    91.4209    71.80   117.75   295.6  1000
        find_k_fun    55.0    62.80    90.1710    73.10   118.40   358.2  1000
jayb
  • 491
  • 3
  • 14
  • 1
    Is the approximation `- log(1 - 0.30) / sum(p)` sufficient? – G. Grothendieck Apr 28 '21 at 14:21
  • @G.Grothendieck Thanks for your comment - it seems to be less exact for higher values of the overall event probability – jayb Apr 28 '21 at 14:28
  • 1
    Yes, that would be expected because it is based on log(1+x) = x approximately for small x. – G. Grothendieck Apr 28 '21 at 14:36
  • @G.Grothendieck okay - I guess it's part of the trade-off between efficiency and precision - this is obviously a very efficient solution which makes a small concession for accuracy. Do you mind giving a bit more explanation on your approximation? – jayb Apr 28 '21 at 14:44
  • 2
    `log(1-0.3) = log(prod(1-k*p)) = sum(log(1-k*p)) = sum(-k*p) = -k*sum(p)` where the approx is in the third equal sign. – G. Grothendieck Apr 28 '21 at 14:48
  • See my edits below. How fast do you need this to be? – Ben Bolker Apr 28 '21 at 19:49
  • An expensive step in an exact solution (well, an approximate solution to the exact problem, I guess) such as the one for polynom given below, is to construct an explicit representation of the n-degree polynomial. But if you want to employ bisection or something, it's not necessary to expand the polynomial, you can just multiply the n terms every time. On a different note, I wonder what is the sensitivity of the solution to the number of terms n -- polynomials can have interesting behavior (I don't know if this one does). – Robert Dodier Apr 28 '21 at 20:09
  • @BenBolker Thanks for the `bsoln`, I've added that above. It'll be part of a function in an individual-based model that will be called approx 1-million times per simulation - but the model is stochastic so there'll be reruns too. So, as fast as possible! – jayb Apr 29 '21 at 10:41

3 Answers3

4

This can be solved with the polynom library.

library(polynom)
library(purrr)

p <- runif(3, 0, 1)
p
#> [1] 0.1072518 0.5781922 0.3877427
# Overall probability
1 - prod(1 - p)
#> [1] 0.7694434

# Target overall probability
target_op <- 0.3

# calculate polynomial to solve for k
poly_list <- p %>% 
  map(~polynomial(c(1, -.))) %>% 
  as.polylist()

# List of linear polynomials to be multiplied:
poly_list
#> [[1]]
#> 1 - 0.1072518*x 
#> 
#> [[2]]
#> 1 - 0.5781922*x 
#> 
#> [[3]]
#> 1 - 0.3877427*x

# we want to solve this polynomial
poly <- 1 - prod(poly_list) - target_op
poly
#> -0.3 + 1.073187*x - 0.3277881*x^2 + 0.02404476*x^3
roots <- solve(poly)
good_roots <- 
  roots %>% 
  # keep only real values
  keep(~Im(.) == 0) %>% 
  Re() %>% 
  # only positive
  keep(~.>0)

good_roots
#> [1] 0.1448852

k <- good_roots[[1]]

1 - prod(1 - k*p)
#> [1] 0.3

Created on 2021-04-28 by the reprex package (v1.0.0)

Iaroslav Domin
  • 2,370
  • 7
  • 17
3

Following @IaroslavDomin's solutions, but constructing the coefficients for this particular case by hand, then using polyroot():

Here's a sequence of three functions (compute individual coeffs, put them together into a vector, find positive real roots):

## construct ith binomial coefficients: the sum of the products 
## of all i-element combinations
bcoef <- function(p,i) {
    sum(apply(combn(p,i),2,prod))
}
## compute all binomial coefficients and put them together
## into the vector of coeffs for 1-prod(1-k*p)
mypoly <- function(p,target=0.3) {
    c(-target,-1*sapply(seq_along(p), bcoef, p =-p))
}
## compute real positive solutions
soln <- function(p, target=0.3) {
    roots <- polyroot(mypoly(p))
    roots <- Re(roots[abs(Im(roots))<1e-16])
    roots <- roots[roots>0]
    if (length(roots)>1) warn(">1 solution")
    return(roots)
}

Try it out for a couple of cases:

p1 <- c(0.1072518,0.5781922, 0.3877427)
s1 <- soln(p1)
1-prod(1-s1*p1)

p2 <- c(0.0496772, 0.04584501, 0.04210299, 0.04026439, 0.03844668, 0.03487194, 0.03137491)
s2 <- soln(p2)
1-prod(1-s2*p2)

If you don't want to be clever, then brute force is perfectly adequate (56 microseconds on my machine when length(p) is 52):

bsoln <- function(p, target=0.3) {
    f <- function(k) { (1-prod(1-k*p)) - target }
    return(uniroot(f, c(0,20))$root)
}
asoln <- function(p, target=0.3) {
    return(- log(1 - target) / sum(p))
}

I started to run benchmarks and gave up; I don't like the format of microbenchmark output and the approximate solution is too fast for rbenchmark::benchmark() to time accurately. In any case, one run of bsoln() with length(p)==52 takes on the order of 50 microseconds, so you're going to have to run this a whole bunch of times before speed becomes problematic ...

Ben Bolker
  • 173,430
  • 21
  • 312
  • 389
  • Thanks for this - I've added some benchmarking above. On the real problem (where `length(p1) == 52`), `combn` runs into vector allocation problems. Do you have any thoughts on that? – jayb Apr 28 '21 at 16:46
  • 1
    Yes, I'm not surprised :-) (The number of combinations goes up exponentially with n ...) – Ben Bolker Apr 28 '21 at 19:31
  • 2
    I'll just point out that your question did specify a 7th-order polynomial. If you wanted to do this for a 53d-order polynomial it would have been good to say so (I would have known immediately that my proposed solution was impractical.) – Ben Bolker Apr 28 '21 at 19:40
  • Yes, fair point. It was my mistake trying to simplify the toy example but not thinking through to implications – jayb Apr 28 '21 at 20:15
1

Another option would be to just search for a root on a segment without specifically calculating polynomial coefficients. This can be done e.g. with the uniroot function.

Only one not-so-trivial thing we need to do here is to specify the segment. k is obviously >=0 - so that would be the left point. Then we know that all the k*p values should be probabilities, hence <=1. Therefore k <= 1/max(p) - that's the right point.

And so the code is:

find_k <- function(p, taget_op) {
  f <- function(x) 1 - prod(1 - x*p) - target_op
  max_k <- 1/max(p)
  res <- uniroot(f, c(0, max_k))
  res$root
}

p <- runif(1000, 0, 1)
target_op <- 0.3
k <- find_k(p, target_op)
k
#> [1] 0.000710281

1 - prod(1 - k*p)
#> [1] 0.2985806

Created on 2021-04-29 by the reprex package (v1.0.0)

This works pretty fast even for 1000 probabilities.

Iaroslav Domin
  • 2,370
  • 7
  • 17
  • 1
    Thanks again for a second solution! I've added it to the benchmarking. I'm not sure if you seen Ben Bolker's edit above, but you've arrived at the same conclusion. I appreciate the extra details on defining the interval though :) – jayb Apr 29 '21 at 10:49
  • 1
    @jayb whoops, indeed. And it seems like a more precise interval specification doesn't really speed up anything. But in any case I think it's nice to be sure that all the probabilities stay in [0, 1]. – Iaroslav Domin Apr 29 '21 at 11:58