As an interesting experiment I wanted to learn how to teach a tiny local llm a new domain by doing Continued Pretraining (CPT) on domain specific data. This article is a write-up of my experiences from using Unsloth to train qwen 3 4B to act as a travel advisor for a fictional city.

Continued Pretraining (CPT)

As the name suggests, CPT is a continuation of the model’s initial training, typically on a specific domain to allow the model to specialize on top of what it already knows.

Normally, full CPT on a model of any size would be impractical on consumer hardware due to high VRAM requirements. However, there is a clever workaround called LoRA (Low-Rank Adaption). The basic idea behind LoRA is that the original model weights are kept frozen while small trainable LoRA adapters are attached to some of the layers of the model. During training, we only update the parameters in the LoRA adapter. In practice this means we only have to touch a fraction of the full set of parameters.

Example: I am working with qwen 3 4B, which has 4 billion trainable parameters. However with my current LoRA configuration, I am only targeting 66 million parameters - 1.6% of the total number of parameters!

To do the actual LoRA based CPT training I am using a framework called Unsloth. I have included the full source here in case you are interested in checking it out.

Domain

The domain can be anything, but I decided to try to teach the model to act as a travel advisor for a fictional city called Awesomeville in the country of Greatness. Everything about the city is made up of course, but the town has its own subway with multiple historical sites located near the subway stations.

The goal of this exercise is to teach the model to reason about the subway map and belonging historical sites.

As an example I want the model to reliably answer questions like: What is the subway route from Museum of Greatness History to Founder's Square?

As an illustration, I have added a snapshot of the subway map below:

Awesomeville subway map showing the Green, Blue, and Gold lines
Awesomeville’s three subway lines connect at Central Station. Select the map to view it full-size.

As you can see there are three subway lines in the city (Green, Blue and Gold) with various stations located near historical sites in town.

The map also shows that the lines connect through central station, so a travel advisor needs to be able to reason and recommend multi-line routes. Some of the key scenarios are listed below:

  • Same line travel (e.g. Blue line stations only)
  • Single transfer through Central station (e.g. Starting on Blue line and transferring to Green line)
  • Connect trips with multiple transfers (e.g. Blue -> Green -> Gold)

Corpus

Create Data

As expected, the most time-consuming part of this project by far was defining the corpus, the collection of data used to train the model. Since the entire universe of Awesomeville is fictional, all data had to be synthesized. Using an agent for this is of course the most practical solution these days, but it’s not as easy as just asking an agent for a perfect training corpus.

I discovered a few pitfalls when synthesizing data using agents.

One thing to keep in mind is that agents often create text generators, which may lead to templated language with lots of repetition from shared intros and fragments. Too much common phrasing around a few variables like station names may make it harder for the model to learn the subtle differences.

Another issue is that agents generate data so fast that it’s easy to lose track, and before you know it, your corpus has grown to 10k entries of questionable quality.

One of the key goals of this exercise was to come up with a set of training data that would enable the model to reason. I wanted to avoid a situation where the data consists of a large amount of specific route examples since this tends to lead to route memorization and likely poor generalization.

Structuring the Data

Experimental Phase

The first draft of my training data was sort of a disaster. Initially, I felt like I was moving in the right direction but before I knew it, I had generated a 10k bloated dataset that relied far too much on memorization of specific route scenarios. Performance would often be poor when unseen examples were introduced. The large dataset did slow down training, but I learned that when working with large datasets, you may not need to train from scratch after every change. Instead, you can split your training into multiple phases. I would start with pre-training using the full dataset followed by post training on the pre-trained model. Post training would be the same Unsloth process, but using a smaller, much more targeted dataset.

Re-Design

Luckily, I realized quickly that I had to start over and build up the training data incrementally and put more thought into the design. I would first start with training data for simple single line travel, then move to one-line-transfers and two-line-transfers. Finally, I added bindings from subway stations to historic sites.

As I was building out the model’s training set, I also built a comprehensive test suite for doing evals to gauge how well training was progressing. Out of a full test suite of 105 tests, 85 of the scenarios were scenarios that were not covered directly during training. See table below with summary:

