Large Language Models in Finance · Chapter 2 / Lecture 2 · Practical

Large Language Models: Architecture and Practice

Tokenise a filing, probe attention, extract earnings figures with structured generation, and deploy a compressed model — step by step.
Juan F. Imbet  ·  EDHEC Business School / Paris Dauphine – PSL University
Session plan

Four guided problems + one capstone

  1. Tokenisation clinic — count tokens, compute cost, hit the context wall
  2. Attention probe — read the positional-encoding heatmap and visualise an attention head
  3. Structured extraction — pull earnings figures reliably with Pydantic schemas
  4. RAG on a 10-K — retrieve relevant passages and ground your answers
  5. Capstone — end-to-end pipeline: tokenise → retrieve → extract → validate
why these problems Each problem isolates exactly one failure mode introduced in the lecture — so you know which technique fixes which symptom in production.
01

Tokenisation clinic

Count tokens, estimate cost, and discover where long-context models earn their price tag.
Guided problem 1 · setup

How much does it cost to read a 10-K?

Before writing any NLP code, count what you're paying for. One 10-K can wipe your monthly budget if you send it naively.

import tiktoken
from pathlib import Path

# Load a 10-K text (SEC EDGAR full-text submission)
text_10k = Path("data/AAPL_10K_2023.txt").read_text()

enc = tiktoken.encoding_for_model("gpt-4o")
tokens = enc.encode(text_10k)
print(f"Tokens: {len(tokens):,}")
print(f"Characters: {len(text_10k):,}")
print(f"Ratio: {len(text_10k)/len(tokens):.2f} chars/token")

# Estimate cost
INPUT_PRICE  = 5.00 / 1_000_000   # USD / token (GPT-5.6 Sol)
OUTPUT_PRICE = 30.00 / 1_000_000
n_questions  = 10
avg_out      = 200
total = len(tokens) * INPUT_PRICE + n_questions * avg_out * OUTPUT_PRICE
print(f"Estimated cost for {n_questions} Q&A: ${total:.4f}")
the surprise Apple's 2023 10-K runs ~80,000 words and approximately 110,000 tokens — about \(\$\)0.55 of input cost per call. A loop over 1,000 filings without caching: ~\(\$\)550. Cache a shared system prompt to cut 70–90%.
Guided problem 1 · exercise

Find the context wall — and three ways to break through it

A frontier-class model offers a 128K-token context. A 10-K plus a detailed prompt can exceed that. Try the three standard mitigations and measure how much content you retain.

def truncate(tokens, limit=127_000):
    """Keep only the first `limit` tokens — simple but loses the end."""
    return tokens[:limit]

def chunk(tokens, size=500, overlap=50):
    """Overlapping windows — nothing lost, but needs multi-call aggregation."""
    return [tokens[i:i+size] for i in range(0, len(tokens), size-overlap)]

def section_extract(text, keywords=("Risk Factors","MD&A","Results")):
    """Pull only high-value sections — smart but requires a section parser."""
    ...

print(f"Truncated tokens:   {len(truncate(tokens)):,}")
chunks = chunk(tokens)
print(f"Chunks (500-50):    {len(chunks)} windows")
Exercise
Compare: (a) mean F1 on 10 FinanceBench questions using truncation vs. (b) chunking with max-pool aggregation. Which strategy preserves more relevant passages for questions whose answers appear near the end of the filing?
expected finding Truncation loses the full risk-factors section (last 30% of most 10-Ks). Chunking + retrieval recovers it; hierarchical encoding recovers structure but requires 2× compute.
02

Attention probe

Read positional encodings from the figure, then visualise an attention head on a real earnings sentence.
Guided problem 2 · the PE heatmap

What does positional encoding look like? Read the heatmap.

Sinusoidal positional-encoding heatmap: 50 positions (rows) by 64 dimensions (columns)
Figure. Sinusoidal positional-encoding matrix for \(d_{\text{model}}=64\), sequence length 50. Row = token position; column = encoding dimension. Low-indexed dimensions oscillate rapidly (short period, fine position); high-indexed dimensions change slowly (long period, coarse position). Source: generated by gen_positional_encoding.py (deterministic).
Reading exercise — three questions
  1. At dimension 0 (leftmost column), estimate the wave period in tokens.
  2. At dimension 62 (rightmost), does one full period fit in 50 positions?
  3. Two sentences are 32 tokens apart — which dimension range encodes this gap most faithfully?
