Category Archives: Julia

DataFrames.jl training: implementing cross validation

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2021/10/15/cv.html

Introduction

Today I decided to show how cross validation can be implemented using the
functions provided by DataFrames.jl. The objective is to discuss common
patterns used when working with data frames.

In this the post I use Julia 1.6.3, DataFrames.jl 1.2.2, and GLM.jl 1.5.1.

Preparing the data and initial analysis

Let us start by creating some synthetic data set we will work with.

julia> using DataFrames

julia> using GLM

julia> using Random

julia> using Statistics

julia> Random.seed!(1234);

julia> df = DataFrame(randn(50, 9), :auto);

julia> df.y = sum(eachcol(df)) .+ 1.0 .+ randn(50);

julia> df
50×10 DataFrame
 Row │ x1         x2          x3         x4          x5          x6          x7         x8         x9          y
     │ Float64    Float64     Float64    Float64     Float64     Float64     Float64    Float64    Float64     Float64
─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1 │  0.867347  -1.22672     0.183976  -1.87215    -0.205782    0.295222    0.183203   0.358659  -0.833507   -2.20367
   2 │ -0.901744  -0.541716   -1.27635   -0.668331   -1.22338    -0.338215    0.975083   0.488578   0.0827196  -3.31337
  ⋮  │     ⋮          ⋮           ⋮          ⋮           ⋮           ⋮           ⋮          ⋮          ⋮           ⋮
  49 │ -1.00978   -1.66323     0.797165  -0.644069    0.127747    0.742054    0.196089  -1.05575    0.771387   -2.364
  50 │ -0.543805  -0.521229    0.103145  -1.37931     0.143105   -0.0665944  -2.34887   -1.37548    0.590872   -4.76852
                                                                                                           46 rows omitted

As you can see we have created data so that the target variable :y is a sum
of features :x1 to :x9 and an intercept equal to 1. The error term in the
model has a normal distribution with mean 0 and variance 1. Similarly all
features also have a normal distribution with mean 0 and variance 1.

Now we create a linear model on the whole data set to have a baseline:

julia> formula = Term(:y) ~ sum([Term(Symbol(:x, i)) for i in 1:9])
FormulaTerm
Response:
  y(unknown)
Predictors:
  x1(unknown)
  x2(unknown)
  x3(unknown)
  x4(unknown)
  x5(unknown)
  x6(unknown)
  x7(unknown)
  x8(unknown)
  x9(unknown)

julia> model = lm(formula, df)
StatsModels.TableRegressionModel{LinearModel{GLM.LmResp{Vector{Float64}}, GLM.DensePredChol{Float64, LinearAlgebra.CholeskyPivoted{Float64, Matrix{Float64}}}}, Matrix{Float64}}

y ~ 1 + x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + x9

Coefficients:
────────────────────────────────────────────────────────────────────────
                Coef.  Std. Error      t  Pr(>|t|)  Lower 95%  Upper 95%
────────────────────────────────────────────────────────────────────────
(Intercept)  1.01641     0.140685   7.22    <1e-08   0.732078    1.30075
x1           0.879802    0.141336   6.22    <1e-06   0.594151    1.16545
x2           1.14157     0.160897   7.10    <1e-07   0.81639     1.46676
x3           0.820527    0.137644   5.96    <1e-06   0.542338    1.09872
x4           1.28829     0.125645  10.25    <1e-12   1.03435     1.54223
x5           1.02118     0.156195   6.54    <1e-07   0.705503    1.33687
x6           0.844008    0.146142   5.78    <1e-06   0.548643    1.13937
x7           0.70256     0.15646    4.49    <1e-04   0.386343    1.01878
x8           0.925448    0.15797    5.86    <1e-06   0.60618     1.24472
x9           0.889749    0.167878   5.30    <1e-05   0.550456    1.22904
────────────────────────────────────────────────────────────────────────

We calculate mean square error of the model on the training data set:

julia> deviance(model) / nrow(df)
0.6689313350544223

julia> mse(model, df) = sum(x->x^2, predict(model, df) - df.y) / nrow(df);

julia> mse(model, df)
0.6689313350544223

It seems (and indeed is) a bit low given we know that the standard deviation of
the error term in our data generation process was 1. We will come back to
it in a second. However, first let us perfom 10-fold cross validation.

Doing cross validation