Test scenario Tests Share What it tests Truly held out Partially represented Directly represented
Green same-line journey 9 8.6% Correct Green Line selection; no transfer 5 0 4
Blue same-line journey 16 15.2% Correct Blue Line selection; no transfer 12 0 4
Gold same-line journey 9 8.6% Correct Gold Line selection; no transfer 5 0 4
One-transfer journey 33 31.4% Origin line → central_station → destination line 31 0 2
Two-journey / errand scenario 33 31.4% Solve two routes independently; reset state between journeys 27 6 0
Historic-site → historic-site 5 4.8% Resolve both sites to stations, then route between them 5 0 0
Total 105 100% 85 6 14

One of the things that surprised me is how much I was able to shrink the original corpus and still have decent performance. However, I did notice that the small qwen model would often struggle to reliably transfer between lines in single-transfer and two-transfer scenarios.

Based on this observation I decided to try to map the original subway map onto an internal representation using synthetic names as seen in the graphic below. The main benefit of this is that the synthetic names make it easier on the small qwen model since line membership is encoded in the name. You also get some help with ordinals from the numeric suffix. I did a similar thing to the historic site mappings.

Awesomeville Subway Map

Original human-readable station names mapped to the locked synthetic naming convention. Line names remain unchanged; Central Station is the shared interchange represented by central_station.

Blue Line Green Line Gold Line Shared interchange

Blue Line

North Terminal
blue_station_one
University Commons
blue_station_two
Founder's Square
blue_station_three
River Market
blue_station_four
Central Station
central_station
Shared interchange
South Gardens
blue_station_six

Green Line

Emerald Hills
green_station_one
Museum District Station
green_station_two
Central Station
central_station
Shared interchange
Innovation Park
green_station_four
Lake Harmony
green_station_five

Gold Line

Westgate
gold_station_one
Old Mill Station
gold_station_two
Central Station
central_station
Shared interchange
East Harbor
gold_station_four
Sunrise Point
gold_station_five

Based on eval performance, moving to synthetic names in the internal map representation resulted in a performance gain of 24% in accuracy. Most of the gains came in scenarios covering line transfers.

Complete Corpus

After the redesign I ended up with a total of 700 entries across 30 categories in my full training dataset. I have included a link to the dataset here.

I have also included a grouping of the data categories below:

Category Count Share Section Actual example from corpus
historic_direct_binding 208 29.7% city_training_historic_sites
historic_binding_blue_historic_site_three_001
blue_historic_site_three
blue_station_three
station_line_membership 84 12.0% city_training_membership
membership_blue_station_one_001
STATION: blue_station_one
LINE: Blue Line
routing_rule 30 4.3% city_training_transfers
routing_rule_001
ROUTING_RULE: If origin and destination are on the same line, remain on that line and set TRANSFER_COUNT: 0.
multi_journey_rule 28 4.0% city_training_transfers
multi_journey_rule_001
MULTI_JOURNEY_RULE: Solve each journey independently.
cross_line_output_rule 24 3.4% city_training_output_contract
cross_line_output_rule_v11_001
CROSS_LINE_OUTPUT_RULE
CONDITION: ORIGIN_LINE != DESTINATION_LINE
OUTPUT:
- origin station
- origin line
- central_station as the transfer
- destination line
- destination station
DO NOT OUTPUT:
- non-transfer intermediate stations
positive_output_demo 24 3.4% city_training_output_examples
positive_same_line_output_v12_001
QUESTION:
What is the subway route from blue_station_two to blue_station_four?