Guided problem 2 · attention head

Extract and plot an attention head on an earnings sentence

Visualise which tokens a model pays attention to — on a real financial sentence — to build intuition for multi-head attention.

from transformers import BertTokenizer, BertModel
import torch, matplotlib.pyplot as plt

tok   = BertTokenizer.from_pretrained("bert-base-uncased")
model = BertModel.from_pretrained("bert-base-uncased",
                                   output_attentions=True)
sentence = ("Revenue increased 12% year-over-year to $4.2 billion, "
            "driven by strong demand in cloud services.")
inp = tok(sentence, return_tensors="pt")
with torch.no_grad():
    out = model(**inp)

attn   = out.attentions[-1][0]   # (heads, seq, seq)
tokens = tok.convert_ids_to_tokens(inp["input_ids"][0])

for head in range(12):
    plt.figure(figsize=(8,6))
    plt.imshow(attn[head].numpy(), cmap="Blues")
    plt.xticks(range(len(tokens)), tokens, rotation=90, fontsize=7)
    plt.yticks(range(len(tokens)), tokens, fontsize=7)
    plt.title(f"Layer 12, Head {head}")
    plt.tight_layout()
    plt.savefig(f"attn_head_{head}.png")
what to look for Does "12%" attend strongly to "\(\$\)4.2"? Does "billion" attend back to "revenue"? Which heads specialise in numeric co-reference vs. subject–verb agreement?
03

Structured extraction

Pull earnings figures reliably with Pydantic + tool use — and diagnose the three failure modes.
Guided problem 3 · schema design

Define the schema — then force the model to respect it

Without constrained output, a model might write "Revenue was approximately USD 4.2B" — unparseable by a database. A Pydantic schema and tool use forces it to emit a machine-readable object every time.

from pydantic import BaseModel
from typing import Optional
import anthropic

class EarningsReport(BaseModel):
    company: str
    period: str                           # e.g. "Q3 2023"
    revenue_bn: Optional[float] = None    # USD billions
    net_income_bn: Optional[float] = None
    eps: Optional[float] = None           # diluted
    guidance_revenue_bn: Optional[float] = None
    sentiment: Optional[str] = None       # "beat" | "miss" | "in-line"

client = anthropic.Anthropic()
response = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=512,
    tools=[{"name": "record_earnings",
            "description": "Record structured earnings data",
            "input_schema": EarningsReport.model_json_schema()}],
    tool_choice={"type": "tool", "name": "record_earnings"},
    messages=[{"role": "user", "content": TRANSCRIPT_SNIPPET}]
)
result = EarningsReport(**response.content[0].input)
print(result.model_dump())
tool_choice enforces the schema Without tool_choice, the model may respond in free text when uncertain. With it, the response must be the named tool call — always parseable.
Guided problem 3 · failure mode triage

Valid JSON does not mean correct data — three failure modes to catch

Run these validation checks after every extraction call — constrained decoding guarantees shape, not values.

def validate_extraction(result, source_text: str) -> list[str]:
    import re
    issues = []
    nums = [float(n) for n in re.findall(r"\d+\.\d+", source_text)]

    # 1. Numerically wrong-but-valid
    if result.revenue_bn and not any(abs(n - result.revenue_bn) < 0.1
                                     for n in nums):
        issues.append("revenue_bn not found in source text")

    # 2. Silent field omission
    for field in ("revenue_bn", "net_income_bn", "eps"):
        if getattr(result, field) is None:
            issues.append(f"missing field: {field}")

    # 3. Unit confusion — revenue should be 0.01–10,000 B range
    if result.revenue_bn and not (0.01 <= result.revenue_bn <= 10_000):
        issues.append(f"suspicious revenue: {result.revenue_bn}B")

    return issues
exercise Run the extractor on five FinanceBench snippets. Which failure mode appears most? Does switching from Haiku to Sonnet reduce the miss rate — and by how much?
04

RAG on a 10-K

Index an SEC filing, retrieve relevant passages, and ground your answers — then measure what naive RAG misses.
Guided problem 4 · indexing

Split → embed → index in five lines

The RAG pipeline is three stages: retrieve relevant chunks, augment the prompt with them, then generate. Start with the retrieval stage.

from sentence_transformers import SentenceTransformer
import faiss, numpy as np