We start with generating a column holding the fold number for each row.
In the example we assume we do a 10-fold cross validation and will number folds
by integers starting with 0 and ending with 9. We need to make sure that
folds should be assigned to rows in a random order so we need to shuffle!
the initially generated fold numbers:

julia> df.fold = shuffle!((1:nrow(df)) .% 10)
50-element Vector{Int64}:
 4
 6
 5
 7
 ⋮
 8
 6
 7

Now we can quite easily extract out training and test data sets for each
individual fold using the following function:

get_fold_data(df, fold) =
    (train=view(df, df.fold .!= fold, :),
     test=view(df, df.fold .== fold, :))

Note that in this code we use views to avoid excessive copying of data.

Now we are ready to calculate the cross validation mean squared error (in the
code we take advantage of the fact that each fold has exactly the same size
in our example):

julia> mean(0:9) do fold
           train, test = get_fold_data(df, fold)
           model_cv = lm(formula, train)
           return mse(model_cv, test)
       end
0.9500293257502437

Indeed it is higher than the MSE we have calculated above as expected.

Let us calculate the expected square error for the new observation fed to
our model (I am using here an analytic formula that is suitable for our specific
way of input data generation):

julia> mset(model) = 1 + sum(x -> (1 - x) ^ 2, coef(model));

julia> mset(model)
1.2810475573651952

We can see it is greater than 1 which manes sense as this error is a sum of
two errors: random term in our data generation process plus the estimation error
of parameters of our model.

If you are not convinced that the proper formula is used to calculate this
expectation then it is easy to check it using simulation:

julia> dfn = DataFrame(randn(10^6, 9), :auto);

julia> dfn.y = sum(eachcol(dfn)) .+ 1.0 .+ randn(10^6);

julia> mse(model, dfn)
1.2817533442813103

And we get a roughly similar result.

It is crucial to note that we can calculate mse_t here accurately because we
know the data generation process. In practice we would not know it but would
want get some measure that would as accurately as possible order the competing
models in terms of their true, unobserved mse_t.

Now you might wonder if the cross validation mean squared error should be
expected to be lower than the actual prediction expected squared error as in
our single run and if it is a better predictor of mse_t than mean squared
error calculated on a training data set. We check this in the next section.

Testing the procedure using simulation

Let us wrap our calculations in the function. We will collect three mean
squared errors:

  • mse_whole: calculated on training data set;
  • mse_cv: calculated using cross validation;
  • mse_t: expected prediction squared error.

Here is the function definition:

function runtest()
    df = DataFrame(randn(50, 9), :auto)
    df.y = sum.(eachrow(df)) .+ 1.0 .+ randn(50)
    model = lm(formula, df)
    mse_whole = mse(model, df)

    mse_t = mset(model)

    df.fold = shuffle!((1:nrow(df)) .% 10)
    mse_cv = mean(0:9) do fold
        train, test = get_fold_data(df, fold)
        model_cv = lm(formula, train)
        return mse(model_cv, test)
    end

    return (mse_whole=mse_whole, mse_cv=mse_cv, mse_t=mse_t)
end

Let us now run 10,000 independent repetitions of our experiment and analyze
the results:

julia> Random.seed!(12);

julia> res = DataFrame([runtest() for _ in 1:10_000])
10000×3 DataFrame
   Row │ mse_whole  mse_cv    mse_t
       │ Float64    Float64   Float64
───────┼──────────────────────────────
     1 │  0.767821  1.20796   1.43191
     2 │  0.595431  1.11465   1.2233
     3 │  0.801025  1.41942   1.25682
   ⋮   │     ⋮         ⋮         ⋮
  9998 │  0.589998  0.930069  1.24527
  9999 │  0.444815  0.685732  1.36184
 10000 │  1.05747   1.68496   1.23118
                     9994 rows omitted

julia> describe(res, :all)
3×13 DataFrame
 Row │ variable   mean      std       min       q25       median    q75       max      nunique  nmissing  first     last     eltype
     │ Symbol     Float64   Float64   Float64   Float64   Float64   Float64   Float64  Nothing  Int64     Float64   Float64  DataType
─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1 │ mse_whole  0.799776  0.177712  0.286417  0.672772  0.785994  0.912926  1.71692                  0  0.767821  1.05747  Float64
   2 │ mse_cv     1.29295   0.305371  0.419517  1.07337   1.26767   1.48104   3.0517                   0  1.20796   1.68496  Float64
   3 │ mse_t      1.25542   0.132023  1.01737   1.16157   1.22982   1.32126   2.07322                  0  1.43191   1.23118  Float64