ANSWER:
Take Blue Line from blue_station_two to blue_station_four. No transfer is required.
routing_decision_rule 24 3.4% city_training_route_rules
routing_decision_rule_v11_001
ROUTING_DECISION_RULE
1. Resolve origin to a subway station if needed.
2. Resolve destination to a subway station if needed.
3. Determine ORIGIN_LINE.
4. Determine DESTINATION_LINE.
5. If ORIGIN_LINE == DESTINATION_LINE: TRANSFER_COUNT = 0.
6. If ORIGIN_LINE != DESTINATION_LINE: TRANSFER_COUNT = 1 at central_station.
same_line_output_rule 24 3.4% city_training_output_contract
same_line_output_rule_v11_001
SAME_LINE_OUTPUT_RULE
CONDITION: ORIGIN_LINE == DESTINATION_LINE
OUTPUT:
- origin station
- line name
- destination station
- no transfer
DO NOT OUTPUT:
- central_station unless it is origin or destination
- intermediate stations
nontransfer_output_invariant 20 2.9% city_training_output_contract
nontransfer_output_invariant_v10_001
NON_TRANSFER_OUTPUT_INVARIANT
CONDITION: origin and destination use the same subway line.
TRANSFER_COUNT: 0
FINAL_OUTPUT: origin + line + destination + no transfer
OMIT: all intermediate stations
OMIT: central_station unless central_station is itself the origin or destination
routing_summary_rule 20 2.9% city_training_output_contract
routing_summary_rule_v10_001
ROUTE_OUTPUT_SELECTION_RULE
If TRANSFER_COUNT = 0:
- output origin station
- output subway line
- output destination station
- state no transfer
- do not output intermediate stations
- do not mention central_station unless it is origin or destination
If TRANSFER_COUNT = 1:
- output origin station and origin line
- output central_station as the transfer station
- output destination line and destination station
- do not output other intermediate stations
central_balanced_decision 18 2.6% city_training_transfers
central_balanced_continue_v4_001
CENTRAL_ROUTE_DECISION
CURRENT_LINE: Blue Line
DESTINATION_LINE: Blue Line
AT: central_station
ACTION: CONTINUE
TRANSFER_COUNT: 0
central_transfer_decision 18 2.6% city_training_transfers
central_decision_001
CURRENT_LINE: Blue Line
REQUIRED_LINE: Blue Line
AT: central_station
ACTION: STAY
TRANSFER_COUNT: 0
historic_routing_rule 18 2.6% city_training_historic_sites
historic_routing_rule_v11_001
HISTORIC_ROUTING_RULE
1. Resolve historic_site -> access_station.
2. Replace the historic-site token with the station.
3. Use that station's line membership for routing.
4. Final route output uses station identifiers only.
multi_journey_decision_rule 18 2.6% city_training_transfers
multi_journey_decision_rule_v11_001
MULTI_JOURNEY_DECISION_RULE
For each journey independently:
1. resolve origin and destination
2. determine origin line and destination line
3. decide transfer count
4. output only routing-relevant endpoints, lines, and transfer
5. reset before the next journey
transfer_output_invariant 16 2.3% city_training_output_contract
transfer_output_invariant_v10_001
TRANSFER_OUTPUT_INVARIANT
CONDITION: origin and destination require different subway lines.
TRANSFER_COUNT: 1
FINAL_OUTPUT: origin + origin line + central_station transfer + destination line + destination
OMIT: all non-transfer intermediate stations
historic_resolution_invariant 12 1.7% city_training_historic_sites
historic_resolution_invariant_v9_001
ENTITY_RESOLUTION_INVARIANT
historic_site -> access_station
After resolution, the historic-site identifier is no longer a routing node.
FINAL_ROUTE may contain station identifiers only.
multi_journey_demo 12 1.7% city_training_transfers
multi_journey_demo_001
JOURNEY_1: Take Green Line from green_station_five to central_station; transfer at central_station to Blue Line; continue to blue_station_two.
END_JOURNEY_1
RESET_ROUTE_STATE
JOURNEY_2: Take Blue Line from blue_station_two to central_station; transfer at central_station to Gold Line; continue to gold_station_five.
transfer_invariant 12 1.7% city_training_transfers
transfer_invariant_v3_001
TRANSFER_INVARIANT: A transfer occurs only when the subway line changes.
multi_journey_line_roles 9 1.3% city_training_transfers
multi_journey_line_roles_v3_001
TWO_JOURNEY_LINE_ROLES
JOURNEY_1_ORIGIN_LINE: Blue Line
JOURNEY_1_DESTINATION_LINE: Green Line
JOURNEY_1_ACTION: ride Blue Line to central_station; transfer to Green Line; finish journey 1.
RESET_ROUTE_STATE
JOURNEY_2_ORIGIN_LINE: Green Line
JOURNEY_2_DESTINATION_LINE: Gold Line
JOURNEY_2_ACTION: ride Green Line to central_station; transfer to Gold Line; finish journey 2.
same_line_central_invariant 9 1.3% city_training_transfers
same_line_central_invariant_v4_001
SAME_LINE_CENTRAL_CASE
ORIGIN: blue_station_two
DESTINATION: blue_station_six
LINE: Blue Line
RULE: If the route passes through central_station, remain on Blue Line.
ACTION_AT_CENTRAL: CONTINUE
TRANSFER_COUNT: 0
same_line_route_demo 9 1.3% city_training_route_demos
same_line_demo_001
ORIGIN: blue_station_one
DESTINATION: blue_station_three
LINE: Blue Line
STATIONS: blue_station_one -> blue_station_two -> blue_station_three
TRANSFER_COUNT: 0
single_transfer_route_demo 9 1.3% city_training_route_demos
transfer_demo_001
Take Blue Line from blue_station_one to central_station; transfer at central_station to Green Line; continue to green_station_five.
destination_invariant 8 1.1% city_training_route_rules
destination_invariant_v9_001
DESTINATION_INVARIANT: The requested destination is fixed and must not change during route construction.
historic_graph_rule 8 1.1% city_training_historic_sites
historic_graph_rule_v8_001
HISTORIC_GRAPH_RULE
1. Resolve the historic-site identifier to its access station.
2. Replace the historic-site identifier with that station.
3. Route between stations using canonical graph rules.
4. Use station identifiers, not historic-site identifiers, in the route output.
line_output_invariant 8 1.1% city_training_output_contract
line_output_invariant_v9_001
LINE_OUTPUT_INVARIANT: Every final route must explicitly name each subway line used.
multi_journey_graph_rule 8 1.1% city_training_transfers
multi_journey_graph_rule_v8_001
MULTI_JOURNEY_GRAPH_RULE
For each journey independently:
1. resolve origin/destination,
2. read required canonical graph(s),
3. construct the route,
4. finish the journey,
5. discard active route state before the next journey.
multi_journey_output_rule 8 1.1% city_training_output_contract
multi_journey_output_rule_v10_001
MULTI_JOURNEY_OUTPUT_RULE: Summarize each journey independently. Output only its origin, required line or lines, any actual transfer at central_station, and its destination. Do not carry intermediate stations or transfer state into the next journey.
historic_route_composition_demo 6 0.9% city_training_historic_sites
historic_route_demo_001
Resolve blue_historic_site_three -> blue_station_three. Resolve gold_historic_site_two -> gold_station_two. Then route only between station identifiers. Take Blue Line from blue_station_three to central_station; transfer at central_station to Gold Line; continue to gold_station_two.
route_output_invariant 6 0.9% city_training_output_contract
route_output_invariant_v5_001
ROUTE_OUTPUT_INVARIANT
Every route answer must explicitly name the subway line used.
destination_central_invariant 2 0.3% city_training_transfers
destination_central_invariant_v5_001
DESTINATION_CENTRAL_INVARIANT
If central_station is the destination, stop when central_station is reached. Do not transfer after reaching the destination.

