Adjusting to Julia: Tea tasting

By: Jan Vanhove

Re-posted from: https://janhove.github.io/posts/2023-02-23-julia-tea-tasting/index.html

In this third blog post in which I try my hand at the Julia language, I’ll tackle a slight variation of an old problem – Fisher’s tea tasting lady – both analytically and using a brute-force simulation.

The lady tasting tea

In The Design of Experiments, Ronald A. Fisher explained the Fisher exact test using the following example. Imagine that a lady claims she can taste the difference between cups of tea in which the tea was poured into the cup first and then milk was added, and cups of tea in which the milk was poured first and then the tea was added. A sceptic might put the lady to the test and prepare eight cups of tea – four with tea to which milk was added, and four with milk to which tea was added. (Yuck to both, by the way.) The lady is presented with these in a random order and is asked to identify those four cups with tea to which milk was added. Now, if the lady has no discriminatory ability whatever, there is only a 1-in-70 chance she identifies all four cups correctly. This is because there are 70 ways of picking four cups out of eight, and only one of these ways is correct. In Julia:

binomial(8, 4)
70

We can now imagine a slight variation on this experiment. If the lady identifies all four cups correctly, we choose to believe she has the purported discriminatory ability. If she identifies two or fewer cups correctly, we remain sceptical. But she identifies three out of four cups correctly, we prepare another eight cups of tea and give her another chance under the same conditions.

We can ask two questions about this new procedure:

  1. With which probability will we believe the lady if she, in fact, does not have any discriminatory ability?
  2. How many rounds of tea tasting will we need on average before the experiment terminates?

In the following, I’ll share both analytical and a simulation-based answers to these questions.

Analytical solution

Under the null hypothesis of no discriminatory ability, the number of correctly identified cups in any one draw () follows a hypergeometric distribution with parameters (total), (successes) and (draws), i.e., [ X (8, 4, 4). ] In any given round, the subject fails the test if she only identifies 0, 1 or 2 cups correctly. Under the null hypothesis, the probability of this happening is given by , the value of which we can determine using the cumulative mass function of the Hypergeometric(8, 4, 4) distribution. We suspend judgement on the subject’s discriminatory abilities if she identifies exactly three cups correctly, in which case she has another go. Under the null hypothesis, the probability of this happening in any given round is given by , the value of which can be determined using the probability mass function of the Hypergeometric(8, 4, 4) distribution.

With those probabilities in hand, we can now compute the probability that the subject fails the experiment in a specific round, assuming the null hypothesis is correct. In the first round, she will fail the experiment with probability . In order to fail in the second round, she needed to have advanced to the second round, which happens with probability , and then fail in that round, which happens with probability . That is, she will fail in the second round with probability . To fail in the third round, she needed to advance to the third round, which happens with probability and then fail in the third round, which happens with probability . That is, she will fail in the third round with probability . Etc. etc. The probability that she will fail somewhere in the experiment if the null hypothesis is true, then, is given by where the first equality is just a matter of shifting the index and the second equality holds because the expression is a geometric series.

Let’s compute the final results using Julia. The following loads the DataFrames and Distributions packages and then defines d as the Hypergeometric(8, 4, 4) distribution. Note that in Julia, the parameters for the Hypergeometric distribution aren’t (total), (successes) and (draws), but rather (successes), (failures) and (draws); see the documentation. We then read off the values for and from the cumulative mass function and probability mass function, respectively:

using Distributions
d = Hypergeometric(4, 4, 4);
p = cdf(d, 2);
q = pdf(d, 3);

The probability that the subject will fail the experiment if she does indeed not have the purported discriminatory ability is now easily computed:

p / (1 - q)
0.9814814814814815

The next question is how many rounds we expect the experiment to carry on for if the null hypothesis is true. At each round, the probability that the experiment terminates in that round is given by . From the geometric distribution, we know that we then on average need attempts before the first terminating event occurs:

1 / (1 - q)
1.2962962962962965

In sum, if the subject lacks any discriminatory ability, there is only a 1.85% chance that she will pass the test, and on average, the experiment will run for 1.3 rounds.

Brute-force solution

First, we define a function experiment() that runs the experiment once. In essence, we have an urn that contains four correct identifications (true) and four incorrect identifications (false). From this urn, we sample() four identifications without replacement.

Note, incidentally, that Julia functions can take both positional arguments and keyword arguments. In the sample() command below, both urn and 4 are passed as positional arguments, and you’d have to read the documentation to figure out which argument specifies what. The keyword arguments are separated from the positional arguments by a semi-colon and are identified with the parameter’s name.

Next, we count the number of trues in our pick using sum(). Depending on how many trues there are in pick, we terminate the experiment, returning false if we remain sceptic and true if we choose to believe the lady, or we run the experiment for one more round. The number of attempts are tallied and output as well.