julia> cor(Matrix(res))
3×3 Matrix{Float64}:
  1.0          0.947       -0.00555373
  0.947        1.0         -0.00844714
 -0.00555373  -0.00844714   1.0

What have we learned from our specific experiment setup:

  • mse_whole is significantly biased downwards;
  • mse_cv is slightly biased upwards on the average when multiple experiments
    are considered, but its variance is much higher than the one of mse_t;
  • mse_cv and mse_whole are significantly correlated and they both do not
    exhibit correlation with mse_t.

Conclusions

I hope the examples I gave of using DataFrames.jl in the context of some typical
data science workflow will be useful. A next step – that I leave as an
exercise – would be to e.g. check how model selection using cross validation
works.

Mythbusting Julia speed

By: Mosè Giordano

Re-posted from: http://giordano.github.io/blog/2021-10-14-mythbusting-julia/

Question: I read a lot on the Internet about this programming language called Julia being
very fast, but I’m very skeptic. What do you say?

Answer: It’s good to be skeptic, but you can try yourself!

Q: Some time ago I actually tried and I found it to be very slow. See, I wrote this
small script, but it took almost a couple of seconds to do very simple things…

A: Right. There are some things to keep in mind:

  1. Julia has a non-negligible start-up time. It isn’t that bad, it’s generally on the order
    of 0.4 seconds or so, but if your workflow consists on running several times very small
    scripts, you’re going to pay this cost every time you start Julia. If this is a
    problem, you can either change your workflow to run a single long-running script or just
    don’t use Julia. If you want to repeat very small tasks multiple times driving them from
    the shell maybe Julia isn’t what you need to use and that’s OK. However keep in mind
    that Julia is a perfectly fine language for
    scripting
    , so you could also
    consider using Julia to drive your scripts, which would also allow you not to start Julia
    multiple times.
  2. Julia is a Just-In-Time (JIT)
    compiler, or maybe a Just-Ahead-Of-Time one. This means that in each Julia session, the
    first time you run a function (or a method of a function, to be more precise) it needs to
    be compiled, unless its compiled version was saved in the
    sysimage. Again, compilation time isn’t
    that bad, usually in the order of ~0.1 seconds for most simple functions, but more
    complicated functions can take longer. The problem however is that this time adds up,
    and it adds to the start-up time above.
  3. It’s very easy to make mistakes that very… I mean very negatively affect
    performance of Julia code. The most common gotcha is using global variables. Global
    variables are usually considered bad practice in many compiled languages, in Julia
    non-constant variables completely destroy performance. Avoid them and reorganise the
    code to use functions, or make the global variable constant, if that’s really a constant
    that has to be accessed by multiple functions. See Julia’s performance
    tips
    for more gotchas. While
    it seems daunting to remember all of them, it takes little practice to become familiar.
    Also, you can gradually learn them, you don’t need to get everything right from the
    beginning, you can learn them the hard way 😉

To summarise, Julia gives its best in long-running sessions so that start-up and JIT
compilation costs are paid as few as possible. There are some tools to make it easier to
restart Julia less frequently, or decrease the JIT cost:

  • Revise.jl automatically recompiles methods that
    are modified in the source code of packages or scripts. When developing packages, this
    tool lets you seamlessly hot-reload the code and not restart Julia all the time. Thanks
    to Julia’s introspection and reflection capabilities, this works granularly and recompiles
    only the needed methods. Note that when you do restart Julia, the package you edited will
    be entirely recompiled anyways. One more reason to avoid restarting Julia multiple times
    🙂
  • If you’re an Emacs user, the need to restart a program as little as possible may sound
    familiar. In that case a solution is to start an Emacs server and connect new clients to
    it. It isn’t a surprise there is a similar tool also for Julia:
    DaemonMode.jl. This package is very useful
    if you insinst on a workflow based on starting Julia multiple times to run small scripts:
    with the server-clients model, you pay the cost of startup time only once.
  • With PackageCompiler.jl you can
    create custom sysimages. A common use-case is to fully compile a stable package that
    you’re planning to use very frequently: JIT cost will be drastically reduced, the drawback
    is an increased size of the sysimage on disk.

