x = true # Assign the boolean value true directly
y = 100 < 10 # Evaluate an expression; the result (false) is stored in yfalse
These notes cover the foundational building blocks of the Julia programming language. Julia is a high-level, high-performance language designed for technical computing. It combines the ease-of-use of languages like Python and R with the speed of compiled languages like C and Fortran. This makes it an increasingly popular choice in economics, statistics, and the computational sciences.
In this set of notes we will cover the following topics:
if statements, and for loops).NonlinearSolve.jl package to find zeros of nonlinear equations, with applications to CES market equilibrium.Optim.jl package to maximize and minimize functions, with applications to CES utility and profit maximization.By the end of these notes you should be comfortable writing simple Julia programs that store data, iterate over collections, make decisions based on conditions, organize your code into reusable functions, and use packages for root solving and optimization.
Every programming language has a set of primitive data types: the simplest kinds of values that everything else is built on top of. In Julia, the most important primitive types for everyday work are booleans, integers, and floating-point numbers. Understanding these types matters because Julia’s performance advantages come in large part from its type system: when the compiler knows exactly what kind of data it is working with, it can exploit it to generate fast machine code.
A boolean is the simplest possible data type. It can take on exactly two values: true or false. Booleans arise naturally whenever we ask a yes-or-no question: Is \(x\) greater than \(y\)? Is this coefficient statistically significant? Has the optimization converged? Have we found an equilibrium?
You can create a boolean directly or by evaluating an expression:
x = true # Assign the boolean value true directly
y = 100 < 10 # Evaluate an expression; the result (false) is stored in yfalse
We can verify the type of a variable using the built-in typeof function:
typeof(y)Bool
The output Bool confirms that y is a boolean. We will see booleans again when we discuss comparisons and if statements later in these notes.
An integer is a whole number, i.e. any value from the set \(\{\ldots, -3, -2, -1, 0, 1, 2, 3, \ldots\}\). Numbers like \(0.5\) or \(\pi\) are not integers. Integers are the natural choice whenever you are counting discrete quantities: the number of observations in a dataset, the index of an element in an array, or the number of iterations in a loop.
x = 3
y = 5
x + y8
typeof(x)Int64
The type Int64 tells us that Julia is storing this integer using 64 bits of memory, which allows it to represent very large numbers (up to roughly \(9.2 \times 10^{18}\)).
Division of two integers in Julia produces a floating-point result by default, which is the mathematically natural behavior:
x / y0.6
Sometimes, however, we care about the remainder after integer division. The modulo operator % computes this:
5 % 3 # 5 divided by 3 is 1 remainder 22
51 % 17 # 51 is exactly divisible by 17, so the remainder is 00
The modulo operator is surprisingly useful in practice. For example, you can test whether a number is even by checking whether n % 2 == 0, or test divisibility more generally. We will use it later when we write a program to find prime numbers.
Most numbers in economics are not integers. Prices, interest rates, GDP growth, and regression coefficients are all real-valued quantities that require decimal representation. Julia represents these using floating-point numbers (or “floats”), which are stored in the IEEE 754 double-precision format.
Pi = 3.14159265358979323846264
typeof(Pi)Float64
The type Float64 indicates a 64-bit floating-point number, which gives roughly 15–17 significant digits of precision. That there is a limited amount of precision can lead to numerical issues: for example if you add a very large number to a very small number you can run into rounding errors. This won’t be particularly relevant to this class but it is something to keep in mind as you work with computers. Julia also provides a built-in constant pi (with even higher precision through its Irrational type):
piπ = 3.1415926535897...
All the standard arithmetic operations work as you would expect:
pi * 4 # Multiplication12.566370614359172
pi / 3 # Division1.0471975511965976
Exponentiation uses the caret operator ^:
2^4 # 2 raised to the 4th power16
Sometimes you want to make sure Julia treats a number as a float rather than an integer. The simplest way to do this is to include a decimal point:
x = 3 # This is an Int64
y = 3. # This is a Float64
typeof(x), typeof(y)(Int64, Float64)
This distinction can matter for performance. When Julia knows a variable is a Float64, it can use the appropriate hardware instructions for floating-point arithmetic. Mixing types forces Julia to convert on the fly, which can slow things down in tight loops.
A string is a sequence of characters, used to represent text. In Julia, strings are created by enclosing text in double quotes:
greeting = "Hello, world!"
typeof(greeting)String
Note that Julia uses double quotes " " for strings. Single quotes are reserved for individual characters (Char type), which is a distinct type:
c = 'A'
typeof(c)Char
This is a common gotcha for people coming from Python, where single and double quotes are interchangeable.
Julia provides a rich set of operations for working with strings. You can concatenate (join) strings using the * operator:
first_name = "David"
last_name = "Evans"
full_name = first_name * " " * last_name"David Evans"
If you are used to Python or R, the use of * for concatenation may seem surprising. Julia chose * because string concatenation is not commutative (\(\text{"ab"} \neq \text{"ba"}\)), and * is the standard mathematical notation for non-commutative operations (like matrix multiplication).
You can also repeat a string using the ^ operator:
"ha" ^ 3"hahaha"
One of the most convenient features in Julia is string interpolation: embedding the value of an expression directly inside a string using the $ sign:
name = "David"
age = 39
"My name is $name and I am $age years old.""My name is David and I am 39 years old."
For more complex expressions, wrap them in parentheses:
x = 10
"The square of $x is $(x^2).""The square of 10 is 100."
String interpolation is much cleaner than manually concatenating strings with *, especially when building messages that include computed values. We will see this in action with println("Element $i of letter_list is $letter") in the enumerate example below.
Julia includes many built-in functions for working with strings:
s = "Economics is great"
length(s) # Number of characters18
uppercase(s) # Convert to uppercase"ECONOMICS IS GREAT"
occursin("great", s) # Check if a substring is presenttrue
replace(s, "great" => "fascinating") # Replace a substring"Economics is fascinating"
split(s) # Split on whitespace into an array of substrings3-element Vector{SubString{String}}:
"Economics"
"is"
"great"
The split function is particularly useful when parsing data files where fields are separated by spaces, commas, or other delimiters.
In data work you frequently need to convert between strings and numeric types. Use parse to convert a string to a number, and string to go the other direction:
parse(Int64, "42") # String → Integer42
parse(Float64, "3.14") # String → Float3.14
string(2.718) # Number → String"2.718"
The parse function will throw an error if the string cannot be interpreted as the requested type—for example, parse(Int64, "hello") would fail. This is useful as a built-in sanity check when reading data.
Individual numbers are useful, but most real work involves collections of values. Julia provides several container types for grouping data together. We’ve already seen examples of containers come up: for example the split(s) function produced a Vector (Array). Each container type has different properties that make it suitable for different situations:
| Container | Syntax | Mutable? | Access |
|---|---|---|---|
| Array | [1, 2, 3] |
Yes | By index |
| Tuple | (1, 2, 3) |
No | By index |
| Named Tuple | (a=1, b=2) |
No | By name or index |
| Dictionary | Dict("a" => 1) |
Yes | By key |
The key trade-offs are mutability (can you change the contents after creation?) and access pattern (do you look things up by position or by name?). Let us look at each in turn.
An array is the workhorse container in Julia. It is an ordered, mutable collection of elements. “Ordered” means the elements have a definite sequence (first, second, third, …), and “mutable” means you can change, add, or remove elements after the array is created.
Arrays are created using square brackets:
David = [32, "male", 71.5] # An array containing mixed types
typeof(David)Vector{Any} (alias for Array{Any, 1})
The type Vector{Any} tells us two things: this is a one-dimensional array (a Vector), and it can hold elements of Any type. Julia determined this because the array contains an integer, a string, and a float—three different types.
When all the elements in an array share the same type, Julia can be much more efficient. It stores the data in a compact, contiguous block of memory and avoids the overhead of checking types at runtime:
Davidfloat = [32.1, 16., 35]
typeof(Davidfloat)Vector{Float64} (alias for Array{Float64, 1})
Here Julia infers that every element is a Float64, so it creates a Vector{Float64}. If you try to insert a value of the wrong type, Julia will raise an error:
Davidfloat[2] = "test" # Cannot assign a String to a Float64 arrayMethodError: Cannot `convert` an object of type String to an object of type Float64 The function `convert` exists, but no method is defined for this combination of argument types. Closest candidates are: convert(::Type{T}, ::T) where T<:Number @ Base number.jl:6 convert(::Type{T}, ::Number) where T<:Number @ Base number.jl:7 convert(::Type{T}, ::T) where T @ Base Base_compiler.jl:133 ... Stacktrace: [1] setindex!(A::Vector{Float64}, x::String, i::Int64) @ Base ./array.jl:985 [2] top-level scope @ ~/University of Oregon Dropbox/David Evans/University of Oregon/Courses/Spring 2026/EC 410/Lectures and Website/Julia/Basic Julia/JuliaBasicsNotes.qmd:287
In performance-critical code (e.g., numerical simulations, optimization routines), you should try to use typed arrays whenever possible.
You access elements of an array using square-bracket indexing:
David[2] # Access the second element"male"
Because arrays are mutable, you can change existing elements and append new ones:
David[3] = David[3] / 12 # Modify the third element
push!(David, 12) # Append a new element to the end
David4-element Vector{Any}:
32
"male"
5.958333333333333
12
Note the exclamation mark in push!. In Julia, function names ending with ! follow a convention indicating that the function modifies its argument in place rather than returning a new copy. This is an important idiom you will see throughout the language.
Unlike Python and C (which start at 0), Julia arrays start at index 1. This is a common source of bugs when switching between languages. The first element is L[1], not L[0].
Here are the key indexing conventions:
L[1] — the first elementL[i] — the element at position \(i\)L[end] — the last elementL[end-1] — the second-to-last elementThe end keyword is particularly convenient because it saves you from having to compute the length of the array.
You can extract a contiguous subset of an array using the colon : operator. The expression L[a:b] returns a new array containing elements at positions \(a, a+1, \ldots, b\):
print(David[1:3]) # Equivalent to David[1:end-1] in this caseAny[32, "male", 5.958333333333333]
Slicing is useful for extracting subsets of data—for example, taking the first \(n\) observations from a dataset, or splitting a time series into training and test sets.
A tuple is similar to an array in that it holds an ordered collection of elements, but with one critical difference: tuples are immutable. Once created, their contents cannot be changed. This makes tuples useful for representing fixed collections of values—things that should not accidentally be modified.
Tuples are created using parentheses (or simply by separating values with commas):
d = (39, "male", 71.5) # Parentheses notation
d = 39, "male", 71.5 # Equivalent: comma-separated values
print(typeof(d))Tuple{Int64, String, Float64}
Notice that Julia tracks the type of each element individually. The type Tuple{Int64, String, Float64} tells us exactly what is in each position.
Attempting to modify a tuple produces an error:
d[1] = 5 # Tuples are immutable!MethodError: no method matching setindex!(::Tuple{Int64, String, Float64}, ::Int64, ::Int64)
The function `setindex!` exists, but no method is defined for this combination of argument types.
Stacktrace:
[1] top-level scope
@ ~/University of Oregon Dropbox/David Evans/University of Oregon/Courses/Spring 2026/EC 410/Lectures and Website/Julia/Basic Julia/JuliaBasicsNotes.qmd:355
One of the most useful features of tuples is unpacking: assigning each element of a tuple to a separate variable in a single statement:
integers = (4, 5, 6)
x, y, z = integers
x4
z6
This works with arrays too:
x, y, z = David # Unpacks the first three elements
x32
Tuple unpacking is especially handy when a function returns multiple values. For example, a function that performs a statistical test might return both the test statistic and the p-value as a tuple, and you can unpack them directly into named variables.
Named tuples combine the immutability of regular tuples with the readability of named access. Instead of remembering that the age is at position 1 and the height is at position 3, you can access fields by name:
person = (name="David", age=39, height=71.5)
typeof(person)@NamedTuple{name::String, age::Int64, height::Float64}
person.name # Access by name (preferred for readability)"David"
person[1] # Can still access by index if needed"David"
Named tuples are ideal when you want to return multiple values from a function with descriptive labels. They are lightweight (no need to define a struct, we’ll talk about this later) and self-documenting. For example, a calibration function might return (β=0.96, σ=2.0, δ=0.025) so the caller immediately knows which parameter is which.
A dictionary (or Dict) is a mutable container that maps keys to values. Unlike arrays and tuples, dictionaries are not ordered by position—instead, you look up values by their key. This makes dictionaries ideal when you need to associate labels with data.
David = Dict("age" => 39, "gender" => "male", "height" => 71.5)
print(typeof(David))Dict{String, Any}
David["age"] # Look up a value by its key39
Because dictionaries are mutable, you can add new key-value pairs or change existing ones:
David["job"] = "Economist"
David["job"]"Economist"
In the dictionary above, "age", "gender", "height", and "job" are the keys, and 39, "male", 71.5, and "Economist" are the corresponding values.
Dictionaries are especially useful when dealing with structured data where positional indexing would be confusing. Imagine an array with 20 elements—was the interest rate at position 7 or position 12? With a dictionary you simply write params["interest_rate"] and the code is self-documenting.
To summarize, here are some rules of thumb:
Much of computational economics involves repeating an operation many times: simulating an economy for \(T\) periods, computing a sum over \(N\) observations, or iterating on a value function until convergence. The for loop is the basic tool for this kind of repetition.
The general form of a for loop in Julia is:
for variable in iterable
# code that may depend on variable
endThe iterable is any object Julia can step through one element at a time—an array, a tuple, a range like 1:10, and so on. For each element in the iterable, Julia executes the code inside the loop with variable set to the current element.
Here is a simple example:
for i in 1:5
println(i)
end1
2
3
4
5
The expression 1:5 creates a range object representing the sequence \(1, 2, 3, 4, 5\). The loop variable i takes on each value in turn, and println prints it followed by a newline. You can think of code like this a rolling out a sequence of lines of code
println(1) #i=1
println(2) #i=2
println(3) #i=3
println(4) #i=4
println(5) #i=51
2
3
4
5
but often times there are additional optimizations the compiler can run behind the scenes that you don’t have to worry about.
We can put any code we like inside the loop body:
for i in 1:5
x = i^2
println(x)
end1
4
9
16
25
For loops are not limited to numeric ranges. You can loop directly over the elements of any collection:
David = [32, "male", 71.5]; # Semicolon suppresses output
for element in David
println(element)
end32
male
71.5
This style—iterating over elements directly rather than over indices—is considered more idiomatic in Julia and tends to produce cleaner, more readable code:
x_values = [4, 2, 3]
for x in x_values
println(x * x)
end16
4
9
Compare that with the index-based alternative, which is more verbose and harder to read:
for i in 1:length(x_values)
println(x_values[i] * x_values[i])
end16
4
9
Both produce the same result, but the first version communicates intent more clearly: “for each value in the list, print its square.”
enumerate for Index-Value PairsSometimes you need both the index and the value during iteration. Rather than managing a counter variable yourself, Julia provides the enumerate function:
Use enumerate when you need both the index and the value. It is cleaner and less error-prone than managing a separate counter.
letter_list = ['a', 'b', 'c']
for (i, letter) in enumerate(letter_list)
println("Element $i of letter_list is $letter")
endElement 1 of letter_list is a
Element 2 of letter_list is b
Element 3 of letter_list is c
Notice the use of string interpolation with the $ sign: inside a string, $i is replaced by the value of i. This is a convenient Julia feature for building formatted output.
A common operation in economics and statistics is the inner product (or dot product) of two vectors. Given \(x = [x_1, \ldots, x_n]\) and \(y = [y_1, \ldots, y_n]\), the inner product is:
\[\langle x, y \rangle = \sum_{i=1}^{n} x_i \cdot y_i\]
Here is how you might compute it with a for loop:
x = [3, 57, 2, 1, 8, 5]
y = [4.3, 23, 2, 47, 42, 6]
ret = 0.0
for i in 1:length(x)
ret = ret + x[i] * y[i] # Equivalently: ret += x[i] * y[i]
end
ret1740.9
Note that we initialize ret = 0.0 (a float) rather than ret = 0 (an integer). This ensures the accumulator has the right type from the start, avoiding a type conversion partway through the loop.
(In practice, Julia provides built-in functions for this—dot(x, y) from the LinearAlgebra standard library—but it is instructive to see how you would implement it yourself.)
In macroeconomics, the geometric sum appears frequently when computing present discounted values. Given a discount factor \(\beta \in (0,1)\), the sum:
\[S = \sum_{j=0}^{50} \beta^j\]
can be computed as follows:
β = 0.95 # Type \beta then press Tab to get the Greek letter
ret = 0.0
for j in 0:50
ret += β^j
end
print(ret)18.538045469742436
Julia supports Unicode identifiers, which means you can use Greek letters in your code. To type β, enter \beta and press Tab in the Julia REPL or a Jupyter/Quarto code cell. This makes your code look much closer to the mathematical notation—a significant readability advantage over languages that restrict you to ASCII variable names.
As a check: the closed-form solution for a geometric series is \(S = \frac{1 - \beta^{51}}{1 - \beta}\). For \(\beta = 0.95\), this gives approximately \(\frac{1 - 0.95^{51}}{0.05} \approx 18.34\), which matches our loop result.
Now that we know how to store data and iterate, the next step is making decisions—executing different code depending on whether a condition is true or false. This requires two ingredients: comparisons (which produce boolean values) and logical operators (which combine boolean values).
Julia provides the standard comparison operators you would expect from mathematics:
| Operator | Meaning |
|---|---|
< |
Less than |
> |
Greater than |
<= |
Less than or equal to |
>= |
Greater than or equal to |
== |
Equal to |
!= |
Not equal to |
Each comparison returns a boolean:
x, y = 1, 2
x < ytrue
x > yfalse
A particularly nice feature of Julia is that you can chain comparisons, just as you would in mathematical notation. The expression a < b < c is equivalent to a < b && b < c, but more readable:
1 < 2 < 3true
1 <= 2 <= 3 > 4false
This is very handy for checking whether a value falls within a range: 0 < x < 1 reads naturally as “\(x\) is between 0 and 1.”
Do not confuse = (assignment) with == (equality test). Writing x = 2 sets x to 2, while x == 2 asks whether x is equal to 2. This is an easy mistake to make early on.
x = 1 # Assignment: x is now 1
x == 2 # Equality test: is x equal to 2?false
x != 2 # Inequality test: is x NOT equal to 2?true
Logical operators let you combine multiple boolean expressions into a single condition. Julia uses the following syntax:
| Expression | Meaning | Result |
|---|---|---|
P && Q |
Logical AND | true only if both P and Q are true |
P \|\| Q |
Logical OR | true if at least one of P or Q is true |
!P |
Logical NOT | Negates P: true becomes false and vice versa |
&&)The && operator returns true only when both sides are true:
1 < 2 && 'f' in "foo" # Both true → truetrue
1 < 2 && 'g' in "foo" # Second is false → falsefalse
||)The || operator returns true when at least one side is true:
1 < 2 || 'g' in "foo" # First is true → true (second doesn't matter)true
Julia uses short-circuit evaluation for both && and ||. For &&, if the first operand is false, Julia does not even evaluate the second (since the result must be false regardless). For ||, if the first operand is true, the second is skipped. This can improve performance and is also used as a control-flow idiom in Julia.
!)The ! operator simply flips a boolean value:
!truefalse
!!true # Double negation returns the original valuetrue
Comparisons and logical operators become truly powerful when combined with conditional statements. An if statement lets you execute a block of code only when a specified condition holds.
The simplest form is:
if condition
# code to execute when condition is true
endx = 5
if x > 4
println("Yay!")
endYay!
If the condition evaluates to false, the code block is simply skipped:
if x < 4
println("This code never runs.")
endNothing is printed, because 5 < 4 is false.
Often you want to do one thing when a condition is true and something else when it is false. The else clause handles this:
x = Int[] # Initialize an empty array of integers
for i in 1:10
if 3 <= i <= 7
push!(x, i^2) # Square the value if i is between 3 and 7
else
push!(x, i) # Otherwise, just use i as-is
end
endprintln(x)[1, 2, 9, 16, 25, 36, 49, 8, 9, 10]
Notice how the chained comparison 3 <= i <= 7 makes the condition easy to read. The resulting array contains the original values for \(i \in \{1,2,8,9,10\}\) and the squared values for \(i \in \{3,4,5,6,7\}\).
Julia also supports elseif for checking multiple conditions in sequence:
if condition1
# code for condition1
elseif condition2
# code for condition2
else
# code if neither condition is met
endLet us put everything together with a more substantial example: finding all prime numbers from 2 to 500. A prime number is a natural number greater than 1 that is divisible only by 1 and itself. The algorithm below uses a straightforward approach: for each candidate number, check whether any previously found prime divides it evenly.
primes = Int[] # Initialize an empty array of integers
for i in 2:500 # Loop through candidate numbers
isprime = true # Assume i is prime until proven otherwise
for p in primes # Check each known prime
if i % p == 0 # If p divides i evenly...
isprime = false # ...then i is not prime
end
end
if isprime # If no known prime divided i...
push!(primes, i) # ...then i is prime; add it to our list
end
end
print(primes)[2, 3, 5, 7, 11, 13, 17, 19, 23, 29 … 443, 449, 457, 461, 463, 467, 479, 487, 491, 499]
This example uses nearly every concept from these notes:
primes) to store the results, with push! to grow it dynamically.%) to test divisibility.isprime) to track the result of the divisibility checks.While this algorithm is not the most efficient way to find primes (a Sieve of Eratosthenes would be faster), it illustrates how simple programming constructs combine to solve a nontrivial problem.
So far we have been writing code as a sequence of statements: one after another, top to bottom. This works for small scripts, but as programs grow it quickly becomes unwieldy. Functions solve this problem by letting you package a piece of code into a reusable, named unit that takes inputs and produces outputs. Functions are arguably the single most important organizational tool in programming. They are also the secret sauce in julia: julia optimizes functions by compiling them into machine code.
There are several reasons to use functions:
simulateAR1(0.0, 100) communicates intent far more clearly than the 10 lines of loop code it encapsulates.Julia’s functions are very flexible. Any object can be passed as an argument, even other functions! Functions can return any kind of object, including other functions. And any number of functions can be defined in a single file.
The simplest way to create a function in Julia is the arrow syntax:
f = x -> x^2
f(2)4
f(3)9
Here x -> x^2 creates an anonymous function (also called a lambda function): a function with no name. We then assign it to the variable f so we can call it later. Anonymous functions are especially useful when you need a short, throwaway function—for example, as an argument to map, filter, or sort.
For simple one-line functions that you want to give a name to, Julia provides a compact assignment form:
g(x) = x^2
g(5)25
This is equivalent to writing g = x -> x^2, but reads more naturally. You will see this style used frequently for mathematical functions that can be expressed in a single expression.
functionFor anything more than a single expression, use the function keyword:
function h(x)
return x^2
end
h(pi)9.869604401089358
The return keyword specifies what value the function gives back to the caller. If you omit return, Julia automatically returns the value of the last expression evaluated in the function body. While this implicit return is convenient for short functions, using return explicitly is good practice for clarity, especially in longer functions.
You can give function arguments default values, making them optional when the function is called:
function greet(name, greeting="Hello")
println("$greeting, $(name)!")
end
greet("David")Hello, David!
greet("David", "Howdy")Howdy, David!
Default arguments are specified with = in the function signature. When the caller omits that argument, the default is used. This is particularly useful for parameters that have a “standard” value but occasionally need to be overridden—like the persistence parameter in a time series model, or the tolerance in an optimization routine.
Functions can return multiple values by returning a tuple. The caller can then unpack the results:
function divide(a, b)
quotient = a ÷ b # integer division
remainder = a % b
return quotient, remainder
end
q, r = divide(17, 5)
println("17 ÷ 5 = $q remainder $r")17 ÷ 5 = 3 remainder 2
This pattern is extremely common in scientific computing. For example, a function that estimates a regression model might return both the coefficient vector and the standard errors. Recall from our earlier discussion that named tuples are an even more readable way to return multiple values: return (coef=β, se=σ).
Julia supports docstrings—documentation strings placed immediately before a function definition. These serve as built-in documentation that can be accessed from the REPL using ?:
"""
square(x)
Return the square of `x`.
"""
function square(x)
return x^2
endMain.Notebook.square
Writing docstrings is a good habit. They make your code self-documenting and help both your collaborators and your future self understand what each function does, what arguments it expects, and what it returns.
Let us put functions to work with an example from time series econometrics. An AR(1) process (first-order autoregressive process) is defined by:
\[ x_{t} = \rho x_{t-1} + \epsilon_{t} \quad\text{where}\quad \epsilon_{t} \sim \mathcal{N}(0,1) \]
Here \(x_t\) is a random variable at time \(t\). Its value depends on the previous period’s value \(x_{t-1}\) (scaled by the persistence parameter \(\rho\)) plus a random shock \(\epsilon_t\) drawn from a standard normal distribution. AR(1) processes are ubiquitous in macroeconomics and finance—they are used to model productivity shocks, income processes, interest rates, and many other time series.
Before writing the full simulation function, let us see how Julia generates random numbers. The randn() function draws from the standard normal distribution \(\mathcal{N}(0,1)\):
println(randn())
println(randn())
println(randn())0.5823714071281189
-0.37028314179894617
-1.0187146419647202
You can also generate an entire array of random numbers at once:
randn(5)5-element Vector{Float64}:
-1.9550833235826852
1.318778505892125
-1.1540191806490612
1.4015403045706611
2.080821559546796
If \(x_{t-1} = 5\) and \(\rho = 0.8\), a single step of the AR(1) process looks like:
x = 5.0
ρ = 0.8
print(ρ * x + randn())3.130730344453524
Now let us simulate multiple periods. Starting from \(x_1 = 5\) with \(\rho = 0.8\), we simulate \(T = 10\) periods by pre-allocating an array and filling it in with a for loop:
T = 10
x = zeros(T) # pre-allocate array of zeros
x[1] = 5.0 # set initial condition
ρ = 0.8
# simulate using a for loop
for t in 2:T
x[t] = ρ * x[t-1] + randn() # update using previous period's value
end
@show x;x = [5.0, 1.9901403891536105, 1.0628777341184208, 0.42537370890233284, -1.028302628752003, -0.7875900096614094, 0.45060446339243265, -1.0033683897154098, 1.3682017097539751, 0.28429685684561756]
Each iteration updates \(x_t = \rho \, x_{t-1} + \varepsilon_t\). Because \(|\rho| < 1\), the series mean-reverts toward zero: large deviations are pulled back, and the long-run average is zero regardless of the initial condition.
We can visualize the simulated path using Plots.jl:
using Plotsplot(x, linewidth=2)
xlabel!("time")
ylabel!("x_t")
title!("AR(1) Process")The xlabel!, ylabel!, and title! functions modify the current plot in place (note the ! convention). We will use Plots.jl extensively throughout this course.
Now we can wrap the full simulation into a function. Notice how it uses several concepts we have covered: arrays, for loops, default arguments, and a docstring:
"""
simulateAR1(x0, T, ρ=0.8)
Simulate `T` periods of the AR(1) process xₜ = ρ*xₜ₋₁ + εₜ
where εₜ ~ N(0,1).
# Arguments
- `x0`: initial value for x
- `T`: number of periods to simulate
- `ρ`: persistence parameter (default 0.8)
# Returns
- Vector of length `T` containing the simulated path
"""
function simulateAR1(x0, T, ρ=0.8)
x = zeros(T) # Pre-allocate array
x[1] = x0 # Set initial condition
for t in 2:T
x[t] = ρ * x[t-1] + randn()
end
return x
endMain.Notebook.simulateAR1
A few things to note about this function:
zeros(T) rather than grown incrementally with push!. This is much more efficient for numerical work because Julia can allocate a single contiguous block of memory up front.ρ=0.8 means the caller can simply write simulateAR1(0.0, 100) for the most common case.Now we can simulate and plot paths for different values of \(\rho\):
plot(simulateAR1(0., 100, 0.1), linewidth=1.5, label="ρ = 0.1")
plot!(simulateAR1(0., 100, 0.5), linewidth=1.5, label="ρ = 0.5")
plot!(simulateAR1(0., 100, 0.99), linewidth=1.5, label="ρ = 0.99")
xlabel!("time")The first plot call creates a new figure, and plot! adds additional lines to it. Higher values of \(\rho\) produce more persistent series—shocks take longer to die out, and the series wanders farther from zero. When \(\rho = 0.99\), a shock at time \(t\) still has a noticeable effect many periods later. When \(\rho = 0.1\), the series is nearly white noise. This is an important intuition for understanding macroeconomic dynamics: highly persistent processes (like log GDP) behave very differently from low-persistence processes (like inflation surprises).
Many important problems in economics can be formulated as finding the root of a function—that is, finding a value \(x^*\) such that \(f(x^*) = 0\). Market equilibrium conditions, Euler equations from dynamic optimization, and fixed-point problems all take this form. While simple linear equations can be solved by hand, realistic models—especially those with constant-elasticity (CES) functional forms—involve nonlinear equations that require numerical methods.
The NonlinearSolve.jl package provides robust, modern algorithms for solving systems of nonlinear equations in Julia. It is part of the SciML ecosystem and is the recommended choice for new Julia projects.
using NonlinearSolveTo use NonlinearSolve, you follow a three-step pattern:
f(u, p) where u is a vector of unknowns and p is a parameter argument (use _ if you don’t need parameters).NonlinearProblem(f, u0) where u0 is your initial guess.sol = solve(prob).The basic calling pattern is:
prob = NonlinearProblem(f, u0) # create the problem with initial guess u0
sol = solve(prob) # solve it
sol.u # the solution x*
sol.retcode # convergence statusThe f(u, p) signature is a convention of the SciML ecosystem. The second argument p allows you to pass parameters (like elasticities or prices) into the problem without hard-coding them. If you don’t need parameters, simply use _ as a placeholder.
Consider a market with constant-elasticity demand and supply:
\[D(p) = A\, p^{-\epsilon_d}, \quad S(p) = B\, p^{\,\epsilon_s}\]
These are the workhorse functional forms in trade and public finance because the elasticity parameters \(\epsilon_d\) and \(\epsilon_s\) are constant—they do not depend on the price level. The equilibrium price \(p^*\) satisfies \(D(p^*) = S(p^*)\), or equivalently, the excess demand \(D(p) - S(p) = 0\).
Unlike linear demand and supply, this equation is nonlinear and generally has no closed-form solution. This is exactly the kind of problem where a numerical solver earns its keep:
function excess_demand(p, _)
A, εd = 100.0, 2.0 # demand parameters
B, εs = 20.0, 1.5 # supply parameters
D = A * p[1]^(-εd) # CES demand
S = B * p[1]^εs # CES supply
return [D - S] # excess demand
end
prob = NonlinearProblem(excess_demand, [1.0])
sol = solve(prob)
sol.u1-element Vector{Float64}:
1.583819608766579
Notice that even though we have a single equation, p is still a vector (of length 1) and the function returns a vector—NonlinearSolve always works with vectors. The _ in the function signature indicates that we are not using the parameter argument. We can verify the solution satisfies \(D(p^*) = S(p^*)\) by plugging back in:
A, εd = 100.0, 2.0 # demand parameters
B, εs = 20.0, 1.5 # supply parameters
D = A * sol.u[1]^(-εd)
S = B * sol.u[1]^εs
println("D = $D, S = $S")D = 39.86470631277377, S = 39.86470631277376
Demand and supply are equal at the equilibrium price, confirming the solver found the correct root.
NonlinearSolve really shines when you have a system of equations. Consider two goods with constant-elasticity demand that includes cross-price effects. When goods are substitutes, an increase in the price of one good raises demand for the other:
\[D_1(p_1, p_2) = 100\, p_1^{-2}\, p_2^{0.5}, \quad S_1(p_1) = 20\, p_1^{1.5}\] \[D_2(p_1, p_2) = 80\, p_2^{-1.5}\, p_1^{0.3}, \quad S_2(p_2) = 15\, p_2^{2}\]
The two equilibrium conditions \(D_1 = S_1\) and \(D_2 = S_2\) must hold simultaneously, so the prices are jointly determined. We can solve this system numerically:
function two_market(p, _)
D1 = 100 * p[1]^(-2.0) * p[2]^(0.5) # good 1 demand
S1 = 20 * p[1]^(1.5) # good 1 supply
D2 = 80 * p[2]^(-1.5) * p[1]^(0.3) # good 2 demand
S2 = 15 * p[2]^(2.0) # good 2 supply
return [D1 - S1, D2 - S2]
end
prob = NonlinearProblem(two_market, [2.0, 2.0])
sol = solve(prob)
sol.u2-element Vector{Float64}:
1.7069573735223966
1.6889575066262879
You can verify convergence by checking sol.retcode:
sol.retcodeReturnCode.Success = 1
The cross-price elasticities (\(p_2^{0.5}\) in the demand for good 1, \(p_1^{0.3}\) in the demand for good 2) mean that a supply shock to one market spills over into the other—a feature that makes analytical solutions intractable but poses no difficulty for a numerical solver.
sol.retcode to confirm the solver found a solution successfully.f(u, p) signature. Your function must accept two arguments even if you don’t use parameters—use _ as a placeholder for the unused argument.Optimization is at the heart of economics. Consumers maximize utility, firms maximize profit, econometricians maximize likelihood functions, and social planners minimize welfare losses. While simple optimization problems can be solved analytically using calculus (set the derivative to zero and solve), many realistic problems—especially those involving nonlinear functions, multiple variables, or numerical simulations—require computational methods.
The Optim.jl package provides a collection of optimization algorithms for unconstrained (and box-constrained) minimization in Julia.
using OptimAn important convention: Optim.jl minimizes functions by default. If you want to maximize a function \(f(x)\), you minimize \(-f(x)\). This is a standard convention in numerical optimization libraries.
For scalar (univariate) functions, provide lower and upper bounds:
result = optimize(f, lower, upper) # uses Brent's methodFor multivariate functions, provide an initial guess:
result = optimize(f, x0) # uses Nelder-Mead by defaultIn both cases, the result object contains:
Optim.minimizer(result) — the optimal input \(x^*\)Optim.minimum(result) — the optimal function value \(f(x^*)\)Consider a consumer with CES preferences over two goods:
\[U(x_1, x_2) = \bigl(\alpha\, x_1^{\rho} + (1-\alpha)\, x_2^{\rho}\bigr)^{1/\rho}\]
where \(\sigma = 1/(1-\rho)\) is the elasticity of substitution. The consumer faces a budget constraint \(p_1 x_1 + p_2 x_2 = I\). By substituting \(x_2 = (I - p_1 x_1)/p_2\), we can reduce this to a single-variable optimization problem:
function neg_utility(x1; α=0.5, ρ=-1.0, p1=1.0, p2=2.0, I=100.0)
x2 = (I - p1 * x1) / p2
return -(α * x1^ρ + (1-α) * x2^ρ)^(1/ρ)
end
result = optimize(neg_utility, 1.0, 99.0)
x1_star = Optim.minimizer(result)
x2_star = (100.0 - x1_star) / 2.0
println("x₁ = $(round(x1_star, digits=2)), x₂ = $(round(x2_star, digits=2))")x₁ = 41.42, x₂ = 29.29
Here we use optimize(f, lower, upper) which applies Brent’s method for scalar optimization over the interval \([1, 99]\) (the bounds ensure both goods are consumed). With \(\rho = -1\) (so \(\sigma = 1/2\)), the goods are complements—the consumer wants to balance consumption of both goods rather than specialize. Note the use of keyword arguments (α=0.5, etc.) to make the function parameters clear while keeping the optimizer interface clean.
Because the budget constraint pins down \(x_2 = (I - p_1 x_1)/p_2\), utility is effectively a function of \(x_1\) alone. We can visualize this to confirm the optimizer found the peak:
x1_grid = LinRange(1.0, 99.0, 100) # 100 points between 1 and 99
utility = [-neg_utility(x1) for x1 in x1_grid] # compute utility for each x1
plot(x1_grid, utility,
xlabel = "x₁", ylabel = "U(x₁, x₂(x₁))",
title = "CES Utility along the Budget Constraint",
lw = 2, legend = false)
vline!([x1_star], ls = :dash, color = :red, lw = 1.5)
annotate!(x1_star + 2, maximum(utility) - 0.1,
text("x₁* = $(round(x1_star, digits=1))", :left, 9))The dashed red line marks the optimum found by Optim. The concavity of the CES utility function guarantees a unique interior maximum—the consumer balances spending across both goods.
Here is a second scalar optimization example. Consider a firm with Cobb-Douglas production \(Y = A\bar{K}^{\alpha} L^{1-\alpha}\) that chooses labor \(L\) in the short run, taking capital \(\bar{K}\) as fixed. Revenue is \(p \cdot Y\) and the only variable cost is the wage bill \(wL\), so profit is:
\[\pi(L) = p \cdot A\, \bar{K}^{\alpha}\, L^{1-\alpha} - wL\]
Because \(1-\alpha < 1\), the production function exhibits diminishing marginal returns to labor, which guarantees an interior optimum. We can verify this analytically: the first-order condition \(p \cdot A\bar{K}^{\alpha}(1-\alpha)L^{-\alpha} = w\) gives \(L^* = \bigl(\frac{p A \bar{K}^{\alpha}(1-\alpha)}{w}\bigr)^{1/\alpha}\). Let us confirm numerically:
function neg_profit(L; A=1.0, K̄=10.0, α=1/3, p=10.0, w=5.0)
Y = A * K̄^α * L^(1-α)
return -(p * Y - w * L) # negate for minimization
end
result = optimize(neg_profit, 0.1, 1000.0)
L_star = Optim.minimizer(result)
println("Optimal labor: $(round(L_star, digits=2))")
println("Maximum profit: $(round(-Optim.minimum(result), digits=2))")Optimal labor: 23.7
Maximum profit: 59.26
Notice that this is again a scalar problem, so we use optimize(f, lower, upper) with bounds that keep \(L > 0\). The keyword arguments (A=1.0, K̄=10.0, etc.) set the model parameters as defaults, keeping the function signature clean for the optimizer while making the economics transparent.
LBFGS() are much faster. For scalar problems, optimize(f, lower, upper) uses Brent’s method, which is efficient and reliable.Optim.converged(result) to verify the optimizer successfully found a solution.In the examples so far, most of our operations have acted on individual numbers—we wrote x^2 to square a single value, or used a for loop to process each element of an array one at a time. But a huge amount of scientific computing involves applying the same operation to every element of an array. Julia provides a powerful mechanism for this called broadcasting, and understanding it will make your code both shorter and faster.
Broadcasting in Julia uses the dot syntax: placing a . before (or after) an operator or function name tells Julia to apply the operation element-wise across arrays. For arithmetic operators, the dot goes before the operator:
x = [1.0, 2.0, 3.0, 4.0, 5.0]
y = x .^ 2 # Square each element5-element Vector{Float64}:
1.0
4.0
9.0
16.0
25.0
z = x .+ 10.0 # Add 10 to each element5-element Vector{Float64}:
11.0
12.0
13.0
14.0
15.0
w = x .* y # Multiply corresponding elements5-element Vector{Float64}:
1.0
8.0
27.0
64.0
125.0
Without the dot, Julia would try to interpret x + 10.0 as adding a scalar to a vector (which does work for + and - as a convenience), but x * y would attempt matrix multiplication, which is not defined for two vectors of the same shape. The dot syntax makes element-wise intent explicit and unambiguous.
For functions, the dot goes after the function name. This tells Julia to apply the function to each element of the input array individually:
log.(x) # Natural log of each element5-element Vector{Float64}:
0.0
0.6931471805599453
1.0986122886681098
1.3862943611198906
1.6094379124341003
sqrt.(x) # Square root of each element5-element Vector{Float64}:
1.0
1.4142135623730951
1.7320508075688772
2.0
2.23606797749979
You can even broadcast user-defined functions. Suppose you have a function g(x) = x^2 + 3x - 1. Writing g.(x) applies it to every element of x:
g(x) = x^2 + 3x - 1
g.(x)5-element Vector{Float64}:
3.0
9.0
17.0
27.0
39.0
This works for any function—built-in or user-defined. Julia automatically generates an efficient loop behind the scenes.
Broadcasting is not just a convenience—it can significantly improve performance. Consider two ways to compute \(\log(c)\) for a vector of consumption levels. The first uses an explicit loop:
result = zeros(length(c))
for i in 1:length(c)
result[i] = log(c[i])
endThe second uses broadcasting:
result = log.(c)Both produce the same answer, but the broadcast version is typically faster. The reason is memory allocation. When you chain operations like x .+ y .* z, Julia fuses the broadcasts into a single loop: it computes the result element by element without creating intermediate temporary arrays. An explicit chain of operations like temp = y .* z; result = x .+ temp would allocate memory for temp, fill it in, and then allocate memory for result—two passes through memory instead of one. This fusion is automatic whenever you use dots, and it becomes increasingly important as arrays grow large.
A common task in computational economics is evaluating a utility function over a grid of consumption values. For example, when solving a consumption-savings problem, you might want to compute \(u(c) = \log(c)\) for many possible consumption levels:
consumption = range(0.1, 10.0, length=100) # 100 points from 0.1 to 10.0
utility_grid = log.(consumption) # log utility for each level100-element Vector{Float64}:
-2.3025850929940455
-1.6094379124341003
-1.2039728043259361
-0.916290731874155
-0.6931471805599453
-0.5108256237659907
-0.35667494393873245
-0.2231435513142097
-0.10536051565782628
0.0
⋮
2.2192034840549946
2.2300144001592104
2.2407096892759584
2.2512917986064953
2.2617630984737906
2.272125885509337
2.2823823856765264
2.2925347571405443
2.302585092994046
using Plotsplot(collect(consumption), utility_grid, linewidth=2, label="u(c) = log(c)",
xlabel="Consumption", ylabel="Utility", legend=:bottomright)The concavity of the log function is clearly visible: the marginal utility of consumption falls as \(c\) increases. This is the fundamental property that drives risk aversion and consumption smoothing in economic models. Broadcasting made it trivial to compute utility over the entire grid in a single expression.
Broadcasting also handles operations between arrays of different shapes. Julia’s rule is simple: dimensions are compatible if they are equal or if one of them is 1. A scalar is treated as an array with all dimensions equal to 1, so it broadcasts with anything.
For example, suppose you have a vector of wages \(w\) and want to compute after-tax income for several tax rates \(\tau\):
wages = [30_000.0, 50_000.0, 75_000.0, 100_000.0]
tax_rates = [0.10, 0.20, 0.30]
# Reshape tax_rates to a row vector so broadcasting gives a matrix
after_tax = wages .* (1 .- tax_rates')4×3 Matrix{Float64}:
27000.0 24000.0 21000.0
45000.0 40000.0 35000.0
67500.0 60000.0 52500.0
90000.0 80000.0 70000.0
The result is a \(4 \times 3\) matrix: each row corresponds to a wage level and each column to a tax rate. Julia automatically expanded the column vector of wages and the row vector of tax rates to produce every combination. This kind of “outer product” broadcasting is extremely useful for computing policy functions, transition matrices, and comparative statics grids.
Use broadcasting when you are applying the same operation to every element of an array, especially for simple mathematical transformations. It is concise, readable, and often faster due to loop fusion. Use explicit loops when the computation at step \(t\) depends on the result from step \(t-1\) (as in our AR(1) simulation), when the logic inside the loop involves complex branching, or when you need fine-grained control over the iteration. Both are perfectly idiomatic in Julia—the language is designed to make loops fast, so you never need to “vectorize for speed” the way you do in Python or R.
Julia’s type system is one of the features that sets it apart from languages like Python and R. While those languages are “dynamically typed” in a way that hides types from the user, Julia makes types a central, visible part of the language. Understanding the type system is important for two reasons: it is the foundation of Julia’s performance (the compiler generates specialized machine code for each type combination), and it enables multiple dispatch, a powerful programming paradigm that makes code both flexible and fast.
Julia distinguishes between abstract types and concrete types. Concrete types are the types that actual values have—Float64, Int64, Bool, String. Every value in Julia has exactly one concrete type, and you can check it with typeof:
typeof(3.14)Float64
typeof(42)Int64
typeof(true)Bool
Abstract types, on the other hand, cannot be instantiated directly. They exist to organize concrete types into a hierarchy. For example, Number is an abstract type that encompasses all numeric types. Integer is also abstract—it encompasses Int64, Int32, UInt8, and other integer types. You can think of the type hierarchy as a tree: abstract types are the interior nodes, and concrete types are the leaves.
You can explore the hierarchy with two built-in functions. supertype tells you a type’s parent in the hierarchy, and subtypes tells you its children:
supertype(Float64)AbstractFloat
supertype(AbstractFloat)Real
subtypes(Number)4-element Vector{Any}:
Base.MultiplicativeInverses.MultiplicativeInverse
Complex
Plots.Measurement
Real
subtypes(Integer)3-element Vector{Any}:
Bool
Signed
Unsigned
The full path from Float64 to the top of the hierarchy is Float64 → AbstractFloat → Real → Number → Any. Every type in Julia ultimately descends from Any, the root of the type tree. This hierarchy matters because functions can be defined to accept any type at a given level of the tree. A function that accepts Number will work with Float64, Int64, BigFloat, and any other numeric type.
You can annotate function arguments with types using the :: operator. This tells Julia (and the reader) what kinds of inputs the function expects:
function double(x::Number)
return 2 * x
end
double(3.14)6.28
double(42)84
The annotation x::Number means this function accepts any numeric type—integers, floats, rationals, and so on. If you try to pass a non-numeric argument, Julia will raise an error:
double("hello")MethodError: no method matching double(::String) The function `double` exists, but no method is defined for this combination of argument types. Closest candidates are: double(::Number) @ Main.Notebook ~/University of Oregon Dropbox/David Evans/University of Oregon/Courses/Spring 2026/EC 410/Lectures and Website/Julia/Basic Julia/JuliaBasicsNotes.qmd:1383 Stacktrace: [1] top-level scope @ ~/University of Oregon Dropbox/David Evans/University of Oregon/Courses/Spring 2026/EC 410/Lectures and Website/Julia/Basic Julia/JuliaBasicsNotes.qmd:1397
Type annotations on function arguments are optional in Julia. If you omit them, the function accepts any type (equivalent to annotating with ::Any). All of the functions we have written so far omitted type annotations, and they worked fine. So why bother?
The answer is multiple dispatch: the ability to define different methods of the same function for different argument types.
Multiple dispatch is Julia’s core programming paradigm. The idea is simple: you can define several versions of a function with the same name but different type signatures, and Julia automatically selects the right version based on the types of the arguments at call time.
Here is an example with a log_utility function. The log utility of a single consumption level is a scalar computation, but the utility of a consumption vector requires element-wise application:
"""
log_utility(c::Float64)
Compute log utility for a single consumption level.
"""
function log_utility(c::Float64)
return log(c)
end
"""
log_utility(c::Vector{Float64})
Compute log utility for a vector of consumption levels.
"""
function log_utility(c::Vector{Float64})
return log.(c)
endMain.Notebook.log_utility
Now Julia dispatches to the correct method automatically:
log_utility(2.5) # calls the scalar method0.9162907318741551
log_utility([1.0, 2.0, 3.0, 4.0]) # calls the vector method4-element Vector{Float64}:
0.0
0.6931471805599453
1.0986122886681098
1.3862943611198906
Each call goes to the version of log_utility whose type signature matches the argument. This is multiple dispatch in action: the same function name, but the behavior depends on the types of the inputs. Julia selects the most specific matching method at runtime.
You can see all the methods defined for a function using the methods function:
methods(log_utility)When you call log_utility(2.5), Julia knows the argument is a Float64. The JIT (just-in-time) compiler generates specialized machine code for that specific type: it uses hardware floating-point instructions, avoids type checks, and produces code that runs as fast as hand-written C. When you call log_utility([1.0, 2.0, 3.0, 4.0]), Julia generates different specialized code optimized for Vector{Float64}. This is fundamentally different from Python, where the interpreter must check the type of every value at every operation. Julia’s approach—“write generic code, get specialized performance”—is the secret to its speed. You write high-level, readable code, and the compiler does the work of making it fast.
Multiple dispatch is pervasive in Julia. Operators like + and * are ordinary functions with hundreds of methods—one for adding two integers, one for adding two floats, one for adding a float and an integer, one for matrix multiplication, and so on. When you write 3.14 + 42, Julia dispatches to a method that promotes the integer to a float and then performs floating-point addition. This is all happening behind the scenes, but it is the reason Julia can be both high-level and high-performance.
Let us close these notes by combining everything we have learned—types, arrays, loops, functions, broadcasting, and plotting—into a complete worked example. We will simulate an AR(1) process, compute summary statistics, and compare them to the theoretical predictions. This example also serves as a preview of the Markov chain and dynamic programming tools we will use throughout the rest of the course.
An AR(1) (first-order autoregressive) process is defined by:
\[ y_t = \rho \, y_{t-1} + \varepsilon_t, \qquad \varepsilon_t \sim \mathcal{N}(0, \sigma^2) \]
where \(\rho \in (-1, 1)\) is the persistence parameter and \(\sigma > 0\) is the standard deviation of the innovation. The process is stationary when \(|\rho| < 1\), meaning it has a well-defined long-run distribution that does not depend on the initial condition. The unconditional (long-run) mean is \(\mathbb{E}[y_t] = 0\), and the unconditional variance is:
\[ \text{Var}(y_t) = \frac{\sigma^2}{1 - \rho^2} \]
This formula reveals an important economic intuition: as \(\rho\) approaches 1, the variance explodes. Highly persistent processes wander widely because shocks accumulate rather than dissipating. When \(\rho = 0\), the process is simply white noise with variance \(\sigma^2\)—each period is independent of the last.
AR(1) processes are the workhorse model in macroeconomics and finance. Log productivity in the RBC model follows an AR(1). Log income in life-cycle consumption models is typically AR(1). Interest rates, exchange rates, and inflation are all commonly modeled as AR(1) processes. Understanding the properties of this simple process is essential preparation for the dynamic models we will study later.
We already wrote a basic simulateAR1 function in the Functions section. Here we extend it to include the innovation standard deviation \(\sigma\) as a parameter and use a named tuple to return both the simulated path and the parameter values:
using Random
"""
simulate_ar1(ρ, σ, T; y0=0.0)
Simulate `T` periods of the AR(1) process yₜ = ρ yₜ₋₁ + σ εₜ
where εₜ ~ N(0, 1).
# Arguments
- `ρ`: persistence parameter (must satisfy |ρ| < 1 for stationarity)
- `σ`: standard deviation of innovations
- `T`: number of periods to simulate
- `y0`: initial value (default 0.0)
# Returns
- Named tuple `(path, ρ, σ)` containing the simulated path and parameters
"""
function simulate_ar1(ρ::Float64, σ::Float64, T::Int; y0::Float64=0.0)
y = zeros(T)
y[1] = y0
for t in 2:T
y[t] = ρ * y[t-1] + σ * randn()
end
return (path = y, ρ = ρ, σ = σ)
endMain.Notebook.simulate_ar1
A few things to note about this function. First, it uses type annotations (ρ::Float64, T::Int) to make the expected inputs explicit. Second, it pre-allocates the output array with zeros(T) for efficiency. Third, it returns a named tuple so the caller can access both the simulated data and the parameters that generated it. Fourth, the loop is unavoidable here: each value \(y_t\) depends on the previous value \(y_{t-1}\), so we cannot use broadcasting.
Let us simulate a single path with \(\rho = 0.9\), \(\sigma = 0.5\), and \(T = 200\) periods:
Random.seed!(42) # Set seed for reproducibility
result = simulate_ar1(0.9, 0.5, 200)
plot(result.path, linewidth=1.5, label="ρ = $(result.ρ), σ = $(result.σ)",
xlabel="Time", ylabel="yₜ", title="Simulated AR(1) Process",
legend=:topright)
hline!([0.0], linestyle=:dash, color=:gray, label="E[yₜ] = 0")The series fluctuates around its unconditional mean of zero, but because \(\rho = 0.9\) is high, the deviations are persistent—the series drifts above or below zero for extended stretches before reverting. This is exactly the kind of behavior we see in macroeconomic time series like detrended GDP or the unemployment rate.
Now let us compare paths with different levels of persistence. Setting the same random seed for each simulation ensures the underlying shocks \(\varepsilon_t\) are identical, so any differences in the paths are due entirely to the persistence parameter:
plot(title="AR(1) Paths: Varying Persistence", xlabel="Time", ylabel="yₜ")
for ρ in [0.2, 0.5, 0.9]
Random.seed!(123)
sim = simulate_ar1(ρ, 0.5, 200)
plot!(sim.path, linewidth=1.5, label="ρ = $ρ")
end
plot!()The visual difference is striking. The low-persistence path (\(\rho = 0.2\)) bounces rapidly around zero—shocks die out within a few periods. The high-persistence path (\(\rho = 0.9\)) exhibits long, slow swings that resemble business cycles. The persistence parameter \(\rho\) is doing all the work: the same sequence of shocks produces dramatically different dynamics depending on how quickly those shocks dissipate.
The theory predicts that the unconditional variance of a stationary AR(1) process is \(\sigma^2 / (1 - \rho^2)\). With a long enough simulation, the sample variance should be close to this theoretical value. Let us check with a simulation of \(T = 100{,}000\) periods:
using Statistics
Random.seed!(999)
ρ, σ = 0.9, 0.5
long_sim = simulate_ar1(ρ, σ, 100_000)
theoretical_var = σ^2 / (1 - ρ^2)
sample_var = var(long_sim.path)
println("Theoretical variance: $(round(theoretical_var, digits=4))")
println("Sample variance: $(round(sample_var, digits=4))")
println("Theoretical mean: 0.0")
println("Sample mean: $(round(mean(long_sim.path), digits=4))")Theoretical variance: 1.3158
Sample variance: 1.2946
Theoretical mean: 0.0
Sample mean: -0.0088
With 100,000 observations, the sample moments are very close to their theoretical counterparts. This convergence is a consequence of the law of large numbers: as the sample size grows, sample averages converge to population means. The small remaining discrepancy is simply sampling variation—it would shrink further with even more observations.
The AR(1) process is a continuous-state Markov process: the distribution of \(y_{t+1}\) depends only on \(y_t\), not on any earlier history. In the next set of lectures, we will study discrete-state Markov chains, where the state variable takes on a finite number of values and transitions are governed by a matrix \(P\). A key technique in computational economics is discretizing continuous AR(1) processes into finite Markov chains using methods like Tauchen (1986) or Rouwenhorst (1995). This lets us apply the powerful matrix algebra tools of Markov chains to problems that are naturally continuous, like the RBC model’s productivity process.
In these notes we covered the essential building blocks of Julia programming:
Primitive data types form the foundation. Booleans (true/false) represent logical values. Integers (Int64) represent whole numbers. Floating-point numbers (Float64) represent real-valued quantities with decimal precision. Strings (String) represent text, with powerful interpolation and manipulation capabilities.
Container types let us organize data into collections:
Vector) are ordered and mutable—the go-to choice for most numerical work.Dict) provide key-value lookup for labeled data.Control flow gives our programs the ability to make decisions and repeat operations:
<, >, ==, !=, <=, >=) produce boolean values.&&, ||, !) combine boolean expressions.Functions let us package reusable code into named units:
x -> x^2) for quick, throwaway operations.f(x) = x^2) for simple named functions.function ... end) for complex logic with return.Root solving with NonlinearSolve.jl lets us find values \(x^*\) where \(f(x^*) = 0\):
f(u, p) and create a NonlinearProblem(f, u0).sol = solve(prob) and read the answer from sol.u.Optimization with Optim.jl lets us maximize and minimize functions:
Optim.jl minimizes by default—negate your function to maximize.optimize(f, lower, upper).optimize(f, x0).Optim.minimizer(result) and Optim.minimum(result) to extract solutions.Broadcasting lets us apply operations element-wise to entire arrays using the dot syntax (.+, .*, f.(x)), with automatic loop fusion for performance.
The type system organizes Julia’s types into a hierarchy of abstract and concrete types. Type annotations and multiple dispatch let you define different methods of the same function for different argument types, and the JIT compiler generates specialized machine code for each type combination.
The AR(1) worked example tied everything together: we wrote a typed, documented simulation function, plotted paths with varying persistence, and confirmed that sample moments converge to their theoretical values—previewing the Markov chain tools we will use throughout the course.
L[1], and L[end] gives the last element.! (like push!) modify their arguments in place.zeros(T)) rather than growing them with push! in performance-sensitive code.log.(x), x .+ y) applies operations element-wise and fuses loops for performance.NonlinearSolve functions use the f(u, p) signature; always check sol.retcode.Optim, remember it minimizes—negate to maximize.With these fundamentals in hand, you are ready to move on to more advanced topics like working with matrices, Markov chains, and tackling dynamic economic models in Julia.