function experiment(attempt = 1)
    urn = [false, false, false, false, true, true, true, true]
    pick = sample(urn, 4; replace = false)
    number = sum(pick)
    if number <= 2
        return false, attempt
    elseif number >= 4
        return true, attempt
    else
      return experiment(attempt + 1)
    end
end;

A single run of experiment() could produce the following output:

experiment()
(false, 1)

Next, we write a function simulate() that runs the experiment() a large number of times, and outputs both whether each experiment() led to us believe the lady or remaining sceptical, and how many round each experiment() took. These results are tabulated in a DataFrame – just because. Of note, Julia supports list comprehension that Python users will be familiar with. I use this feature here both the run the experiment a large number of times as well as to parse the output.

using DataFrames

function simulate(runs = 10000)
    results = [experiment() for _ in 1:runs]
    success = [results[i][1] for i in 1:runs]
    attempts = [results[i][2] for i in 1:runs]
    d = DataFrame(Successful = success, Attempts = attempts)
    return d
end;

Let’s swing for the fences and run this experiment a million times. Like in Python, we can make large numbers easier to parse by inserting underscores in them:

runs = 1_000_000;

Using the @time macro, we can check how long it take for this simulation to finish.

@time d = simulate(runs)
  0.359740 seconds (4.07 M allocations: 361.334 MiB, 14.82% gc time, 35.07% compilation time)
1000000×2 DataFrame
999975 rows omitted
Row Successful Attempts
Bool Int64
1 false 1
2 false 1
3 false 1
4 false 1
5 false 1
6 false 2
7 false 3
8 false 2
9 false 1
10 false 1
11 false 1
12 false 1
13 false 2
999989 false 1
999990 false 1
999991 false 1
999992 false 4
999993 false 1
999994 false 1
999995 false 1
999996 false 1
999997 false 1
999998 false 1
999999 false 1
1000000 false 1

On my machine then, running this simulation takes less than a second. Note that 60% of this time is compilation time. (Update: When migrating my blog to Quarto, I reran this code using a new Julia version (1.9.1). Now the code runs faster.) Indeed, if we run the function another time, i.e., after it’s been compiled, the run time drops to about 0.3 seconds (Update: 0.2 seconds now.):

@time d2 = simulate(runs)
  0.209087 seconds (3.89 M allocations: 348.982 MiB, 16.29% gc time)
1000000×2 DataFrame
999975 rows omitted
Row Successful Attempts
Bool Int64
1 false 1
2 false 1
3 false 1
4 false 1
5 false 1
6 false 2
7 false 2
8 false 1
9 false 1
10 false 1
11 false 2
12 false 1
13 false 1
999989 false 3
999990 false 3
999991 false 1
999992 false 1
999993 false 1
999994 false 1
999995 false 1
999996 false 1
999997 false 1
999998 false 2
999999 false 1
1000000 false 1

Using describe(), we see that this simulation – which doesn’t ‘know’ anything about hypergeometric and geometric distributions, produces the same answers that we arrived at by analytical means: There’s a 1.86% chance that we end up believing the lady even if she has no discriminatory ability whatsoever. And if she doesn’t have any discriminatory ability, we’ll need 1.3 rounds on average before terminating the experiment:

describe(d)
2×7 DataFrame
Row variable mean min median max nmissing eltype
Symbol Float64 Integer Float64 Integer Int64 DataType
1 Successful 0.018533 false 0.0 true 0 Bool
2 Attempts 1.29611 1 1.0 10 0 Int64

The slight discrepancy between the simulation-based results and the analytical ones are just due to randomness. Below is a quick way for constructing 95% confidence intervals around both of our simulation-based estimates, and the analytical solutions fall within both intervals.

means = mean.(eachcol(d))
2-element Vector{Float64}:
 0.018533
 1.296105
ses = std.(eachcol(d)) / sqrt(runs)
2-element Vector{Float64}:
 0.00013486862533794173
 0.0006198767726106645
upr = means + 1.96*ses
2-element Vector{Float64}:
 0.018797342505662368
 1.297319958474317
lwr = means - 1.96*ses
2-element Vector{Float64}:
 0.018268657494337634
 1.2948900415256832

TransformVariables.jl gets pretty printing

By: Tamás K. Papp

Re-posted from: https://tamaspapp.eu/pages/blog/2023/02-22-transformvariables-pretty-printing/index.html

A recent update (version 0.8.2) to TransformVariables.jl adds pretty printing of (nested) transformations. Eg what used to be

julia> using StaticArrays, TransformVariablesjulia> t = as((a = asℝ₊, b = as(Array, asℝ₋, 3, 3),
               c = corr_cholesky_factor(13),
               d = as((asℝ, corr_cholesky_factor(SMatrix{3,3}),
                       UnitSimplex(3), UnitVector(4)))))