Q: I’ve seen the micro-benchmarks on Julia’s
website. They seem too good to be real! And the set of benchmarks is very arbitrary to
make Julia look good

A: Let’s make it clear. Benchmarking is hard, languages benchmarks even more so: language
specifications can allow better or worse optimisations, but to get real numbers to compare
you usually use specific implementations (for C/C++ think of GCC vs LLVM vs Intel compilers
vs you-name-it; there are many Python implementations with different focus on speed), which
can have very different performance on different hardware. At that point, what are you
actually benchmarking? Language specification? Particular implementation? Vendor
optimisations? Hardware performance?

There is no reason to think Julia’s micro-benchmarks are somewhat fake: the source code is
available
, anyone can check, improve, and run
it. Julia is in the same ballpark as other compiled languages, like C, LuaJIT, Rust, Go and
Fortran, there is nothing special about it. Just like all other compiled languages, Julia
timings don’t include compilation time, to show the pure runtime performance of compiled
code. I agree the set of these benchmarks is arbitrary, any set would be, it’s impossible
to cover all possible applications! Actually, these benchmarks were chosen early in Julia’s
development to guide optimisations to do in the language. In a sense, Julia was optimised
to make these benchmarks look good.

Q: Well, plotting is so slow in Julia!

A: That’s a fair point. Honestly, plotting doesn’t have to be slow in Julia. Some simple
or low-level plotting packages are actually fairly fast. However, one of the most popular
packages for plotting, Plots.jl tends to feel slow to someone used to plotting in R or
Python. This happens because Plots.jl has an interesting interface with which you can use
the same code to produce plots using different backends, but this means that some code paths
are lazily compiled on-the-fly when a new backend is enabled, including the default one.
This problem used to be very annoying, 2-3 years ago the first time you’d do a plot in a
Julia session (loading the Plots.jl package and running the plot command) it’d take up
to ~20 seconds for the plot to appear, leading to the so-called “time-to-first-plot”
problem. The situation today is much improved, with time-to-first-plot for Plots.jl in
the order of ~5 seconds on recent computers. Definitely not as fast as you’d like, but much
more bearable than before, with the hope to see more small improvements along the way in the
future. As already said, the issue can be made less tedious by restarting Julia as little
as possible, or using a custom sysimage which includes Plots.jl.

Q: I’ve heard claims of people getting some ridiculously large speed-ups by moving to
Julia, like 200x, is that true? Is that even possible?

A: To be fair, probably the original code was not so performant, but speeding it up would
have been complicated for the developer. And maybe with Julia it was easier to use hardware
accelerators like GPUs. Does that make the claim misleading? Maybe so, because that’s not
a 1-to-1 comparison, but again, 1-to-1 comparisons are hard to make in a way that is fair
for all languages. Does that make the claim false? The fact is that Julia allowed them to
more easily enable optimisations that’d have been harder to achieve otherwise with the same
amount of effort. Isn’t that the point of a language that aims to make you more productive?

Q: Then should I expect a similar speed up by moving to Julia?

A: Not really! A more accurate answer would be that it depends. If your C/C++/Fortran code
is already heavily optimised, maybe even with some assembly to squeeze out the last bits of
performance, or if you have a Python+NumPy code which follows best performance practices
(vectorised functions, no for loops, etc…) don’t expect any sensible speedup by
translating it to Julia, if any speedup at all. In this case moving to Julia may not be
even worth the effort, just to set the right expectations. Actually, a naive literal
translation is likely to have fairly bad performance because what’s idiomatic in a language
may not be so in a different language with different properties (for example, they may have
different memory layouts). Julia isn’t a magic wand, it’s a compiler which generates
machine code, you can likely achieve the same machine code with many other languages. The
difference usually lies in the amount of effort you need to put on it with the same
experience in each language, starting a project from scratch: my totally biased claim is
that tends to favour Julia 🙂 On the other hand, if the problem you want to solve isn’t
amenable to be vectorised, note there are no performance penalties in using for loops in
Julia, so in this case moving from a for-allergic language or package to Julia can give
you a nice boost.

If you’re willing to move your code to Julia, the improvements you should expect are not
necessarily about speed: you will work in a portable language, and get access to a wide,
composable ecosystem of packages for numerical computing, with possibility to switch to use
hardware accelerators (like GPUs) with minimal effort.