The most stubborn scenarios to stabilize were related to bindings between historic sites and subway stations. I used the same trick of mapping human readable names to internal names, but ~30% of the training data had to be dedicated to enforcing these mappings.

Another stubborn case was determining transfer vs. no transfer for a subway journey. The main challenge is that all transfers occur through Central Station, but the model still needs to distinguish same line journeys from cross line journeys when passing through Central Station.

One of the key points to enforce is that just passing through Central Station should not automatically trigger a transfer. It must occur in combination with the origin and destination being on different lines.

Another side effect of enforcing this point was that the model would often incorrectly tack on Central Station to a single line journey. Almost as if it was trying to prove the point that passing through Central Station wouldn’t trigger a transfer for same line travel.

To counteract this, I introduced a few targeted rules in the same_line_central_invariant category. See one example below:

same line ↓ transfer_count = 0 ↓ do not introduce a transfer station ↓ answer using origin + line + destination

Overall Performance

I had to go through multiple iterations to arrive at a high performing dataset. However, at this point I would say model performance is very good. I think it’s fair to say that the model can generalize well and reason across historic sites, lines and stations.

Out of 105 eval cases, only two tests fail now. The two failing tests both fail because of incorrectly including Central Station on a journey on the same line. The targeted enforcement described above helped with several of these cases, but the underlying issue isn’t 100% solved.

Another area of improvement is ordinals and enumerating the full sequence of stations along a complete journey. The model is very good at mapping full journeys from origin to destination but is not trained to correctly list the full list of stations along the way. The model does get some help with ordinals from the numeric suffix in the station name, but more training scenarios are needed to enforce this point. It’s not a critical skill for route recommendations though.

I have included the full repo here in case you are interested having a look.