TransformVariables.TransformTuple{NamedTuple{(:a, :b, :c, :d), Tuple{TransformVariables.ShiftedExp{true, Int64}, TransformVariables.ArrayTransformation{TransformVariables.ShiftedExp{false, Int64}, 2}, CorrCholeskyFactor, TransformVariables.TransformTuple{Tuple{TransformVariables.Identity, TransformVariables.StaticCorrCholeskyFactor{3, 3}, UnitSimplex, UnitVector}}}}}((a = asℝ₊, b = TransformVariables.ArrayTransformation{TransformVariables.ShiftedExp{false, Int64}, 2}(asℝ₋, (3, 3)), c = CorrCholeskyFactor(13), d = TransformVariables.TransformTuple{Tuple{TransformVariables.Identity, TransformVariables.StaticCorrCholeskyFactor{3, 3}, UnitSimplex, UnitVector}}((asℝ, TransformVariables.StaticCorrCholeskyFactor{3, 3}(), UnitSimplex(3), UnitVector(4)), 9)), 97)

which is a single line of 700+ characters (which your browser mercifully hides behind a scrollbar), we now have

julia> using StaticArrays, TransformVariables
[ Info: Precompiling TransformVariables [84d833dd-6860-57f9-a1a7-6da5db126cff]julia> t = as((a = asℝ₊, b = as(Array, asℝ₋, 3, 3),
                      c = corr_cholesky_factor(13),
                      d = as((asℝ, corr_cholesky_factor(SMatrix{3,3}),
                              UnitSimplex(3), UnitVector(4)))))
[1:97] NamedTuple of transformations
  [1:1] :a → asℝ₊
  [2:10] :b → 3×3×asℝ₋ (dimension 1)
  [11:88] :c → 13×13 correlation cholesky factor
  [89:97] :d → Tuple of transformations
    [98:98] 1 → asℝ
    [108:110] 2 → SMatrix{3,3} correlation cholesky factor
    [120:121] 3 → 3 element unit simplex transformation
    [131:133] 4 → 4 element unit vector transformation

which tells you which indices map to which part of the result.

Julia and Python better together

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2023/02/17/python.html

Introduction

Many data scientists with whom I discuss tell me that they like Julia, but
there are some functionalities in Python that they like and would want to
keep using.

In this post I want to show that if you are in this situation the answer is:
it is fine – you can work with Julia and keep using Python packages you like
as a part of your Julia workflow.

The post is tested under Julia 1.8.5 and Status PyCall.jl 1.95.1,
Conda.jl 1.8.0, PyPlot.jl 2.11.0, and GLM.jl 1.8.1.

Level 1: Popular Python packages have an interface in Julia

Many (if not majority of) Python packages are written in other languages (like
C++) and Python is only a wrapper around them. Most Python users do not care
(or even think) about it – they focus on getting their projects delivered.

The same approach can be used in Julia. Specifically, Julia package can be just
a wrapper around some Python package. Examples of such popular packages are:

This is an easy scenario as all you need to do is install a Julia package
and you are ready to go and use your favorite Python package.

I will give here one example from Matplotlib documentation. I want to reproduce
the Python code given here:

# Python code
fig, ax = plt.subplots(figsize=(5, 2.7))
t = np.arange(0.0, 5.0, 0.01)
s = np.cos(2 * np.pi * t)
ax.plot(t, s, lw=2)
ax.annotate('local max', xy=(2, 1), xytext=(3, 1.5),
            arrowprops=dict(facecolor='black', shrink=0.05))
ax.set_ylim(-2, 2)

Now let us do the same in Julia using PyPlot.jl:

# Julia code
using PyPlot
fig, ax = plt.subplots(figsize=(5, 2.7))
t = 0.0:0.01:4.99
s = cos.(2 * π * t)
ax.plot(t, s, lw=2)
ax.annotate("local max", xy=(2, 1), xytext=(3, 1.5),
            arrowprops=Dict("facecolor" => "black", "shrink" => 0.05))
ax.set_ylim(-2, 2)

As you can see the codes are almost identical. The only differences are related
to syntax. For example ' is replaced by " and dict by Dict.

Both codes produce the following plot:

Example plot

Level 2: You can use any Python package from Julia

Not all Python packages have Julia wrappers. Also, in some cases you might
want to work with a different version or configuration of the Python package
than provided by the wrapper. Is this a problem? No, this is not a problem at
all:

You can load and use any Python package from Julia.

There are two Julia packages that allow for this PyCall.jl and
PythonCall.jl. Both packages provide the ability to directly call and
fully interoperate with Python from the Julia language. There are some
technical differences between them which are described here so that
you can decide which one you prefer.

Below I will give you an example of using PyCall.jl.

Assume you like using statsmodels package from Python and would want to
reproduce this example from its documentation:

# Python code
import numpy as np
import statsmodels.api as sm
spector_data = sm.datasets.spector.load()
spector_data.exog = sm.add_constant(spector_data.exog, prepend=False)
mod = sm.OLS(spector_data.endog, spector_data.exog)
res = mod.fit()
print(res.summary())

Let me show you how to reproduce it step-by-step in Julia.

We start with loading the packages:

using PyCall
sm = pyimport("statsmodels.api")

Note that using PyCall.jl we can load any Python package using the
pyimport function. The second line might give you an error like this:

julia> sm = pyimport("statsmodels.api")
ERROR: PyError (PyImport_ImportModule
...

This is not a problem. It is just an information that the statsmodels package
is not installed under Python. In this case you can easily install it from
Julia. Just do:

using Conda
Conda.add("statsmodels")

and you are ready to go. Now sm = pyimport("statsmodels.api") will work.

We are ready to build the model in Julia using statsmodels:

# Julia code
spector_data = sm.datasets.spector.load()
spector_data["exog"] = sm.add_constant(spector_data["exog"], prepend=false)
mod = sm.OLS(spector_data["endog", spector_data["exog"])
res = mod.fit()
res.summary()

and you get the output that is the same as in statsmodels documentation:

PyObject <class 'statsmodels.iolib.summary.Summary'>
"""
                            OLS Regression Results
==============================================================================
Dep. Variable:                  GRADE   R-squared:                       0.416
Model:                            OLS   Adj. R-squared:                  0.353
Method:                 Least Squares   F-statistic:                     6.646
Date:                Fri, 17 Feb 2023   Prob (F-statistic):            0.00157
Time:                        14:41:35   Log-Likelihood:                -12.978
No. Observations:                  32   AIC:                             33.96
Df Residuals:                      28   BIC:                             39.82
Df Model:                           3
Covariance Type:            nonrobust
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
GPA            0.4639      0.162      2.864      0.008       0.132       0.796
TUCE           0.0105      0.019      0.539      0.594      -0.029       0.050
PSI            0.3786      0.139      2.720      0.011       0.093       0.664
const         -1.4980      0.524     -2.859      0.008      -2.571      -0.425
==============================================================================
Omnibus:                        0.176   Durbin-Watson:                   2.346
Prob(Omnibus):                  0.916   Jarque-Bera (JB):                0.167
Skew:                           0.141   Prob(JB):                        0.920
Kurtosis:                       2.786   Cond. No.                         176.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors
is correctly specified.

Note that the differences in codes are minimal. Again I needed to adjust to
Julia syntax by changing False to false and doing dictionary access using
square brackets like in spector_data["exog"]. All else is identical (and for
this reason I put a comment on top showing which language is used as it could
be easily confused).

You might, however, ask if it is possible to use data from Python in Julia (or
data from Julia in Python). Yes – this is also supported. The only thing to
remember is that automatic conversion of values between Python and Julia is done
for a predefined list of most common types (like arrays, dictionaries).

Let me give an example how this is done by estimating the same regression using
GLM.jl. What I will do is transport the data as arrays from Python to Julia
(I chose this case as it is most commonly used in my experience).

using GLM
lm(spector_data["exog"].to_numpy(), spector_data["endog"].to_numpy())

And you get the output:

LinearModel{GLM.LmResp{Vector{Float64}}, GLM.DensePredChol{Float64,
LinearAlgebra.CholeskyPivoted{Float64, Matrix{Float64}, Vector{Int64}}}}:

Coefficients:
───────────────────────────────────────────────────────────────────
         Coef.  Std. Error      t  Pr(>|t|)   Lower 95%   Upper 95%
───────────────────────────────────────────────────────────────────
x1   0.463852    0.161956    2.86    0.0078   0.132099    0.795604
x2   0.0104951   0.0194829   0.54    0.5944  -0.0294137   0.0504039
x3   0.378555    0.139173    2.72    0.0111   0.0934724   0.663637
x4  -1.49802     0.523889   -2.86    0.0079  -2.57115    -0.42488
───────────────────────────────────────────────────────────────────

As you can see the results are the same, except that we have lost column
names. The reason is that we have used arrays to transport data from Python to
Julia (column names also could be transported, but I did not want to
complicate the example).

Conclusions

Today the conclusion is short (but for me extremely powerful):

From Julia you have all Julia and all Python packages available to use in
your projects.

If you know some package in Python and want to keep using it in Julia it is
easy. In most cases you can just copy-paste your Python code to Julia and do
minor syntax adjustments and you are done.

I find this interoperability of Julia and Python really amazing.