# 1. Chunk — overlapping 500-word windows
paragraphs = text_10k.split("\n\n")
chunks = []
for p in paragraphs:
    words = p.split()
    for i in range(0, len(words), 450):
        chunks.append(" ".join(words[i:i+500]))

# 2. Embed
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
emb   = np.array(model.encode(chunks, batch_size=64)).astype("float32")

# 3. Index (cosine via normalised inner product)
faiss.normalize_L2(emb)
index = faiss.IndexFlatIP(emb.shape[1])
index.add(emb)

# 4. Query
q_emb  = model.encode(["What are the main liquidity risks?"],
                       normalize_embeddings=True)
D, I   = index.search(np.array(q_emb).astype("float32"), k=3)
passages = [chunks[i] for i in I[0]]
why FAISS Dense retrieval over 500 chunks with a flat index takes under 10 ms. At 1M chunks HNSW reduces this to \(O(\log n)\). For local development the flat index suffices.
Guided problem 4 · generation + triage

Inject retrieved passages — then measure what naive RAG gets wrong

FinanceBench shows naive RAG fails approximately 81% of questions. Run the triage checklist on your pipeline.

def rag_answer(query, passages, client):
    context = "\n\n---\n\n".join(passages)
    system  = ("Answer using ONLY the provided context. "
               "If the answer is not present, say 'Not found in filing'.")
    resp = client.messages.create(
        model="claude-haiku-4-5", max_tokens=512,
        system=system,
        messages=[{"role": "user",
                   "content": f"Context:\n{context}\n\nQ: {query}"}]
    )
    return resp.content[0].text
Triage checklist
  1. Is the claim directly supported by a retrieved passage? Highlight it.
  2. Does the model add information absent from all 3 passages? (extrinsic hallucination)
  3. Were the correct passages retrieved? Swap to BM25 — does precision improve?
  4. Is the answer at wrong granularity? If yes, add a cross-encoder re-ranker.
FinanceBench lesson The #1 failure: the answer exists in the filing but was not retrieved. Increase \(k\), use hybrid BM25+dense, or section-targeted retrieval (Zhang et al., 2024).
05

Capstone: end-to-end pipeline

Tokenise → retrieve → extract → validate — all four problems wired together.
Capstone

Wire the four problems into one production-grade pipeline

Given a list of 10-K filings, automatically extract a structured earnings summary for each company, validate every field, and flag questions where RAG retrieval failed.

def pipeline(filing_path: str, questions: list[str]) -> dict:
    text   = Path(filing_path).read_text()
    tokens = enc.encode(text)
    cost   = len(tokens) * INPUT_PRICE * len(questions)

    chunks = chunk_text(text)               # Problem 1
    index  = build_faiss_index(chunks)      # Problem 4

    results = {}
    for q in questions:
        passages = retrieve(index, chunks, q, k=5)
        answer   = rag_answer(q, passages, client)

        if any(kw in q.lower() for kw in ("revenue","earnings","eps")):
            report = extract_earnings(answer, client)  # Problem 3
            issues = validate_extraction(report, answer)
            results[q] = {"answer": answer,
                          "structured": report.model_dump(),
                          "issues": issues}
        else:
            results[q] = {"answer": answer}

    return {"cost_estimate_usd": cost, "results": results}
Extension challenge — compression (optional)
Swap claude-haiku-4-5 for a locally served 7B Llama-3 quantised to INT4 with AWQ. Measure: (a) extraction accuracy on 10 FinanceBench questions, (b) latency per call, (c) cost per 1,000 filings. Does QLoRA fine-tuning on a proprietary 10-K corpus close the gap to Haiku?
Wrap-up

What you should be able to do now

Skills practised
  • Count tokens, estimate cost, choose chunking strategy
  • Read a positional-encoding heatmap; extract and plot attention weights
  • Design a Pydantic schema, call with tool use, validate structured output
  • Build a FAISS index, run hybrid BM25+dense retrieval, triage RAG failures
production checklist Before deploying any LLM-in-finance pipeline: (1) token budget + cost cap; (2) correct chunking for document length; (3) constrained decoding + schema validation; (4) hybrid RAG; (5) hallucination triage on a held-out set; (6) EU AI Act compliance assessment if output drives automated decisions.
next session Lecture 3 Practical — fine-tuning FinBERT on a proprietary sentiment dataset, and running LopezLira & Tang (2023) signals on your own news feed.