docs(edu): write markov §4 weather model exercise [257a2a]

Add full content for Exercise 1 — Weather Model:
- Setup instructions (cargo new, cargo add rand)
- Starter code skeleton with todo!() bodies
- Step-by-step hints: index conversion, weighted random draw,
  simulate loop, running with the 2-state matrix, comparing
  empirical frequencies to the stationary distribution (2/3, 1/3)
- Collapsed reference solution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 79c21df6a0
commit 401ad5f750

@ -127,7 +127,23 @@ P^2 = [[0.8*0.8 + 0.2*0.4, 0.8*0.2 + 0.2*0.6],
**Goal:** Build a two-state Markov chain in Rust that models daily weather as either `Sunny` or `Rainy`, driven by a transition matrix, and simulate 30 days of weather.
#### Setup
Create a new Cargo binary project and add the `rand` crate:
```sh
cargo new weather-chain
cd weather-chain
cargo add rand
```
#### Starter Code
Replace the contents of `src/main.rs` with the following skeleton. Do not change the struct layout or function signatures — your task is to fill in the `todo!()` bodies and write `main`.
```rust
use rand::Rng;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Weather { Sunny, Rainy }
@ -140,9 +156,153 @@ impl WeatherChain {
fn step(&self, current: Weather, rng: &mut impl Rng) -> Weather { todo!() }
fn simulate(&self, start: Weather, steps: usize, rng: &mut impl Rng) -> Vec<Weather> { todo!() }
}
fn main() { todo!() }
```
#### Step 1 — Index conversion
`step` needs to index into `self.transition` using the current state, and later convert an index back to a `Weather` value. Add two helper methods to the `Weather` enum:
- `fn index(self) -> usize` — returns `0` for `Sunny`, `1` for `Rainy`
- `fn from_index(i: usize) -> Self` — returns `Sunny` for `0`, `Rainy` for anything else
A simple `match` expression handles both. These are the only tools you need to bridge the enum and the matrix.
#### Step 2 — Implement `WeatherChain::step`
`step` must sample the next state from the probability row for the current state. The technique is a **cumulative-probability walk**:
1. Retrieve the transition row: `let row = self.transition[current.index()];`
2. Draw a uniform float in [0, 1): `let r: f64 = rng.gen();`
3. Walk the row, accumulating probability. When the running total first exceeds `r`, return that state.
For the two-state case this reduces to a single comparison — if `r < row[0]` return `Sunny`, otherwise return `Rainy` — but implementing the loop works for any number of states and is worth practising.
Add a fallback `Weather::from_index(row.len() - 1)` after the loop to satisfy the compiler; floating-point rounding can in rare cases leave the loop without returning.
#### Step 3 — Implement `WeatherChain::simulate`
`simulate` runs the chain for `steps` transitions, collecting every state visited including the start:
1. Allocate a `Vec` with capacity `steps + 1`.
2. Push `start` and set `current = start`.
3. Loop `steps` times: call `self.step(current, rng)`, update `current`, and push.
4. Return the `Vec`.
The returned slice will have length `steps + 1` (the initial state plus one state per step).
#### Step 4 — Run the simulation
In `main`, create a `WeatherChain` with the two-state matrix from Section 3:
```
Sunny [0.8, 0.2]
Rainy [0.4, 0.6]
```
Seed a repeatable RNG with `rand::rngs::SmallRng::seed_from_u64(42)` (add `use rand::SeedableRng;`). Simulate 30 steps starting from `Sunny`, print the resulting sequence, and count how many days were sunny vs rainy.
Expected output structure (exact numbers vary by seed):
```
[Sunny, Sunny, Rainy, Sunny, ...]
Sunny days: 21 (67.7%)
Rainy days: 10 (32.3%)
```
#### Step 5 — Compare to the stationary distribution
The **stationary distribution** π satisfies π = πP, meaning once the chain reaches it, the distribution no longer changes. For this matrix, solve the two equations:
```
π₀ = 0.8·π₀ + 0.4·π₁
π₁ = 0.2·π₀ + 0.6·π₁
π₀ + π₁ = 1
```
The first equation simplifies to `0.2·π₀ = 0.4·π₁`, giving `π₀ = 2·π₁`. Substituting into the normalisation constraint: `π₀ = 2/3 ≈ 66.7%`, `π₁ = 1/3 ≈ 33.3%`.
Print the stationary percentages alongside your simulated counts. With only 31 data points the match will be rough; re-run with 1 000 or 10 000 steps to see the empirical frequencies converge.
#### Reference Solution
<details>
<summary>Show full solution</summary>
```rust
use rand::{Rng, SeedableRng, rngs::SmallRng};
#[derive(Debug, Clone, Copy, PartialEq)]
enum Weather { Sunny, Rainy }
impl Weather {
fn index(self) -> usize {
match self {
Weather::Sunny => 0,
Weather::Rainy => 1,
}
}
fn from_index(i: usize) -> Self {
match i {
0 => Weather::Sunny,
_ => Weather::Rainy,
}
}
}
struct WeatherChain {
/// transition[current][next] = probability
transition: [[f64; 2]; 2],
}
impl WeatherChain {
fn step(&self, current: Weather, rng: &mut impl Rng) -> Weather {
let row = self.transition[current.index()];
let r: f64 = rng.gen();
let mut cumulative = 0.0;
for (i, &prob) in row.iter().enumerate() {
cumulative += prob;
if r < cumulative {
return Weather::from_index(i);
}
}
Weather::from_index(row.len() - 1)
}
fn simulate(&self, start: Weather, steps: usize, rng: &mut impl Rng) -> Vec<Weather> {
let mut states = Vec::with_capacity(steps + 1);
states.push(start);
let mut current = start;
for _ in 0..steps {
current = self.step(current, rng);
states.push(current);
}
states
}
}
fn main() {
let mut rng = SmallRng::seed_from_u64(42);
let chain = WeatherChain {
transition: [[0.8, 0.2], [0.4, 0.6]],
};
let states = chain.simulate(Weather::Sunny, 30, &mut rng);
println!("{:?}", states);
let total = states.len() as f64;
let sunny = states.iter().filter(|&&s| s == Weather::Sunny).count();
let rainy = states.len() - sunny;
println!("Sunny days: {} ({:.1}%)", sunny, 100.0 * sunny as f64 / total);
println!("Rainy days: {} ({:.1}%)", rainy, 100.0 * rainy as f64 / total);
println!("Stationary: Sunny ≈ 66.7%, Rainy ≈ 33.3%");
}
```
> 🚧 This section is a stub — see nbd ticket `257a2a`
</details>
---

Loading…
Cancel
Save