908 lines
28 KiB
Python
908 lines
28 KiB
Python
# ---
|
||
# jupyter:
|
||
# jupytext:
|
||
# text_representation:
|
||
# extension: .py
|
||
# format_name: percent
|
||
# format_version: '1.3'
|
||
# jupytext_version: 1.19.1
|
||
# kernelspec:
|
||
# display_name: Python 3 (ipykernel)
|
||
# language: python
|
||
# name: python3
|
||
# ---
|
||
|
||
# %% [markdown]
|
||
# # Text Data Extraction: Structuring Unstructured Financial Documents
|
||
#
|
||
# **Chapter 4: Fundamental and Alternative Data**
|
||
# **Docker image**: `ml4t`
|
||
# **Section Reference**: See Section 4.5 for text dataset engineering concepts
|
||
#
|
||
# ## Purpose
|
||
#
|
||
# Corporate filings contain valuable information locked in unstructured text. This notebook
|
||
# demonstrates how to extract and structure high-value text blocks (MD&A, Risk Factors) from
|
||
# 10-K and 10-Q filings, creating clean datasets ready for NLP analysis in later chapters.
|
||
#
|
||
# ## Learning Objectives
|
||
#
|
||
# After completing this notebook, you will be able to:
|
||
# - Navigate SEC EDGAR filing structure
|
||
# - Extract specific sections from 10-K/10-Q filings (MD&A, Risk Factors)
|
||
# - Clean and normalize extracted text
|
||
# - Create point-in-time correct text datasets
|
||
# - Prepare data for downstream NLP analysis
|
||
#
|
||
# ## Cross-References
|
||
#
|
||
# - **Upstream**: SEC EDGAR filings via `02_sec_filing_explorer.py`
|
||
# - **Downstream**: Chapter 10 `08_text_feature_evaluation.py` (NLP feature engineering)
|
||
# - **Related**: `02_sec_filing_explorer.py` (filing access)
|
||
#
|
||
# ## Key Concepts
|
||
#
|
||
# - **10-K**: Annual report with comprehensive company information
|
||
# - **10-Q**: Quarterly report with interim financial information
|
||
# - **MD&A**: Management's Discussion and Analysis of Financial Condition
|
||
# - **Risk Factors**: Required disclosure of material risks
|
||
# - **Item Numbers**: Standard sections in SEC filings (Item 7 = MD&A)
|
||
|
||
# %%
|
||
"""Text Data Extraction — extract and structure high-value text blocks from SEC filings for NLP analysis."""
|
||
|
||
import warnings
|
||
|
||
warnings.filterwarnings("ignore")
|
||
|
||
import os
|
||
import re
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import polars as pl
|
||
from bs4 import BeautifulSoup
|
||
from edgar import Company, set_identity
|
||
|
||
# %% tags=["parameters"]
|
||
# Production defaults — Papermill injects overrides for CI
|
||
EDGAR_TICKER = "AAPL"
|
||
EDGAR_FORM = "10-K"
|
||
|
||
# %% [markdown]
|
||
# ## 1. SEC Filing Structure
|
||
#
|
||
# 10-K filings follow a standard structure:
|
||
#
|
||
# | Item | Section | Content |
|
||
# |------|---------|---------|
|
||
# | Item 1 | Business | Company description and operations |
|
||
# | Item 1A | Risk Factors | Material risks to the business |
|
||
# | Item 7 | MD&A | Management's analysis of financial condition |
|
||
# | Item 7A | Quantitative Disclosures | Market risk exposures |
|
||
# | Item 8 | Financial Statements | Audited financials |
|
||
#
|
||
# The most valuable sections for NLP analysis:
|
||
# - **Item 1A (Risk Factors)**: Sentiment, risk themes, changes over time
|
||
# - **Item 7 (MD&A)**: Management's perspective on performance
|
||
|
||
# %%
|
||
# Define section patterns for extraction
|
||
# Note: 10-K and 10-Q have different item numbers for the same content
|
||
# 10-K: MD&A = Item 7, Risk Factors = Item 1A
|
||
# 10-Q: MD&A = Item 2 (Part I), Risk Factors = Item 1A (Part II, if updated)
|
||
|
||
SECTION_PATTERNS_10K = {
|
||
"risk_factors": {
|
||
# Line-anchored patterns to avoid Table of Contents matches
|
||
"start_patterns": [
|
||
r"^\s*ITEM\s*1A\.?\s*[-–—]?\s*RISK\s*FACTORS",
|
||
r"^\s*Item\s*1A\.?\s*[-–—]?\s*Risk\s*Factors",
|
||
],
|
||
"end_patterns": [
|
||
r"^\s*ITEM\s*1B\b",
|
||
r"^\s*ITEM\s*2\b",
|
||
r"^\s*Item\s*1B\b",
|
||
r"^\s*Item\s*2\b",
|
||
],
|
||
"item_number": "1A",
|
||
},
|
||
"mda": {
|
||
# Handle both straight and curly apostrophes: ' (U+0027) and ' (U+2019)
|
||
"start_patterns": [
|
||
r"^\s*ITEM\s*7\.?\s*[-–—]?\s*MANAGEMENT(?:'|\u2019)?S?\s*DISCUSSION",
|
||
r"^\s*Item\s*7\.?\s*[-–—]?\s*Management(?:'|\u2019)?s?\s*Discussion",
|
||
],
|
||
"end_patterns": [
|
||
r"^\s*ITEM\s*7A\b",
|
||
r"^\s*ITEM\s*8\b",
|
||
r"^\s*Item\s*7A\b",
|
||
r"^\s*Item\s*8\b",
|
||
],
|
||
"item_number": "7",
|
||
},
|
||
"business": {
|
||
"start_patterns": [
|
||
r"^\s*ITEM\s*1\.?\s*[-–—]?\s*BUSINESS",
|
||
r"^\s*Item\s*1\.?\s*[-–—]?\s*Business",
|
||
],
|
||
"end_patterns": [
|
||
r"^\s*ITEM\s*1A\b",
|
||
r"^\s*ITEM\s*2\b",
|
||
r"^\s*Item\s*1A\b",
|
||
r"^\s*Item\s*2\b",
|
||
],
|
||
"item_number": "1",
|
||
},
|
||
}
|
||
|
||
# 10-Q patterns differ from 10-K
|
||
SECTION_PATTERNS_10Q = {
|
||
"risk_factors": {
|
||
# In 10-Q, risk factor updates appear in Part II, Item 1A
|
||
# Line-anchored patterns with MULTILINE support (handled in extract_section)
|
||
"start_patterns": [
|
||
r"^\s*ITEM\s*1A\.?\s*[-–—]?\s*RISK\s*FACTORS",
|
||
r"^\s*Item\s*1A\.?\s*[-–—]?\s*Risk\s*Factors",
|
||
],
|
||
"end_patterns": [
|
||
r"^\s*ITEM\s*1B\b",
|
||
r"^\s*ITEM\s*2\b",
|
||
r"^\s*Item\s*2\b",
|
||
],
|
||
"item_number": "1A",
|
||
# For 10-Q risk factors, prefer matches after PART II
|
||
"require_after": r"PART\s*II",
|
||
},
|
||
"mda": {
|
||
# In 10-Q, MD&A is Part I, Item 2 (NOT Item 7)
|
||
# Handle both straight and curly apostrophes
|
||
"start_patterns": [
|
||
r"^\s*ITEM\s*2\.?\s*[-–—]?\s*MANAGEMENT(?:'|\u2019)?S?\s*DISCUSSION",
|
||
r"^\s*Item\s*2\.?\s*[-–—]?\s*Management(?:'|\u2019)?s?\s*Discussion",
|
||
],
|
||
"end_patterns": [
|
||
r"^\s*ITEM\s*3\b",
|
||
r"^\s*Item\s*3\b",
|
||
],
|
||
"item_number": "2",
|
||
},
|
||
}
|
||
|
||
# Default to 10-K patterns (backward compatible)
|
||
SECTION_PATTERNS = SECTION_PATTERNS_10K
|
||
|
||
|
||
def get_patterns_for_form(form_type: str) -> dict:
|
||
"""Get appropriate section patterns based on filing type."""
|
||
form_type_upper = form_type.upper() if form_type else "10-K"
|
||
if "10-Q" in form_type_upper:
|
||
return SECTION_PATTERNS_10Q
|
||
return SECTION_PATTERNS_10K
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 2. Text Cleaning Functions
|
||
#
|
||
# Raw SEC filings contain HTML, special characters, and formatting that must be cleaned.
|
||
|
||
|
||
# %%
|
||
def clean_html(html_text: str, drop_tables: bool = False) -> str:
|
||
"""Remove HTML tags and convert to plain text while preserving paragraph boundaries."""
|
||
if not html_text:
|
||
return ""
|
||
|
||
soup = BeautifulSoup(html_text, "html.parser")
|
||
|
||
# Remove script and style elements
|
||
for script in soup(["script", "style"]):
|
||
script.decompose()
|
||
|
||
# Optionally remove tables (embedded financial tables in narrative sections)
|
||
if drop_tables:
|
||
for table in soup.find_all("table"):
|
||
table.decompose()
|
||
|
||
# Preserve paragraph boundaries by using newline separator
|
||
text = soup.get_text(separator="\n")
|
||
|
||
return text
|
||
|
||
|
||
# %%
|
||
def clean_text(text: str) -> str:
|
||
"""
|
||
Clean extracted text while preserving paragraph breaks.
|
||
|
||
Removes common filing artifacts and normalizes whitespace without
|
||
flattening everything into a single line (preserves paragraph structure
|
||
for downstream chunking and change detection).
|
||
"""
|
||
if not text:
|
||
return ""
|
||
|
||
# Normalize line endings
|
||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||
|
||
# Remove common filing artifacts (line-anchored for safety)
|
||
text = re.sub(r"(?im)^\s*table of contents\s*$", "", text)
|
||
text = re.sub(r"(?im)^\s*page\s+\d+\s*$", "", text)
|
||
text = re.sub(r"(?im)^\s*\d+\s*of\s*\d+\s*$", "", text)
|
||
|
||
# Remove URLs
|
||
text = re.sub(r"https?://\S+", "", text)
|
||
|
||
# Remove excessive special characters but keep newlines
|
||
text = re.sub(r"[_=\-]{3,}", " ", text)
|
||
text = re.sub(r"[•●◦▪]", " ", text)
|
||
|
||
# Normalize spaces within each line, but preserve line breaks
|
||
text = "\n".join(re.sub(r"[ \t]+", " ", line).strip() for line in text.split("\n"))
|
||
|
||
# Collapse excessive blank lines to paragraph breaks
|
||
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||
|
||
return text
|
||
|
||
|
||
# %%
|
||
def normalize_for_analysis(text: str) -> str:
|
||
"""
|
||
Normalize text for NLP analysis.
|
||
|
||
Converts to lowercase, removes numbers (optional),
|
||
and standardizes formatting.
|
||
"""
|
||
if not text:
|
||
return ""
|
||
|
||
# Convert to lowercase
|
||
text = text.lower()
|
||
|
||
# Standardize common abbreviations
|
||
text = re.sub(r"\bu\.s\.?\b", "united states", text)
|
||
text = re.sub(r"\be\.g\.?\b", "for example", text)
|
||
text = re.sub(r"\bi\.e\.?\b", "that is", text)
|
||
|
||
# Remove possessive 's
|
||
text = re.sub(r"'s\b", "", text)
|
||
|
||
# Remove numbers (optional - depends on use case)
|
||
# text = re.sub(r"\d+", "", text)
|
||
|
||
return text
|
||
|
||
|
||
# Test cleaning
|
||
sample_html = """
|
||
<p>ITEM 1A. RISK FACTORS</p>
|
||
<p>The following risks could <b>materially</b> affect our business:</p>
|
||
<p>• Competition may increase</p>
|
||
<p>• Economic conditions may deteriorate</p>
|
||
<p>Page 15 of 120</p>
|
||
<p>Table of Contents</p>
|
||
"""
|
||
|
||
cleaned = clean_text(clean_html(sample_html))
|
||
print("Sample cleaning:")
|
||
print(f"Original HTML length: {len(sample_html)}")
|
||
print(f"Cleaned text: {cleaned}")
|
||
|
||
# %% [markdown]
|
||
# ## 3. Section Extraction
|
||
|
||
|
||
# %%
|
||
def extract_section(text: str, section_name: str, patterns: dict = None) -> str | None:
|
||
"""
|
||
Extract a specific section from a 10-K/10-Q filing using anchored item boundaries.
|
||
|
||
Robust to TOC duplicates by finding all start_pattern matches and
|
||
picking the candidate (start, end) span with the most content
|
||
between it and the next end_pattern. Inline references inside
|
||
narrative text are filtered out by the same span-length test —
|
||
they don't have a matching end-pattern Item N+1 immediately after.
|
||
|
||
Parameters
|
||
----------
|
||
text : str
|
||
Full filing text
|
||
section_name : str
|
||
Name of section to extract (e.g., 'risk_factors', 'mda')
|
||
patterns : dict
|
||
Custom patterns, or use SECTION_PATTERNS
|
||
|
||
Returns
|
||
-------
|
||
str or None
|
||
Extracted section text, or None if no candidate has substantive content.
|
||
"""
|
||
if not text:
|
||
return None
|
||
|
||
if patterns is None:
|
||
patterns = SECTION_PATTERNS
|
||
|
||
if section_name not in patterns:
|
||
raise ValueError(f"Unknown section: {section_name}")
|
||
|
||
config = patterns[section_name]
|
||
flags = re.IGNORECASE | re.MULTILINE | re.DOTALL
|
||
|
||
# Some patterns may require matching after a specific marker
|
||
# (e.g., PART II for 10-Q risk factors).
|
||
offset = 0
|
||
search_text = text
|
||
require_after = config.get("require_after")
|
||
if require_after:
|
||
after_match = re.search(require_after, search_text, flags)
|
||
if after_match:
|
||
offset = after_match.start()
|
||
search_text = search_text[after_match.start() :]
|
||
|
||
# Collect every candidate start position from every start pattern.
|
||
starts: list[int] = []
|
||
for pattern in config["start_patterns"]:
|
||
starts.extend(offset + m.end() for m in re.finditer(pattern, search_text, flags))
|
||
if not starts:
|
||
return None
|
||
starts = sorted(set(starts))
|
||
|
||
# For each candidate start, find the next end_pattern match and
|
||
# measure the span. The real section beats every TOC entry on
|
||
# span length because TOC entries are followed by the next TOC
|
||
# entry within a few dozen characters.
|
||
best_span: tuple[int, int] | None = None
|
||
for start_pos in starts:
|
||
end_pos = len(text)
|
||
for end_pattern in config["end_patterns"]:
|
||
end_match = re.search(end_pattern, text[start_pos:], flags)
|
||
if end_match:
|
||
end_pos = start_pos + end_match.start()
|
||
break
|
||
span = end_pos - start_pos
|
||
if best_span is None or span > (best_span[1] - best_span[0]):
|
||
best_span = (start_pos, end_pos)
|
||
|
||
if best_span is None or (best_span[1] - best_span[0]) < 200:
|
||
return None
|
||
|
||
return clean_text(text[best_span[0] : best_span[1]])
|
||
|
||
|
||
# Create sample 10-K text for demonstration
|
||
sample_10k = """
|
||
FORM 10-K
|
||
ANNUAL REPORT
|
||
|
||
Table of Contents
|
||
|
||
PART I
|
||
|
||
ITEM 1. BUSINESS
|
||
|
||
Our company is a leading provider of technology solutions for enterprise
|
||
customers worldwide. We design, manufacture, and support a portfolio of
|
||
hardware, software, and cloud services that help organizations modernize
|
||
their data infrastructure.
|
||
|
||
We operate across three reportable segments — Cloud Platform, Enterprise
|
||
Software, and Professional Services — and sell directly through a global
|
||
sales force as well as a network of channel partners. Our customers span
|
||
financial services, healthcare, public sector, and manufacturing.
|
||
|
||
Recent strategic initiatives include expanding our hyperscale data center
|
||
footprint, deepening integrations with leading public cloud providers,
|
||
and broadening our generative-AI product portfolio.
|
||
|
||
ITEM 1A. RISK FACTORS
|
||
|
||
An investment in our securities involves a high degree of risk.
|
||
You should carefully consider the following risk factors:
|
||
|
||
COMPETITION RISKS
|
||
We face significant competition from larger companies with more resources.
|
||
New entrants may disrupt our market position.
|
||
|
||
ECONOMIC RISKS
|
||
Economic downturns could reduce customer spending on our products.
|
||
Currency fluctuations may impact our international revenue.
|
||
|
||
REGULATORY RISKS
|
||
Changes in regulations could increase our compliance costs.
|
||
Data privacy laws continue to evolve across jurisdictions.
|
||
|
||
ITEM 1B. UNRESOLVED STAFF COMMENTS
|
||
|
||
None.
|
||
|
||
ITEM 2. PROPERTIES
|
||
|
||
We lease our headquarters in San Francisco, California.
|
||
|
||
PART II
|
||
|
||
ITEM 7. MANAGEMENT'S DISCUSSION AND ANALYSIS OF FINANCIAL CONDITION
|
||
|
||
Overview
|
||
The past year has been transformative for our company.
|
||
Revenue increased 25% driven by strong product adoption.
|
||
|
||
Results of Operations
|
||
Our gross margin improved to 72% from 68% in the prior year.
|
||
Operating expenses remained well controlled.
|
||
|
||
Liquidity and Capital Resources
|
||
We ended the year with $500 million in cash.
|
||
We believe current resources are sufficient for operations.
|
||
|
||
ITEM 7A. QUANTITATIVE AND QUALITATIVE DISCLOSURES ABOUT MARKET RISK
|
||
|
||
We are exposed to interest rate and foreign currency risks.
|
||
|
||
ITEM 8. FINANCIAL STATEMENTS
|
||
"""
|
||
|
||
for section in ["business", "risk_factors", "mda"]:
|
||
extracted = extract_section(sample_10k, section)
|
||
if extracted:
|
||
preview = extracted[:200] + "..." if len(extracted) > 200 else extracted
|
||
print(f"{section.upper()} ({len(extracted)} chars):")
|
||
print(preview)
|
||
print()
|
||
|
||
# %% [markdown]
|
||
# ## 4. Building a Text Dataset
|
||
|
||
|
||
# %%
|
||
_DEFAULT_SECTIONS = ("risk_factors", "mda")
|
||
|
||
|
||
def create_text_dataset(
|
||
filings: list[dict], sections: list[str] = _DEFAULT_SECTIONS
|
||
) -> pl.DataFrame:
|
||
"""
|
||
Create a structured dataset from multiple filings.
|
||
|
||
Parameters
|
||
----------
|
||
filings : list of dict
|
||
Each dict should contain:
|
||
- cik: Company CIK
|
||
- company_name: Company name
|
||
- accession_no: EDGAR accession number (stable filing identifier)
|
||
- filing_date: Filing date
|
||
- accepted_at: SEC acceptance timestamp (for intraday PIT)
|
||
- period_end: Reporting period end date
|
||
- filing_type: 10-K, 10-Q, etc.
|
||
- text: Raw filing text
|
||
sections : list of str
|
||
Sections to extract
|
||
|
||
Returns
|
||
-------
|
||
pl.DataFrame
|
||
Structured text dataset with PIT-correct schema
|
||
|
||
Note
|
||
----
|
||
Automatically uses correct section patterns based on filing_type (10-K vs 10-Q).
|
||
In 10-Q, MD&A is Item 2 (not Item 7 as in 10-K).
|
||
"""
|
||
records = []
|
||
|
||
for filing in filings:
|
||
filing_type = filing.get("filing_type", "10-K")
|
||
base_record = {
|
||
"cik": filing.get("cik"),
|
||
"company_name": filing.get("company_name"),
|
||
# Stable filing identifier (prefer over cik + filing_date)
|
||
"accession_no": filing.get("accession_no"),
|
||
"filing_type": filing_type,
|
||
# PIT timestamps
|
||
"filing_date": filing.get("filing_date"),
|
||
"accepted_at": filing.get("accepted_at"), # Intraday PIT
|
||
"period_end": filing.get("period_end"),
|
||
}
|
||
|
||
text = filing.get("text", "")
|
||
|
||
# Use form-type-aware patterns
|
||
patterns = get_patterns_for_form(filing_type)
|
||
|
||
for section in sections:
|
||
# Skip sections not defined for this form type
|
||
if section not in patterns:
|
||
continue
|
||
|
||
extracted = extract_section(text, section, patterns)
|
||
|
||
record = {
|
||
**base_record,
|
||
"section": section,
|
||
"text": extracted or "",
|
||
"text_length": len(extracted) if extracted else 0,
|
||
"word_count": len(extracted.split()) if extracted else 0,
|
||
}
|
||
records.append(record)
|
||
|
||
return pl.DataFrame(records)
|
||
|
||
|
||
# Create sample filings with PIT-correct schema
|
||
sample_filings = [
|
||
{
|
||
"cik": "0000320193",
|
||
"company_name": "Apple Inc.",
|
||
"accession_no": "0000320193-24-000081",
|
||
"filing_date": datetime(2024, 10, 31),
|
||
"accepted_at": datetime(2024, 10, 31, 16, 35, 12), # SEC acceptance timestamp
|
||
"period_end": datetime(2024, 9, 28),
|
||
"filing_type": "10-K",
|
||
"text": sample_10k,
|
||
},
|
||
{
|
||
"cik": "0000789019",
|
||
"company_name": "Microsoft Corporation",
|
||
"accession_no": "0000789019-24-000042",
|
||
"filing_date": datetime(2024, 7, 30),
|
||
"accepted_at": datetime(2024, 7, 30, 16, 5, 45),
|
||
"period_end": datetime(2024, 6, 30),
|
||
"filing_type": "10-K",
|
||
"text": sample_10k, # Using same sample for demo
|
||
},
|
||
]
|
||
|
||
text_dataset = create_text_dataset(sample_filings)
|
||
text_dataset
|
||
|
||
# %% [markdown]
|
||
# ## 5. Text Statistics and Quality Checks
|
||
|
||
|
||
# %%
|
||
def calculate_text_statistics(text: str) -> dict:
|
||
"""Calculate statistics about extracted text."""
|
||
if not text:
|
||
return {
|
||
"word_count": 0,
|
||
"sentence_count": 0,
|
||
"avg_word_length": 0,
|
||
"avg_sentence_length": 0,
|
||
"unique_words": 0,
|
||
"lexical_diversity": 0,
|
||
}
|
||
|
||
words = text.lower().split()
|
||
sentences = re.split(r"[.!?]+", text)
|
||
sentences = [s.strip() for s in sentences if s.strip()]
|
||
|
||
word_count = len(words)
|
||
unique_words = len(set(words))
|
||
|
||
return {
|
||
"word_count": word_count,
|
||
"sentence_count": len(sentences),
|
||
"avg_word_length": np.mean([len(w) for w in words]) if words else 0,
|
||
"avg_sentence_length": word_count / len(sentences) if sentences else 0,
|
||
"unique_words": unique_words,
|
||
"lexical_diversity": unique_words / word_count if word_count > 0 else 0,
|
||
}
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Quality Check Extraction
|
||
# Flag potential issues: missing sections, truncated extractions, or boundary contamination.
|
||
|
||
|
||
# %%
|
||
def quality_check_extraction(df: pl.DataFrame, min_words: int = 100) -> pl.DataFrame:
|
||
"""
|
||
Check quality of extracted text.
|
||
|
||
Flags potential issues:
|
||
- Missing (extraction returned nothing)
|
||
- Too short (extraction may have failed partially)
|
||
- Too long (may have captured extra sections)
|
||
- Boundary leak (captured content from next section)
|
||
"""
|
||
# Add quality flags - NOTE: order matters! Check missing first since 0 < min_words
|
||
result = df.with_columns(
|
||
[
|
||
pl.when(pl.col("word_count") == 0)
|
||
.then(pl.lit("missing"))
|
||
.when(pl.col("word_count") < min_words)
|
||
.then(pl.lit("too_short"))
|
||
.when(pl.col("word_count") > 50000)
|
||
.then(pl.lit("suspiciously_long"))
|
||
.otherwise(pl.lit("ok"))
|
||
.alias("quality_flag")
|
||
]
|
||
)
|
||
|
||
# Check for boundary contamination (e.g., "Item 8" appearing in MD&A)
|
||
# This indicates the end boundary wasn't properly detected
|
||
result = result.with_columns(
|
||
pl.when((pl.col("section") == "mda") & pl.col("text").str.contains(r"(?i)\bITEM\s*8\b"))
|
||
.then(pl.lit(True))
|
||
.when(
|
||
(pl.col("section") == "risk_factors")
|
||
& pl.col("text").str.contains(r"(?i)\bITEM\s*2\b.*\bPROPERTIES\b")
|
||
)
|
||
.then(pl.lit(True))
|
||
.otherwise(pl.lit(False))
|
||
.alias("possible_boundary_leak")
|
||
)
|
||
|
||
return result
|
||
|
||
|
||
text_dataset_checked = quality_check_extraction(text_dataset)
|
||
text_dataset_checked.select(["company_name", "section", "word_count", "quality_flag"])
|
||
|
||
# %%
|
||
sample_text = text_dataset.filter(
|
||
(pl.col("section") == "risk_factors") & (pl.col("word_count") > 0)
|
||
)["text"][0]
|
||
|
||
stats = calculate_text_statistics(sample_text)
|
||
print("Text Statistics (Risk Factors section):")
|
||
for k, v in stats.items():
|
||
print(f" {k}: {v:.2f}" if isinstance(v, float) else f" {k}: {v}")
|
||
|
||
# %% [markdown]
|
||
# ## 6. Change Analysis Over Time
|
||
#
|
||
# Track how MD&A and Risk Factors change between filings.
|
||
|
||
|
||
# %%
|
||
def calculate_text_similarity(text1: str, text2: str) -> dict:
|
||
"""
|
||
Calculate similarity between two text documents.
|
||
|
||
Uses simple word overlap metrics (Jaccard similarity).
|
||
For production, consider using TF-IDF or embeddings.
|
||
"""
|
||
if not text1 or not text2:
|
||
return {"jaccard": 0, "word_overlap_pct": 0}
|
||
|
||
words1 = set(text1.lower().split())
|
||
words2 = set(text2.lower().split())
|
||
|
||
intersection = words1 & words2
|
||
union = words1 | words2
|
||
|
||
jaccard = len(intersection) / len(union) if union else 0
|
||
overlap_pct = len(intersection) / len(words1) if words1 else 0
|
||
|
||
return {
|
||
"jaccard_similarity": jaccard,
|
||
"word_overlap_pct": overlap_pct,
|
||
"new_words": len(words2 - words1),
|
||
"removed_words": len(words1 - words2),
|
||
}
|
||
|
||
|
||
# %%
|
||
def extract_new_risk_factors(old_text: str, new_text: str) -> list[str]:
|
||
"""
|
||
Identify potentially new risk factors by finding new paragraphs.
|
||
|
||
Simple heuristic: paragraphs in new filing not present in old.
|
||
"""
|
||
if not old_text or not new_text:
|
||
return []
|
||
|
||
# Split into paragraphs
|
||
old_paragraphs = set(p.strip().lower() for p in old_text.split("\n\n") if len(p.strip()) > 50)
|
||
new_paragraphs = [p.strip() for p in new_text.split("\n\n") if len(p.strip()) > 50]
|
||
|
||
new_risks = []
|
||
for para in new_paragraphs:
|
||
# Check if paragraph is substantially new (low similarity to all old paragraphs)
|
||
para_lower = para.lower()
|
||
if para_lower not in old_paragraphs:
|
||
# Simple check - could be improved with fuzzy matching
|
||
new_risks.append(para[:200] + "..." if len(para) > 200 else para)
|
||
|
||
return new_risks[:5] # Return top 5 new items
|
||
|
||
|
||
# %%
|
||
# Simulate two versions of risk factors
|
||
old_risks = """
|
||
COMPETITION RISKS
|
||
We face significant competition from larger companies.
|
||
|
||
ECONOMIC RISKS
|
||
Economic downturns could reduce customer spending.
|
||
"""
|
||
|
||
new_risks = """
|
||
COMPETITION RISKS
|
||
We face significant competition from larger companies with more resources.
|
||
|
||
ECONOMIC RISKS
|
||
Economic downturns could reduce customer spending.
|
||
|
||
AI TECHNOLOGY RISKS
|
||
Rapid advances in artificial intelligence may disrupt our business model.
|
||
We may need to make significant investments to remain competitive.
|
||
|
||
CYBERSECURITY RISKS
|
||
We face increasing threats from sophisticated cyber attacks.
|
||
"""
|
||
|
||
similarity = calculate_text_similarity(old_risks, new_risks)
|
||
print("Similarity Metrics:")
|
||
for k, v in similarity.items():
|
||
print(f" {k}: {v:.3f}" if isinstance(v, float) else f" {k}: {v}")
|
||
|
||
new_items = extract_new_risk_factors(old_risks, new_risks)
|
||
print(f"\nNew Risk Factors Identified: {len(new_items)}")
|
||
for i, item in enumerate(new_items, 1):
|
||
print(f" {i}. {item[:100]}...")
|
||
|
||
# %% [markdown]
|
||
# ## 7. Saving the Dataset
|
||
|
||
|
||
# %%
|
||
def save_text_dataset(df: pl.DataFrame, output_path: str, format: str = "parquet") -> None:
|
||
"""
|
||
Save text dataset to file.
|
||
|
||
Parameters
|
||
----------
|
||
df : pl.DataFrame
|
||
Text dataset
|
||
output_path : str
|
||
Output file path
|
||
format : str
|
||
'parquet' or 'csv'
|
||
"""
|
||
path = Path(output_path)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
if format == "parquet":
|
||
df.write_parquet(path)
|
||
elif format == "csv":
|
||
df.write_csv(path)
|
||
else:
|
||
raise ValueError(f"Unknown format: {format}")
|
||
|
||
print(f"Saved {len(df)} records to {path}")
|
||
|
||
|
||
# %%
|
||
# Example save (commented out to avoid file creation in demo)
|
||
# save_text_dataset(text_dataset, "/tmp/sec_text_data.parquet")
|
||
print("\nTo save dataset:")
|
||
print(" save_text_dataset(text_dataset, 'data/sec_text_data.parquet')")
|
||
|
||
# %% [markdown]
|
||
# ## 8. End-to-End Run Against a Live EDGAR Filing
|
||
#
|
||
# The previous sections developed the extraction pipeline against an
|
||
# inline sample 10-K. The same helpers run unchanged against a real
|
||
# filing: `edgartools` fetches the latest AAPL 10-K from EDGAR, our
|
||
# `extract_section` + `clean_text` + `calculate_text_statistics`
|
||
# helpers process it, and the resulting per-section summary mirrors
|
||
# what the synthetic example produced earlier in the notebook.
|
||
#
|
||
# The SEC requires a real User-Agent identity for every request and
|
||
# blocks placeholder addresses. Set `EDGAR_IDENTITY` in your environment
|
||
# to `"Your Name your.email@domain.com"` before running this section:
|
||
#
|
||
# ```bash
|
||
# export EDGAR_IDENTITY="Jane Doe jane@example.org"
|
||
# ```
|
||
#
|
||
# The fetch is read-only, rate-limited by edgartools, and typically
|
||
# completes in 1–3 seconds.
|
||
|
||
# %%
|
||
edgar_identity = os.environ.get("EDGAR_IDENTITY")
|
||
if not edgar_identity:
|
||
raise RuntimeError(
|
||
"EDGAR_IDENTITY environment variable is not set. The SEC requires a "
|
||
"real User-Agent (name + email) for every EDGAR request and blocks "
|
||
"placeholder addresses. Set it before running this section, e.g. "
|
||
'`export EDGAR_IDENTITY="Jane Doe jane@example.org"`.'
|
||
)
|
||
set_identity(edgar_identity)
|
||
|
||
company = Company(EDGAR_TICKER)
|
||
latest_filing = company.get_filings(form=EDGAR_FORM).latest()
|
||
print(
|
||
f"{EDGAR_TICKER} {EDGAR_FORM}: accession {latest_filing.accession_no}, filed {latest_filing.filing_date}"
|
||
)
|
||
|
||
# %% [markdown]
|
||
# Fetch the filing text and run the section extractor with the
|
||
# form-aware patterns. The two highest-value sections (Risk Factors
|
||
# and MD&A) come back as plain strings — ready for `clean_text`,
|
||
# tokenisation, or downstream NLP feature engineering.
|
||
|
||
# %%
|
||
real_text = latest_filing.text()
|
||
form_patterns = get_patterns_for_form(EDGAR_FORM)
|
||
|
||
real_risk = extract_section(real_text, "risk_factors", form_patterns)
|
||
real_mda = extract_section(real_text, "mda", form_patterns)
|
||
real_business = extract_section(real_text, "business", form_patterns)
|
||
|
||
real_stats = pl.DataFrame(
|
||
[
|
||
{
|
||
"section": name,
|
||
"extracted": text is not None,
|
||
"char_count": len(text) if text else 0,
|
||
"word_count": calculate_text_statistics(text)["word_count"] if text else 0,
|
||
}
|
||
for name, text in [
|
||
("business", real_business),
|
||
("risk_factors", real_risk),
|
||
("mda", real_mda),
|
||
]
|
||
]
|
||
)
|
||
real_stats
|
||
|
||
# %% [markdown]
|
||
# A successful run shows non-zero word counts on each row — the same
|
||
# pipeline that produced the synthetic-text statistics in Section 5
|
||
# works against a current SEC filing without code changes. From here,
|
||
# the cleaned strings feed directly into the NLP feature pipeline in
|
||
# Chapter 10.
|
||
|
||
# %% [markdown]
|
||
# ## 9. Key Takeaways
|
||
#
|
||
# ### Text Extraction Best Practices
|
||
#
|
||
# 1. **Use line-anchored regex patterns for section boundaries**
|
||
# - Item numbers are standardized (Item 1A, Item 7)
|
||
# - But formatting varies - need multiple patterns with `^` anchoring
|
||
# - Skip Table of Contents matches by searching after TOC marker
|
||
#
|
||
# 2. **Clean thoroughly but preserve paragraph structure**
|
||
# - Remove HTML tags and filing artifacts
|
||
# - Normalize whitespace *within* lines, but keep paragraph breaks
|
||
# - Paragraph structure enables change detection over time
|
||
#
|
||
# 3. **Validate extraction quality**
|
||
# - Check text length (too short = extraction failed)
|
||
# - Check for boundary leaks (e.g., "Item 8" in MD&A)
|
||
# - Track quality metrics for iterative rule refinement
|
||
#
|
||
# 4. **Track point-in-time with stable identifiers**
|
||
# - Use **accession number** as primary key (not cik + filing_date)
|
||
# - Use **accepted_at** for intraday PIT correctness
|
||
# - Keep both filing_date and period_end for different query patterns
|
||
#
|
||
# 5. **Store efficiently with audit trail**
|
||
# - Parquet format for large datasets
|
||
# - Include metadata (CIK, accession, timestamps, section)
|
||
# - Consider storing both raw and cleaned text for reproducibility
|
||
#
|
||
# ### Most Valuable Sections
|
||
#
|
||
# | Section | Alpha Use Case |
|
||
# |---------|----------------|
|
||
# | **Risk Factors (1A)** | Sentiment analysis, risk themes, change detection |
|
||
# | **MD&A (Item 7)** | Management sentiment, outlook changes |
|
||
# | **Business (Item 1)** | Competitive position, strategy changes |
|
||
#
|
||
# ### Forward Reference
|
||
#
|
||
# This structured text dataset is the input for NLP techniques covered in later chapters:
|
||
# - Sentiment scoring (positive/negative language)
|
||
# - Topic modeling (identify themes)
|
||
# - Named entity recognition (extract companies, products)
|
||
# - Text embeddings (semantic similarity)
|