Files
2026-07-13 13:26:28 +08:00

1509 lines
58 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"cells": [
{
"cell_type": "markdown",
"id": "adaf08d3",
"metadata": {
"tags": []
},
"source": [
"# Text Data Extraction: Structuring Unstructured Financial Documents\n",
"\n",
"**Chapter 4: Fundamental and Alternative Data**\n",
"**Docker image**: `ml4t`\n",
"**Section Reference**: See Section 4.5 for text dataset engineering concepts\n",
"\n",
"## Purpose\n",
"\n",
"Corporate filings contain valuable information locked in unstructured text. This notebook\n",
"demonstrates how to extract and structure high-value text blocks (MD&A, Risk Factors) from\n",
"10-K and 10-Q filings, creating clean datasets ready for NLP analysis in later chapters.\n",
"\n",
"## Learning Objectives\n",
"\n",
"After completing this notebook, you will be able to:\n",
"- Navigate SEC EDGAR filing structure\n",
"- Extract specific sections from 10-K/10-Q filings (MD&A, Risk Factors)\n",
"- Clean and normalize extracted text\n",
"- Create point-in-time correct text datasets\n",
"- Prepare data for downstream NLP analysis\n",
"\n",
"## Cross-References\n",
"\n",
"- **Upstream**: SEC EDGAR filings via `02_sec_filing_explorer.py`\n",
"- **Downstream**: Chapter 10 `08_text_feature_evaluation.py` (NLP feature engineering)\n",
"- **Related**: `02_sec_filing_explorer.py` (filing access)\n",
"\n",
"## Key Concepts\n",
"\n",
"- **10-K**: Annual report with comprehensive company information\n",
"- **10-Q**: Quarterly report with interim financial information\n",
"- **MD&A**: Management's Discussion and Analysis of Financial Condition\n",
"- **Risk Factors**: Required disclosure of material risks\n",
"- **Item Numbers**: Standard sections in SEC filings (Item 7 = MD&A)"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "959cc2b5",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:29.853419Z",
"iopub.status.busy": "2026-05-16T23:42:29.853286Z",
"iopub.status.idle": "2026-05-16T23:42:30.362862Z",
"shell.execute_reply": "2026-05-16T23:42:30.362342Z"
},
"tags": []
},
"outputs": [],
"source": [
"\"\"\"Text Data Extraction — extract and structure high-value text blocks from SEC filings for NLP analysis.\"\"\"\n",
"\n",
"import warnings\n",
"\n",
"warnings.filterwarnings(\"ignore\")\n",
"\n",
"import os\n",
"import re\n",
"from datetime import datetime\n",
"from pathlib import Path\n",
"\n",
"import numpy as np\n",
"import polars as pl\n",
"from bs4 import BeautifulSoup\n",
"from edgar import Company, set_identity"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "7b2a7030",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.367215Z",
"iopub.status.busy": "2026-05-16T23:42:30.367058Z",
"iopub.status.idle": "2026-05-16T23:42:30.369174Z",
"shell.execute_reply": "2026-05-16T23:42:30.368881Z"
},
"tags": [
"parameters"
]
},
"outputs": [],
"source": [
"# Production defaults — Papermill injects overrides for CI\n",
"EDGAR_TICKER = \"AAPL\"\n",
"EDGAR_FORM = \"10-K\""
]
},
{
"cell_type": "markdown",
"id": "0daaf10f",
"metadata": {
"tags": []
},
"source": [
"## 1. SEC Filing Structure\n",
"\n",
"10-K filings follow a standard structure:\n",
"\n",
"| Item | Section | Content |\n",
"|------|---------|---------|\n",
"| Item 1 | Business | Company description and operations |\n",
"| Item 1A | Risk Factors | Material risks to the business |\n",
"| Item 7 | MD&A | Management's analysis of financial condition |\n",
"| Item 7A | Quantitative Disclosures | Market risk exposures |\n",
"| Item 8 | Financial Statements | Audited financials |\n",
"\n",
"The most valuable sections for NLP analysis:\n",
"- **Item 1A (Risk Factors)**: Sentiment, risk themes, changes over time\n",
"- **Item 7 (MD&A)**: Management's perspective on performance"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "b317fde4",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.377937Z",
"iopub.status.busy": "2026-05-16T23:42:30.377815Z",
"iopub.status.idle": "2026-05-16T23:42:30.381933Z",
"shell.execute_reply": "2026-05-16T23:42:30.381575Z"
},
"lines_to_next_cell": 2,
"tags": []
},
"outputs": [],
"source": [
"# Define section patterns for extraction\n",
"# Note: 10-K and 10-Q have different item numbers for the same content\n",
"# 10-K: MD&A = Item 7, Risk Factors = Item 1A\n",
"# 10-Q: MD&A = Item 2 (Part I), Risk Factors = Item 1A (Part II, if updated)\n",
"\n",
"SECTION_PATTERNS_10K = {\n",
" \"risk_factors\": {\n",
" # Line-anchored patterns to avoid Table of Contents matches\n",
" \"start_patterns\": [\n",
" r\"^\\s*ITEM\\s*1A\\.?\\s*[-–—]?\\s*RISK\\s*FACTORS\",\n",
" r\"^\\s*Item\\s*1A\\.?\\s*[-–—]?\\s*Risk\\s*Factors\",\n",
" ],\n",
" \"end_patterns\": [\n",
" r\"^\\s*ITEM\\s*1B\\b\",\n",
" r\"^\\s*ITEM\\s*2\\b\",\n",
" r\"^\\s*Item\\s*1B\\b\",\n",
" r\"^\\s*Item\\s*2\\b\",\n",
" ],\n",
" \"item_number\": \"1A\",\n",
" },\n",
" \"mda\": {\n",
" # Handle both straight and curly apostrophes: ' (U+0027) and ' (U+2019)\n",
" \"start_patterns\": [\n",
" r\"^\\s*ITEM\\s*7\\.?\\s*[-–—]?\\s*MANAGEMENT(?:'|\\u2019)?S?\\s*DISCUSSION\",\n",
" r\"^\\s*Item\\s*7\\.?\\s*[-–—]?\\s*Management(?:'|\\u2019)?s?\\s*Discussion\",\n",
" ],\n",
" \"end_patterns\": [\n",
" r\"^\\s*ITEM\\s*7A\\b\",\n",
" r\"^\\s*ITEM\\s*8\\b\",\n",
" r\"^\\s*Item\\s*7A\\b\",\n",
" r\"^\\s*Item\\s*8\\b\",\n",
" ],\n",
" \"item_number\": \"7\",\n",
" },\n",
" \"business\": {\n",
" \"start_patterns\": [\n",
" r\"^\\s*ITEM\\s*1\\.?\\s*[-–—]?\\s*BUSINESS\",\n",
" r\"^\\s*Item\\s*1\\.?\\s*[-–—]?\\s*Business\",\n",
" ],\n",
" \"end_patterns\": [\n",
" r\"^\\s*ITEM\\s*1A\\b\",\n",
" r\"^\\s*ITEM\\s*2\\b\",\n",
" r\"^\\s*Item\\s*1A\\b\",\n",
" r\"^\\s*Item\\s*2\\b\",\n",
" ],\n",
" \"item_number\": \"1\",\n",
" },\n",
"}\n",
"\n",
"# 10-Q patterns differ from 10-K\n",
"SECTION_PATTERNS_10Q = {\n",
" \"risk_factors\": {\n",
" # In 10-Q, risk factor updates appear in Part II, Item 1A\n",
" # Line-anchored patterns with MULTILINE support (handled in extract_section)\n",
" \"start_patterns\": [\n",
" r\"^\\s*ITEM\\s*1A\\.?\\s*[-–—]?\\s*RISK\\s*FACTORS\",\n",
" r\"^\\s*Item\\s*1A\\.?\\s*[-–—]?\\s*Risk\\s*Factors\",\n",
" ],\n",
" \"end_patterns\": [\n",
" r\"^\\s*ITEM\\s*1B\\b\",\n",
" r\"^\\s*ITEM\\s*2\\b\",\n",
" r\"^\\s*Item\\s*2\\b\",\n",
" ],\n",
" \"item_number\": \"1A\",\n",
" # For 10-Q risk factors, prefer matches after PART II\n",
" \"require_after\": r\"PART\\s*II\",\n",
" },\n",
" \"mda\": {\n",
" # In 10-Q, MD&A is Part I, Item 2 (NOT Item 7)\n",
" # Handle both straight and curly apostrophes\n",
" \"start_patterns\": [\n",
" r\"^\\s*ITEM\\s*2\\.?\\s*[-–—]?\\s*MANAGEMENT(?:'|\\u2019)?S?\\s*DISCUSSION\",\n",
" r\"^\\s*Item\\s*2\\.?\\s*[-–—]?\\s*Management(?:'|\\u2019)?s?\\s*Discussion\",\n",
" ],\n",
" \"end_patterns\": [\n",
" r\"^\\s*ITEM\\s*3\\b\",\n",
" r\"^\\s*Item\\s*3\\b\",\n",
" ],\n",
" \"item_number\": \"2\",\n",
" },\n",
"}\n",
"\n",
"# Default to 10-K patterns (backward compatible)\n",
"SECTION_PATTERNS = SECTION_PATTERNS_10K\n",
"\n",
"\n",
"def get_patterns_for_form(form_type: str) -> dict:\n",
" \"\"\"Get appropriate section patterns based on filing type.\"\"\"\n",
" form_type_upper = form_type.upper() if form_type else \"10-K\"\n",
" if \"10-Q\" in form_type_upper:\n",
" return SECTION_PATTERNS_10Q\n",
" return SECTION_PATTERNS_10K"
]
},
{
"cell_type": "markdown",
"id": "c6b9fe4a",
"metadata": {
"lines_to_next_cell": 2,
"tags": []
},
"source": [
"## 2. Text Cleaning Functions\n",
"\n",
"Raw SEC filings contain HTML, special characters, and formatting that must be cleaned."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "7da03d4e",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.388405Z",
"iopub.status.busy": "2026-05-16T23:42:30.388225Z",
"iopub.status.idle": "2026-05-16T23:42:30.391214Z",
"shell.execute_reply": "2026-05-16T23:42:30.390834Z"
},
"tags": []
},
"outputs": [],
"source": [
"def clean_html(html_text: str, drop_tables: bool = False) -> str:\n",
" \"\"\"Remove HTML tags and convert to plain text while preserving paragraph boundaries.\"\"\"\n",
" if not html_text:\n",
" return \"\"\n",
"\n",
" soup = BeautifulSoup(html_text, \"html.parser\")\n",
"\n",
" # Remove script and style elements\n",
" for script in soup([\"script\", \"style\"]):\n",
" script.decompose()\n",
"\n",
" # Optionally remove tables (embedded financial tables in narrative sections)\n",
" if drop_tables:\n",
" for table in soup.find_all(\"table\"):\n",
" table.decompose()\n",
"\n",
" # Preserve paragraph boundaries by using newline separator\n",
" text = soup.get_text(separator=\"\\n\")\n",
"\n",
" return text"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "cc7ac3e0",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.394871Z",
"iopub.status.busy": "2026-05-16T23:42:30.394712Z",
"iopub.status.idle": "2026-05-16T23:42:30.397931Z",
"shell.execute_reply": "2026-05-16T23:42:30.397610Z"
},
"tags": []
},
"outputs": [],
"source": [
"def clean_text(text: str) -> str:\n",
" \"\"\"\n",
" Clean extracted text while preserving paragraph breaks.\n",
"\n",
" Removes common filing artifacts and normalizes whitespace without\n",
" flattening everything into a single line (preserves paragraph structure\n",
" for downstream chunking and change detection).\n",
" \"\"\"\n",
" if not text:\n",
" return \"\"\n",
"\n",
" # Normalize line endings\n",
" text = text.replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\")\n",
"\n",
" # Remove common filing artifacts (line-anchored for safety)\n",
" text = re.sub(r\"(?im)^\\s*table of contents\\s*$\", \"\", text)\n",
" text = re.sub(r\"(?im)^\\s*page\\s+\\d+\\s*$\", \"\", text)\n",
" text = re.sub(r\"(?im)^\\s*\\d+\\s*of\\s*\\d+\\s*$\", \"\", text)\n",
"\n",
" # Remove URLs\n",
" text = re.sub(r\"https?://\\S+\", \"\", text)\n",
"\n",
" # Remove excessive special characters but keep newlines\n",
" text = re.sub(r\"[_=\\-]{3,}\", \" \", text)\n",
" text = re.sub(r\"[•●◦▪]\", \" \", text)\n",
"\n",
" # Normalize spaces within each line, but preserve line breaks\n",
" text = \"\\n\".join(re.sub(r\"[ \\t]+\", \" \", line).strip() for line in text.split(\"\\n\"))\n",
"\n",
" # Collapse excessive blank lines to paragraph breaks\n",
" text = re.sub(r\"\\n{3,}\", \"\\n\\n\", text).strip()\n",
"\n",
" return text"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "d33bc058",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.401595Z",
"iopub.status.busy": "2026-05-16T23:42:30.401460Z",
"iopub.status.idle": "2026-05-16T23:42:30.407030Z",
"shell.execute_reply": "2026-05-16T23:42:30.406614Z"
},
"tags": []
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Sample cleaning:\n",
"Original HTML length: 228\n",
"Cleaned text: ITEM 1A. RISK FACTORS\n",
"\n",
"The following risks could\n",
"materially\n",
"affect our business:\n",
"\n",
"Competition may increase\n",
"\n",
"Economic conditions may deteriorate\n",
"\n",
"Page 15 of 120\n"
]
}
],
"source": [
"def normalize_for_analysis(text: str) -> str:\n",
" \"\"\"\n",
" Normalize text for NLP analysis.\n",
"\n",
" Converts to lowercase, removes numbers (optional),\n",
" and standardizes formatting.\n",
" \"\"\"\n",
" if not text:\n",
" return \"\"\n",
"\n",
" # Convert to lowercase\n",
" text = text.lower()\n",
"\n",
" # Standardize common abbreviations\n",
" text = re.sub(r\"\\bu\\.s\\.?\\b\", \"united states\", text)\n",
" text = re.sub(r\"\\be\\.g\\.?\\b\", \"for example\", text)\n",
" text = re.sub(r\"\\bi\\.e\\.?\\b\", \"that is\", text)\n",
"\n",
" # Remove possessive 's\n",
" text = re.sub(r\"'s\\b\", \"\", text)\n",
"\n",
" # Remove numbers (optional - depends on use case)\n",
" # text = re.sub(r\"\\d+\", \"\", text)\n",
"\n",
" return text\n",
"\n",
"\n",
"# Test cleaning\n",
"sample_html = \"\"\"\n",
"<p>ITEM 1A. RISK FACTORS</p>\n",
"<p>The following risks could <b>materially</b> affect our business:</p>\n",
"<p>• Competition may increase</p>\n",
"<p>• Economic conditions may deteriorate</p>\n",
"<p>Page 15 of 120</p>\n",
"<p>Table of Contents</p>\n",
"\"\"\"\n",
"\n",
"cleaned = clean_text(clean_html(sample_html))\n",
"print(\"Sample cleaning:\")\n",
"print(f\"Original HTML length: {len(sample_html)}\")\n",
"print(f\"Cleaned text: {cleaned}\")"
]
},
{
"cell_type": "markdown",
"id": "b1c3e33b",
"metadata": {
"lines_to_next_cell": 2,
"tags": []
},
"source": [
"## 3. Section Extraction"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "69aba95d",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.417832Z",
"iopub.status.busy": "2026-05-16T23:42:30.417637Z",
"iopub.status.idle": "2026-05-16T23:42:30.428151Z",
"shell.execute_reply": "2026-05-16T23:42:30.427688Z"
},
"tags": []
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"BUSINESS (728 chars):\n",
"Our company is a leading provider of technology solutions for enterprise\n",
"customers worldwide. We design, manufacture, and support a portfolio of\n",
"hardware, software, and cloud services that help organi...\n",
"\n",
"RISK_FACTORS (541 chars):\n",
"An investment in our securities involves a high degree of risk.\n",
"You should carefully consider the following risk factors:\n",
"\n",
"COMPETITION RISKS\n",
"We face significant competition from larger companies with ...\n",
"\n",
"MDA (424 chars):\n",
"AND ANALYSIS OF FINANCIAL CONDITION\n",
"\n",
"Overview\n",
"The past year has been transformative for our company.\n",
"Revenue increased 25% driven by strong product adoption.\n",
"\n",
"Results of Operations\n",
"Our gross margin im...\n",
"\n"
]
}
],
"source": [
"def extract_section(text: str, section_name: str, patterns: dict = None) -> str | None:\n",
" \"\"\"\n",
" Extract a specific section from a 10-K/10-Q filing using anchored item boundaries.\n",
"\n",
" Robust to TOC duplicates by finding all start_pattern matches and\n",
" picking the candidate (start, end) span with the most content\n",
" between it and the next end_pattern. Inline references inside\n",
" narrative text are filtered out by the same span-length test —\n",
" they don't have a matching end-pattern Item N+1 immediately after.\n",
"\n",
" Parameters\n",
" ----------\n",
" text : str\n",
" Full filing text\n",
" section_name : str\n",
" Name of section to extract (e.g., 'risk_factors', 'mda')\n",
" patterns : dict\n",
" Custom patterns, or use SECTION_PATTERNS\n",
"\n",
" Returns\n",
" -------\n",
" str or None\n",
" Extracted section text, or None if no candidate has substantive content.\n",
" \"\"\"\n",
" if not text:\n",
" return None\n",
"\n",
" if patterns is None:\n",
" patterns = SECTION_PATTERNS\n",
"\n",
" if section_name not in patterns:\n",
" raise ValueError(f\"Unknown section: {section_name}\")\n",
"\n",
" config = patterns[section_name]\n",
" flags = re.IGNORECASE | re.MULTILINE | re.DOTALL\n",
"\n",
" # Some patterns may require matching after a specific marker\n",
" # (e.g., PART II for 10-Q risk factors).\n",
" offset = 0\n",
" search_text = text\n",
" require_after = config.get(\"require_after\")\n",
" if require_after:\n",
" after_match = re.search(require_after, search_text, flags)\n",
" if after_match:\n",
" offset = after_match.start()\n",
" search_text = search_text[after_match.start() :]\n",
"\n",
" # Collect every candidate start position from every start pattern.\n",
" starts: list[int] = []\n",
" for pattern in config[\"start_patterns\"]:\n",
" starts.extend(offset + m.end() for m in re.finditer(pattern, search_text, flags))\n",
" if not starts:\n",
" return None\n",
" starts = sorted(set(starts))\n",
"\n",
" # For each candidate start, find the next end_pattern match and\n",
" # measure the span. The real section beats every TOC entry on\n",
" # span length because TOC entries are followed by the next TOC\n",
" # entry within a few dozen characters.\n",
" best_span: tuple[int, int] | None = None\n",
" for start_pos in starts:\n",
" end_pos = len(text)\n",
" for end_pattern in config[\"end_patterns\"]:\n",
" end_match = re.search(end_pattern, text[start_pos:], flags)\n",
" if end_match:\n",
" end_pos = start_pos + end_match.start()\n",
" break\n",
" span = end_pos - start_pos\n",
" if best_span is None or span > (best_span[1] - best_span[0]):\n",
" best_span = (start_pos, end_pos)\n",
"\n",
" if best_span is None or (best_span[1] - best_span[0]) < 200:\n",
" return None\n",
"\n",
" return clean_text(text[best_span[0] : best_span[1]])\n",
"\n",
"\n",
"# Create sample 10-K text for demonstration\n",
"sample_10k = \"\"\"\n",
"FORM 10-K\n",
"ANNUAL REPORT\n",
"\n",
"Table of Contents\n",
"\n",
"PART I\n",
"\n",
"ITEM 1. BUSINESS\n",
"\n",
"Our company is a leading provider of technology solutions for enterprise\n",
"customers worldwide. We design, manufacture, and support a portfolio of\n",
"hardware, software, and cloud services that help organizations modernize\n",
"their data infrastructure.\n",
"\n",
"We operate across three reportable segments — Cloud Platform, Enterprise\n",
"Software, and Professional Services — and sell directly through a global\n",
"sales force as well as a network of channel partners. Our customers span\n",
"financial services, healthcare, public sector, and manufacturing.\n",
"\n",
"Recent strategic initiatives include expanding our hyperscale data center\n",
"footprint, deepening integrations with leading public cloud providers,\n",
"and broadening our generative-AI product portfolio.\n",
"\n",
"ITEM 1A. RISK FACTORS\n",
"\n",
"An investment in our securities involves a high degree of risk.\n",
"You should carefully consider the following risk factors:\n",
"\n",
"COMPETITION RISKS\n",
"We face significant competition from larger companies with more resources.\n",
"New entrants may disrupt our market position.\n",
"\n",
"ECONOMIC RISKS\n",
"Economic downturns could reduce customer spending on our products.\n",
"Currency fluctuations may impact our international revenue.\n",
"\n",
"REGULATORY RISKS\n",
"Changes in regulations could increase our compliance costs.\n",
"Data privacy laws continue to evolve across jurisdictions.\n",
"\n",
"ITEM 1B. UNRESOLVED STAFF COMMENTS\n",
"\n",
"None.\n",
"\n",
"ITEM 2. PROPERTIES\n",
"\n",
"We lease our headquarters in San Francisco, California.\n",
"\n",
"PART II\n",
"\n",
"ITEM 7. MANAGEMENT'S DISCUSSION AND ANALYSIS OF FINANCIAL CONDITION\n",
"\n",
"Overview\n",
"The past year has been transformative for our company.\n",
"Revenue increased 25% driven by strong product adoption.\n",
"\n",
"Results of Operations\n",
"Our gross margin improved to 72% from 68% in the prior year.\n",
"Operating expenses remained well controlled.\n",
"\n",
"Liquidity and Capital Resources\n",
"We ended the year with $500 million in cash.\n",
"We believe current resources are sufficient for operations.\n",
"\n",
"ITEM 7A. QUANTITATIVE AND QUALITATIVE DISCLOSURES ABOUT MARKET RISK\n",
"\n",
"We are exposed to interest rate and foreign currency risks.\n",
"\n",
"ITEM 8. FINANCIAL STATEMENTS\n",
"\"\"\"\n",
"\n",
"for section in [\"business\", \"risk_factors\", \"mda\"]:\n",
" extracted = extract_section(sample_10k, section)\n",
" if extracted:\n",
" preview = extracted[:200] + \"...\" if len(extracted) > 200 else extracted\n",
" print(f\"{section.upper()} ({len(extracted)} chars):\")\n",
" print(preview)\n",
" print()"
]
},
{
"cell_type": "markdown",
"id": "438d5831",
"metadata": {
"lines_to_next_cell": 2,
"tags": []
},
"source": [
"## 4. Building a Text Dataset"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "0b5c7317",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.438564Z",
"iopub.status.busy": "2026-05-16T23:42:30.438490Z",
"iopub.status.idle": "2026-05-16T23:42:30.445368Z",
"shell.execute_reply": "2026-05-16T23:42:30.444964Z"
},
"tags": []
},
"outputs": [
{
"data": {
"text/html": [
"<div><style>\n",
".dataframe > thead > tr,\n",
".dataframe > tbody > tr {\n",
" text-align: right;\n",
" white-space: pre-wrap;\n",
"}\n",
"</style>\n",
"<small>shape: (4, 11)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>cik</th><th>company_name</th><th>accession_no</th><th>filing_type</th><th>filing_date</th><th>accepted_at</th><th>period_end</th><th>section</th><th>text</th><th>text_length</th><th>word_count</th></tr><tr><td>str</td><td>str</td><td>str</td><td>str</td><td>datetime[μs]</td><td>datetime[μs]</td><td>datetime[μs]</td><td>str</td><td>str</td><td>i64</td><td>i64</td></tr></thead><tbody><tr><td>&quot;0000320193&quot;</td><td>&quot;Apple Inc.&quot;</td><td>&quot;0000320193-24-000081&quot;</td><td>&quot;10-K&quot;</td><td>2024-10-31 00:00:00</td><td>2024-10-31 16:35:12</td><td>2024-09-28 00:00:00</td><td>&quot;risk_factors&quot;</td><td>&quot;An investment in our securitie…</td><td>541</td><td>74</td></tr><tr><td>&quot;0000320193&quot;</td><td>&quot;Apple Inc.&quot;</td><td>&quot;0000320193-24-000081&quot;</td><td>&quot;10-K&quot;</td><td>2024-10-31 00:00:00</td><td>2024-10-31 16:35:12</td><td>2024-09-28 00:00:00</td><td>&quot;mda&quot;</td><td>&quot;AND ANALYSIS OF FINANCIAL COND…</td><td>424</td><td>64</td></tr><tr><td>&quot;0000789019&quot;</td><td>&quot;Microsoft Corporation&quot;</td><td>&quot;0000789019-24-000042&quot;</td><td>&quot;10-K&quot;</td><td>2024-07-30 00:00:00</td><td>2024-07-30 16:05:45</td><td>2024-06-30 00:00:00</td><td>&quot;risk_factors&quot;</td><td>&quot;An investment in our securitie…</td><td>541</td><td>74</td></tr><tr><td>&quot;0000789019&quot;</td><td>&quot;Microsoft Corporation&quot;</td><td>&quot;0000789019-24-000042&quot;</td><td>&quot;10-K&quot;</td><td>2024-07-30 00:00:00</td><td>2024-07-30 16:05:45</td><td>2024-06-30 00:00:00</td><td>&quot;mda&quot;</td><td>&quot;AND ANALYSIS OF FINANCIAL COND…</td><td>424</td><td>64</td></tr></tbody></table></div>"
],
"text/plain": [
"shape: (4, 11)\n",
"┌───────────┬───────────┬───────────┬───────────┬───┬───────────┬───────────┬───────────┬──────────┐\n",
"│ cik ┆ company_n ┆ accession ┆ filing_ty ┆ … ┆ section ┆ text ┆ text_leng ┆ word_cou │\n",
"│ --- ┆ ame ┆ _no ┆ pe ┆ ┆ --- ┆ --- ┆ th ┆ nt │\n",
"│ str ┆ --- ┆ --- ┆ --- ┆ ┆ str ┆ str ┆ --- ┆ --- │\n",
"│ ┆ str ┆ str ┆ str ┆ ┆ ┆ ┆ i64 ┆ i64 │\n",
"╞═══════════╪═══════════╪═══════════╪═══════════╪═══╪═══════════╪═══════════╪═══════════╪══════════╡\n",
"│ 000032019 ┆ Apple ┆ 000032019 ┆ 10-K ┆ … ┆ risk_fact ┆ An invest ┆ 541 ┆ 74 │\n",
"│ 3 ┆ Inc. ┆ 3-24-0000 ┆ ┆ ┆ ors ┆ ment in ┆ ┆ │\n",
"│ ┆ ┆ 81 ┆ ┆ ┆ ┆ our secur ┆ ┆ │\n",
"│ ┆ ┆ ┆ ┆ ┆ ┆ itie… ┆ ┆ │\n",
"│ 000032019 ┆ Apple ┆ 000032019 ┆ 10-K ┆ … ┆ mda ┆ AND ┆ 424 ┆ 64 │\n",
"│ 3 ┆ Inc. ┆ 3-24-0000 ┆ ┆ ┆ ┆ ANALYSIS ┆ ┆ │\n",
"│ ┆ ┆ 81 ┆ ┆ ┆ ┆ OF ┆ ┆ │\n",
"│ ┆ ┆ ┆ ┆ ┆ ┆ FINANCIAL ┆ ┆ │\n",
"│ ┆ ┆ ┆ ┆ ┆ ┆ COND… ┆ ┆ │\n",
"│ 000078901 ┆ Microsoft ┆ 000078901 ┆ 10-K ┆ … ┆ risk_fact ┆ An invest ┆ 541 ┆ 74 │\n",
"│ 9 ┆ Corporati ┆ 9-24-0000 ┆ ┆ ┆ ors ┆ ment in ┆ ┆ │\n",
"│ ┆ on ┆ 42 ┆ ┆ ┆ ┆ our secur ┆ ┆ │\n",
"│ ┆ ┆ ┆ ┆ ┆ ┆ itie… ┆ ┆ │\n",
"│ 000078901 ┆ Microsoft ┆ 000078901 ┆ 10-K ┆ … ┆ mda ┆ AND ┆ 424 ┆ 64 │\n",
"│ 9 ┆ Corporati ┆ 9-24-0000 ┆ ┆ ┆ ┆ ANALYSIS ┆ ┆ │\n",
"│ ┆ on ┆ 42 ┆ ┆ ┆ ┆ OF ┆ ┆ │\n",
"│ ┆ ┆ ┆ ┆ ┆ ┆ FINANCIAL ┆ ┆ │\n",
"│ ┆ ┆ ┆ ┆ ┆ ┆ COND… ┆ ┆ │\n",
"└───────────┴───────────┴───────────┴───────────┴───┴───────────┴───────────┴───────────┴──────────┘"
]
},
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"_DEFAULT_SECTIONS = (\"risk_factors\", \"mda\")\n",
"\n",
"\n",
"def create_text_dataset(\n",
" filings: list[dict], sections: list[str] = _DEFAULT_SECTIONS\n",
") -> pl.DataFrame:\n",
" \"\"\"\n",
" Create a structured dataset from multiple filings.\n",
"\n",
" Parameters\n",
" ----------\n",
" filings : list of dict\n",
" Each dict should contain:\n",
" - cik: Company CIK\n",
" - company_name: Company name\n",
" - accession_no: EDGAR accession number (stable filing identifier)\n",
" - filing_date: Filing date\n",
" - accepted_at: SEC acceptance timestamp (for intraday PIT)\n",
" - period_end: Reporting period end date\n",
" - filing_type: 10-K, 10-Q, etc.\n",
" - text: Raw filing text\n",
" sections : list of str\n",
" Sections to extract\n",
"\n",
" Returns\n",
" -------\n",
" pl.DataFrame\n",
" Structured text dataset with PIT-correct schema\n",
"\n",
" Note\n",
" ----\n",
" Automatically uses correct section patterns based on filing_type (10-K vs 10-Q).\n",
" In 10-Q, MD&A is Item 2 (not Item 7 as in 10-K).\n",
" \"\"\"\n",
" records = []\n",
"\n",
" for filing in filings:\n",
" filing_type = filing.get(\"filing_type\", \"10-K\")\n",
" base_record = {\n",
" \"cik\": filing.get(\"cik\"),\n",
" \"company_name\": filing.get(\"company_name\"),\n",
" # Stable filing identifier (prefer over cik + filing_date)\n",
" \"accession_no\": filing.get(\"accession_no\"),\n",
" \"filing_type\": filing_type,\n",
" # PIT timestamps\n",
" \"filing_date\": filing.get(\"filing_date\"),\n",
" \"accepted_at\": filing.get(\"accepted_at\"), # Intraday PIT\n",
" \"period_end\": filing.get(\"period_end\"),\n",
" }\n",
"\n",
" text = filing.get(\"text\", \"\")\n",
"\n",
" # Use form-type-aware patterns\n",
" patterns = get_patterns_for_form(filing_type)\n",
"\n",
" for section in sections:\n",
" # Skip sections not defined for this form type\n",
" if section not in patterns:\n",
" continue\n",
"\n",
" extracted = extract_section(text, section, patterns)\n",
"\n",
" record = {\n",
" **base_record,\n",
" \"section\": section,\n",
" \"text\": extracted or \"\",\n",
" \"text_length\": len(extracted) if extracted else 0,\n",
" \"word_count\": len(extracted.split()) if extracted else 0,\n",
" }\n",
" records.append(record)\n",
"\n",
" return pl.DataFrame(records)\n",
"\n",
"\n",
"# Create sample filings with PIT-correct schema\n",
"sample_filings = [\n",
" {\n",
" \"cik\": \"0000320193\",\n",
" \"company_name\": \"Apple Inc.\",\n",
" \"accession_no\": \"0000320193-24-000081\",\n",
" \"filing_date\": datetime(2024, 10, 31),\n",
" \"accepted_at\": datetime(2024, 10, 31, 16, 35, 12), # SEC acceptance timestamp\n",
" \"period_end\": datetime(2024, 9, 28),\n",
" \"filing_type\": \"10-K\",\n",
" \"text\": sample_10k,\n",
" },\n",
" {\n",
" \"cik\": \"0000789019\",\n",
" \"company_name\": \"Microsoft Corporation\",\n",
" \"accession_no\": \"0000789019-24-000042\",\n",
" \"filing_date\": datetime(2024, 7, 30),\n",
" \"accepted_at\": datetime(2024, 7, 30, 16, 5, 45),\n",
" \"period_end\": datetime(2024, 6, 30),\n",
" \"filing_type\": \"10-K\",\n",
" \"text\": sample_10k, # Using same sample for demo\n",
" },\n",
"]\n",
"\n",
"text_dataset = create_text_dataset(sample_filings)\n",
"text_dataset"
]
},
{
"cell_type": "markdown",
"id": "e4039d22",
"metadata": {
"lines_to_next_cell": 2,
"tags": []
},
"source": [
"## 5. Text Statistics and Quality Checks"
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "947f2307",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.452191Z",
"iopub.status.busy": "2026-05-16T23:42:30.452111Z",
"iopub.status.idle": "2026-05-16T23:42:30.454589Z",
"shell.execute_reply": "2026-05-16T23:42:30.454283Z"
},
"lines_to_next_cell": 2,
"tags": []
},
"outputs": [],
"source": [
"def calculate_text_statistics(text: str) -> dict:\n",
" \"\"\"Calculate statistics about extracted text.\"\"\"\n",
" if not text:\n",
" return {\n",
" \"word_count\": 0,\n",
" \"sentence_count\": 0,\n",
" \"avg_word_length\": 0,\n",
" \"avg_sentence_length\": 0,\n",
" \"unique_words\": 0,\n",
" \"lexical_diversity\": 0,\n",
" }\n",
"\n",
" words = text.lower().split()\n",
" sentences = re.split(r\"[.!?]+\", text)\n",
" sentences = [s.strip() for s in sentences if s.strip()]\n",
"\n",
" word_count = len(words)\n",
" unique_words = len(set(words))\n",
"\n",
" return {\n",
" \"word_count\": word_count,\n",
" \"sentence_count\": len(sentences),\n",
" \"avg_word_length\": np.mean([len(w) for w in words]) if words else 0,\n",
" \"avg_sentence_length\": word_count / len(sentences) if sentences else 0,\n",
" \"unique_words\": unique_words,\n",
" \"lexical_diversity\": unique_words / word_count if word_count > 0 else 0,\n",
" }"
]
},
{
"cell_type": "markdown",
"id": "e495ed4c",
"metadata": {
"lines_to_next_cell": 2,
"tags": []
},
"source": [
"### Quality Check Extraction\n",
"Flag potential issues: missing sections, truncated extractions, or boundary contamination."
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "a7534900",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.460452Z",
"iopub.status.busy": "2026-05-16T23:42:30.460386Z",
"iopub.status.idle": "2026-05-16T23:42:30.467099Z",
"shell.execute_reply": "2026-05-16T23:42:30.466816Z"
},
"tags": []
},
"outputs": [
{
"data": {
"text/html": [
"<div><style>\n",
".dataframe > thead > tr,\n",
".dataframe > tbody > tr {\n",
" text-align: right;\n",
" white-space: pre-wrap;\n",
"}\n",
"</style>\n",
"<small>shape: (4, 4)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>company_name</th><th>section</th><th>word_count</th><th>quality_flag</th></tr><tr><td>str</td><td>str</td><td>i64</td><td>str</td></tr></thead><tbody><tr><td>&quot;Apple Inc.&quot;</td><td>&quot;risk_factors&quot;</td><td>74</td><td>&quot;too_short&quot;</td></tr><tr><td>&quot;Apple Inc.&quot;</td><td>&quot;mda&quot;</td><td>64</td><td>&quot;too_short&quot;</td></tr><tr><td>&quot;Microsoft Corporation&quot;</td><td>&quot;risk_factors&quot;</td><td>74</td><td>&quot;too_short&quot;</td></tr><tr><td>&quot;Microsoft Corporation&quot;</td><td>&quot;mda&quot;</td><td>64</td><td>&quot;too_short&quot;</td></tr></tbody></table></div>"
],
"text/plain": [
"shape: (4, 4)\n",
"┌───────────────────────┬──────────────┬────────────┬──────────────┐\n",
"│ company_name ┆ section ┆ word_count ┆ quality_flag │\n",
"│ --- ┆ --- ┆ --- ┆ --- │\n",
"│ str ┆ str ┆ i64 ┆ str │\n",
"╞═══════════════════════╪══════════════╪════════════╪══════════════╡\n",
"│ Apple Inc. ┆ risk_factors ┆ 74 ┆ too_short │\n",
"│ Apple Inc. ┆ mda ┆ 64 ┆ too_short │\n",
"│ Microsoft Corporation ┆ risk_factors ┆ 74 ┆ too_short │\n",
"│ Microsoft Corporation ┆ mda ┆ 64 ┆ too_short │\n",
"└───────────────────────┴──────────────┴────────────┴──────────────┘"
]
},
"execution_count": 10,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"def quality_check_extraction(df: pl.DataFrame, min_words: int = 100) -> pl.DataFrame:\n",
" \"\"\"\n",
" Check quality of extracted text.\n",
"\n",
" Flags potential issues:\n",
" - Missing (extraction returned nothing)\n",
" - Too short (extraction may have failed partially)\n",
" - Too long (may have captured extra sections)\n",
" - Boundary leak (captured content from next section)\n",
" \"\"\"\n",
" # Add quality flags - NOTE: order matters! Check missing first since 0 < min_words\n",
" result = df.with_columns(\n",
" [\n",
" pl.when(pl.col(\"word_count\") == 0)\n",
" .then(pl.lit(\"missing\"))\n",
" .when(pl.col(\"word_count\") < min_words)\n",
" .then(pl.lit(\"too_short\"))\n",
" .when(pl.col(\"word_count\") > 50000)\n",
" .then(pl.lit(\"suspiciously_long\"))\n",
" .otherwise(pl.lit(\"ok\"))\n",
" .alias(\"quality_flag\")\n",
" ]\n",
" )\n",
"\n",
" # Check for boundary contamination (e.g., \"Item 8\" appearing in MD&A)\n",
" # This indicates the end boundary wasn't properly detected\n",
" result = result.with_columns(\n",
" pl.when((pl.col(\"section\") == \"mda\") & pl.col(\"text\").str.contains(r\"(?i)\\bITEM\\s*8\\b\"))\n",
" .then(pl.lit(True))\n",
" .when(\n",
" (pl.col(\"section\") == \"risk_factors\")\n",
" & pl.col(\"text\").str.contains(r\"(?i)\\bITEM\\s*2\\b.*\\bPROPERTIES\\b\")\n",
" )\n",
" .then(pl.lit(True))\n",
" .otherwise(pl.lit(False))\n",
" .alias(\"possible_boundary_leak\")\n",
" )\n",
"\n",
" return result\n",
"\n",
"\n",
"text_dataset_checked = quality_check_extraction(text_dataset)\n",
"text_dataset_checked.select([\"company_name\", \"section\", \"word_count\", \"quality_flag\"])"
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "b0e04cb7",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.470857Z",
"iopub.status.busy": "2026-05-16T23:42:30.470784Z",
"iopub.status.idle": "2026-05-16T23:42:30.473273Z",
"shell.execute_reply": "2026-05-16T23:42:30.472983Z"
},
"tags": []
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Text Statistics (Risk Factors section):\n",
" word_count: 74\n",
" sentence_count: 7\n",
" avg_word_length: 6.28\n",
" avg_sentence_length: 10.57\n",
" unique_words: 63\n",
" lexical_diversity: 0.85\n"
]
}
],
"source": [
"sample_text = text_dataset.filter(\n",
" (pl.col(\"section\") == \"risk_factors\") & (pl.col(\"word_count\") > 0)\n",
")[\"text\"][0]\n",
"\n",
"stats = calculate_text_statistics(sample_text)\n",
"print(\"Text Statistics (Risk Factors section):\")\n",
"for k, v in stats.items():\n",
" print(f\" {k}: {v:.2f}\" if isinstance(v, float) else f\" {k}: {v}\")"
]
},
{
"cell_type": "markdown",
"id": "978f5052",
"metadata": {
"lines_to_next_cell": 2,
"tags": []
},
"source": [
"## 6. Change Analysis Over Time\n",
"\n",
"Track how MD&A and Risk Factors change between filings."
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "2bdd5191",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.479497Z",
"iopub.status.busy": "2026-05-16T23:42:30.479428Z",
"iopub.status.idle": "2026-05-16T23:42:30.481688Z",
"shell.execute_reply": "2026-05-16T23:42:30.481364Z"
},
"tags": []
},
"outputs": [],
"source": [
"def calculate_text_similarity(text1: str, text2: str) -> dict:\n",
" \"\"\"\n",
" Calculate similarity between two text documents.\n",
"\n",
" Uses simple word overlap metrics (Jaccard similarity).\n",
" For production, consider using TF-IDF or embeddings.\n",
" \"\"\"\n",
" if not text1 or not text2:\n",
" return {\"jaccard\": 0, \"word_overlap_pct\": 0}\n",
"\n",
" words1 = set(text1.lower().split())\n",
" words2 = set(text2.lower().split())\n",
"\n",
" intersection = words1 & words2\n",
" union = words1 | words2\n",
"\n",
" jaccard = len(intersection) / len(union) if union else 0\n",
" overlap_pct = len(intersection) / len(words1) if words1 else 0\n",
"\n",
" return {\n",
" \"jaccard_similarity\": jaccard,\n",
" \"word_overlap_pct\": overlap_pct,\n",
" \"new_words\": len(words2 - words1),\n",
" \"removed_words\": len(words1 - words2),\n",
" }"
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "9af7bb68",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.485135Z",
"iopub.status.busy": "2026-05-16T23:42:30.485073Z",
"iopub.status.idle": "2026-05-16T23:42:30.487421Z",
"shell.execute_reply": "2026-05-16T23:42:30.487100Z"
},
"tags": []
},
"outputs": [],
"source": [
"def extract_new_risk_factors(old_text: str, new_text: str) -> list[str]:\n",
" \"\"\"\n",
" Identify potentially new risk factors by finding new paragraphs.\n",
"\n",
" Simple heuristic: paragraphs in new filing not present in old.\n",
" \"\"\"\n",
" if not old_text or not new_text:\n",
" return []\n",
"\n",
" # Split into paragraphs\n",
" old_paragraphs = set(p.strip().lower() for p in old_text.split(\"\\n\\n\") if len(p.strip()) > 50)\n",
" new_paragraphs = [p.strip() for p in new_text.split(\"\\n\\n\") if len(p.strip()) > 50]\n",
"\n",
" new_risks = []\n",
" for para in new_paragraphs:\n",
" # Check if paragraph is substantially new (low similarity to all old paragraphs)\n",
" para_lower = para.lower()\n",
" if para_lower not in old_paragraphs:\n",
" # Simple check - could be improved with fuzzy matching\n",
" new_risks.append(para[:200] + \"...\" if len(para) > 200 else para)\n",
"\n",
" return new_risks[:5] # Return top 5 new items"
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "428d5bf0",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.490894Z",
"iopub.status.busy": "2026-05-16T23:42:30.490784Z",
"iopub.status.idle": "2026-05-16T23:42:30.493035Z",
"shell.execute_reply": "2026-05-16T23:42:30.492801Z"
},
"tags": []
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Similarity Metrics:\n",
" jaccard_similarity: 0.310\n",
" word_overlap_pct: 0.929\n",
" new_words: 28\n",
" removed_words: 1\n",
"\n",
"New Risk Factors Identified: 3\n",
" 1. COMPETITION RISKS\n",
"We face significant competition from larger companies with more resources....\n",
" 2. AI TECHNOLOGY RISKS\n",
"Rapid advances in artificial intelligence may disrupt our business model.\n",
"We may...\n",
" 3. CYBERSECURITY RISKS\n",
"We face increasing threats from sophisticated cyber attacks....\n"
]
}
],
"source": [
"# Simulate two versions of risk factors\n",
"old_risks = \"\"\"\n",
"COMPETITION RISKS\n",
"We face significant competition from larger companies.\n",
"\n",
"ECONOMIC RISKS\n",
"Economic downturns could reduce customer spending.\n",
"\"\"\"\n",
"\n",
"new_risks = \"\"\"\n",
"COMPETITION RISKS\n",
"We face significant competition from larger companies with more resources.\n",
"\n",
"ECONOMIC RISKS\n",
"Economic downturns could reduce customer spending.\n",
"\n",
"AI TECHNOLOGY RISKS\n",
"Rapid advances in artificial intelligence may disrupt our business model.\n",
"We may need to make significant investments to remain competitive.\n",
"\n",
"CYBERSECURITY RISKS\n",
"We face increasing threats from sophisticated cyber attacks.\n",
"\"\"\"\n",
"\n",
"similarity = calculate_text_similarity(old_risks, new_risks)\n",
"print(\"Similarity Metrics:\")\n",
"for k, v in similarity.items():\n",
" print(f\" {k}: {v:.3f}\" if isinstance(v, float) else f\" {k}: {v}\")\n",
"\n",
"new_items = extract_new_risk_factors(old_risks, new_risks)\n",
"print(f\"\\nNew Risk Factors Identified: {len(new_items)}\")\n",
"for i, item in enumerate(new_items, 1):\n",
" print(f\" {i}. {item[:100]}...\")"
]
},
{
"cell_type": "markdown",
"id": "a6cc7776",
"metadata": {
"lines_to_next_cell": 2,
"tags": []
},
"source": [
"## 7. Saving the Dataset"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "d783f693",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.499705Z",
"iopub.status.busy": "2026-05-16T23:42:30.499643Z",
"iopub.status.idle": "2026-05-16T23:42:30.501537Z",
"shell.execute_reply": "2026-05-16T23:42:30.501300Z"
},
"tags": []
},
"outputs": [],
"source": [
"def save_text_dataset(df: pl.DataFrame, output_path: str, format: str = \"parquet\") -> None:\n",
" \"\"\"\n",
" Save text dataset to file.\n",
"\n",
" Parameters\n",
" ----------\n",
" df : pl.DataFrame\n",
" Text dataset\n",
" output_path : str\n",
" Output file path\n",
" format : str\n",
" 'parquet' or 'csv'\n",
" \"\"\"\n",
" path = Path(output_path)\n",
" path.parent.mkdir(parents=True, exist_ok=True)\n",
"\n",
" if format == \"parquet\":\n",
" df.write_parquet(path)\n",
" elif format == \"csv\":\n",
" df.write_csv(path)\n",
" else:\n",
" raise ValueError(f\"Unknown format: {format}\")\n",
"\n",
" print(f\"Saved {len(df)} records to {path}\")"
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "6046edd1",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.508209Z",
"iopub.status.busy": "2026-05-16T23:42:30.508074Z",
"iopub.status.idle": "2026-05-16T23:42:30.509742Z",
"shell.execute_reply": "2026-05-16T23:42:30.509472Z"
},
"tags": []
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"To save dataset:\n",
" save_text_dataset(text_dataset, 'data/sec_text_data.parquet')\n"
]
}
],
"source": [
"# Example save (commented out to avoid file creation in demo)\n",
"# save_text_dataset(text_dataset, \"/tmp/sec_text_data.parquet\")\n",
"print(\"\\nTo save dataset:\")\n",
"print(\" save_text_dataset(text_dataset, 'data/sec_text_data.parquet')\")"
]
},
{
"cell_type": "markdown",
"id": "94c660be",
"metadata": {
"tags": []
},
"source": [
"## 8. End-to-End Run Against a Live EDGAR Filing\n",
"\n",
"The previous sections developed the extraction pipeline against an\n",
"inline sample 10-K. The same helpers run unchanged against a real\n",
"filing: `edgartools` fetches the latest AAPL 10-K from EDGAR, our\n",
"`extract_section` + `clean_text` + `calculate_text_statistics`\n",
"helpers process it, and the resulting per-section summary mirrors\n",
"what the synthetic example produced earlier in the notebook.\n",
"\n",
"The SEC requires a real User-Agent identity for every request and\n",
"blocks placeholder addresses. Set `EDGAR_IDENTITY` in your environment\n",
"to `\"Your Name your.email@domain.com\"` before running this section:\n",
"\n",
"```bash\n",
"export EDGAR_IDENTITY=\"Jane Doe jane@example.org\"\n",
"```\n",
"\n",
"The fetch is read-only, rate-limited by edgartools, and typically\n",
"completes in 13 seconds."
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "a51a61c0",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:30.521249Z",
"iopub.status.busy": "2026-05-16T23:42:30.521167Z",
"iopub.status.idle": "2026-05-16T23:42:31.209555Z",
"shell.execute_reply": "2026-05-16T23:42:31.208498Z"
},
"tags": []
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"AAPL 10-K: accession 0000320193-25-000079, filed 2025-10-31\n"
]
}
],
"source": [
"edgar_identity = os.environ.get(\"EDGAR_IDENTITY\")\n",
"if not edgar_identity:\n",
" raise RuntimeError(\n",
" \"EDGAR_IDENTITY environment variable is not set. The SEC requires a \"\n",
" \"real User-Agent (name + email) for every EDGAR request and blocks \"\n",
" \"placeholder addresses. Set it before running this section, e.g. \"\n",
" '`export EDGAR_IDENTITY=\"Jane Doe jane@example.org\"`.'\n",
" )\n",
"set_identity(edgar_identity)\n",
"\n",
"company = Company(EDGAR_TICKER)\n",
"latest_filing = company.get_filings(form=EDGAR_FORM).latest()\n",
"print(\n",
" f\"{EDGAR_TICKER} {EDGAR_FORM}: accession {latest_filing.accession_no}, filed {latest_filing.filing_date}\"\n",
")"
]
},
{
"cell_type": "markdown",
"id": "55712373",
"metadata": {
"tags": []
},
"source": [
"Fetch the filing text and run the section extractor with the\n",
"form-aware patterns. The two highest-value sections (Risk Factors\n",
"and MD&A) come back as plain strings — ready for `clean_text`,\n",
"tokenisation, or downstream NLP feature engineering."
]
},
{
"cell_type": "code",
"execution_count": 18,
"id": "67ce88e7",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-16T23:42:31.232014Z",
"iopub.status.busy": "2026-05-16T23:42:31.231910Z",
"iopub.status.idle": "2026-05-16T23:42:32.061206Z",
"shell.execute_reply": "2026-05-16T23:42:32.060845Z"
},
"tags": []
},
"outputs": [
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"></pre>\n"
],
"text/plain": []
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"text/html": [
"<div><style>\n",
".dataframe > thead > tr,\n",
".dataframe > tbody > tr {\n",
" text-align: right;\n",
" white-space: pre-wrap;\n",
"}\n",
"</style>\n",
"<small>shape: (3, 4)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>section</th><th>extracted</th><th>char_count</th><th>word_count</th></tr><tr><td>str</td><td>bool</td><td>i64</td><td>i64</td></tr></thead><tbody><tr><td>&quot;business&quot;</td><td>true</td><td>16043</td><td>2332</td></tr><tr><td>&quot;risk_factors&quot;</td><td>true</td><td>68195</td><td>9836</td></tr><tr><td>&quot;mda&quot;</td><td>true</td><td>18569</td><td>2762</td></tr></tbody></table></div>"
],
"text/plain": [
"shape: (3, 4)\n",
"┌──────────────┬───────────┬────────────┬────────────┐\n",
"│ section ┆ extracted ┆ char_count ┆ word_count │\n",
"│ --- ┆ --- ┆ --- ┆ --- │\n",
"│ str ┆ bool ┆ i64 ┆ i64 │\n",
"╞══════════════╪═══════════╪════════════╪════════════╡\n",
"│ business ┆ true ┆ 16043 ┆ 2332 │\n",
"│ risk_factors ┆ true ┆ 68195 ┆ 9836 │\n",
"│ mda ┆ true ┆ 18569 ┆ 2762 │\n",
"└──────────────┴───────────┴────────────┴────────────┘"
]
},
"execution_count": 18,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"real_text = latest_filing.text()\n",
"form_patterns = get_patterns_for_form(EDGAR_FORM)\n",
"\n",
"real_risk = extract_section(real_text, \"risk_factors\", form_patterns)\n",
"real_mda = extract_section(real_text, \"mda\", form_patterns)\n",
"real_business = extract_section(real_text, \"business\", form_patterns)\n",
"\n",
"real_stats = pl.DataFrame(\n",
" [\n",
" {\n",
" \"section\": name,\n",
" \"extracted\": text is not None,\n",
" \"char_count\": len(text) if text else 0,\n",
" \"word_count\": calculate_text_statistics(text)[\"word_count\"] if text else 0,\n",
" }\n",
" for name, text in [\n",
" (\"business\", real_business),\n",
" (\"risk_factors\", real_risk),\n",
" (\"mda\", real_mda),\n",
" ]\n",
" ]\n",
")\n",
"real_stats"
]
},
{
"cell_type": "markdown",
"id": "47ad6360",
"metadata": {
"tags": []
},
"source": [
"A successful run shows non-zero word counts on each row — the same\n",
"pipeline that produced the synthetic-text statistics in Section 5\n",
"works against a current SEC filing without code changes. From here,\n",
"the cleaned strings feed directly into the NLP feature pipeline in\n",
"Chapter 10."
]
},
{
"cell_type": "markdown",
"id": "64d4ba3e",
"metadata": {
"tags": []
},
"source": [
"## 9. Key Takeaways\n",
"\n",
"### Text Extraction Best Practices\n",
"\n",
"1. **Use line-anchored regex patterns for section boundaries**\n",
" - Item numbers are standardized (Item 1A, Item 7)\n",
" - But formatting varies - need multiple patterns with `^` anchoring\n",
" - Skip Table of Contents matches by searching after TOC marker\n",
"\n",
"2. **Clean thoroughly but preserve paragraph structure**\n",
" - Remove HTML tags and filing artifacts\n",
" - Normalize whitespace *within* lines, but keep paragraph breaks\n",
" - Paragraph structure enables change detection over time\n",
"\n",
"3. **Validate extraction quality**\n",
" - Check text length (too short = extraction failed)\n",
" - Check for boundary leaks (e.g., \"Item 8\" in MD&A)\n",
" - Track quality metrics for iterative rule refinement\n",
"\n",
"4. **Track point-in-time with stable identifiers**\n",
" - Use **accession number** as primary key (not cik + filing_date)\n",
" - Use **accepted_at** for intraday PIT correctness\n",
" - Keep both filing_date and period_end for different query patterns\n",
"\n",
"5. **Store efficiently with audit trail**\n",
" - Parquet format for large datasets\n",
" - Include metadata (CIK, accession, timestamps, section)\n",
" - Consider storing both raw and cleaned text for reproducibility\n",
"\n",
"### Most Valuable Sections\n",
"\n",
"| Section | Alpha Use Case |\n",
"|---------|----------------|\n",
"| **Risk Factors (1A)** | Sentiment analysis, risk themes, change detection |\n",
"| **MD&A (Item 7)** | Management sentiment, outlook changes |\n",
"| **Business (Item 1)** | Competitive position, strategy changes |\n",
"\n",
"### Forward Reference\n",
"\n",
"This structured text dataset is the input for NLP techniques covered in later chapters:\n",
"- Sentiment scoring (positive/negative language)\n",
"- Topic modeling (identify themes)\n",
"- Named entity recognition (extract companies, products)\n",
"- Text embeddings (semantic similarity)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.14.3"
},
"papermill": {
"default_parameters": {},
"duration": 3.284106,
"end_time": "2026-05-16T23:42:32.483343+00:00",
"environment_variables": {},
"exception": null,
"input_path": "04_fundamental_alternative_data/14_text_data_extraction.ipynb",
"output_path": "04_fundamental_alternative_data/14_text_data_extraction.ipynb",
"parameters": {},
"start_time": "2026-05-16T23:42:29.199237+00:00",
"version": "2.7.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}