trend-narrative 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trend_narrative-0.1.0/PKG-INFO +157 -0
- trend_narrative-0.1.0/README.md +138 -0
- trend_narrative-0.1.0/pyproject.toml +40 -0
- trend_narrative-0.1.0/setup.cfg +4 -0
- trend_narrative-0.1.0/tests/test_detector.py +162 -0
- trend_narrative-0.1.0/tests/test_extractor.py +121 -0
- trend_narrative-0.1.0/tests/test_narrative.py +231 -0
- trend_narrative-0.1.0/trend_narrative/__init__.py +42 -0
- trend_narrative-0.1.0/trend_narrative/detector.py +218 -0
- trend_narrative-0.1.0/trend_narrative/extractor.py +96 -0
- trend_narrative-0.1.0/trend_narrative/narrative.py +260 -0
- trend_narrative-0.1.0/trend_narrative.egg-info/PKG-INFO +157 -0
- trend_narrative-0.1.0/trend_narrative.egg-info/SOURCES.txt +14 -0
- trend_narrative-0.1.0/trend_narrative.egg-info/dependency_links.txt +1 -0
- trend_narrative-0.1.0/trend_narrative.egg-info/requires.txt +7 -0
- trend_narrative-0.1.0/trend_narrative.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trend-narrative
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Piecewise-linear trend detection and plain-English narrative generation for time-series data.
|
|
5
|
+
Author-email: Yuki Suzuki <yukinko.iwasaki@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: trend,narrative,time-series,piecewise,statistics
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: numpy>=1.24
|
|
14
|
+
Requires-Dist: scipy>=1.10
|
|
15
|
+
Requires-Dist: pwlf>=2.2
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# trend-narrative
|
|
21
|
+
|
|
22
|
+
A standalone Python package that combines **piecewise-linear trend detection** and **plain-English narrative generation** for time-series data.
|
|
23
|
+
|
|
24
|
+
Originally developed across two repos:
|
|
25
|
+
- Trend detection logic → `dime-worldbank/mega-boost` (`feature/add_trend_detection_model`)
|
|
26
|
+
- Narrative generation → `dime-worldbank/rpf-country-dash` (`enhancement/trend_narrative`)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install -e ".[dev]" # editable install with dev dependencies
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Dependencies: `numpy`, `scipy`, `pwlf`
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
### One-liner
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import numpy as np
|
|
46
|
+
from trend_narrative import generate_narrative
|
|
47
|
+
|
|
48
|
+
x = np.arange(2010, 2022, dtype=float)
|
|
49
|
+
y = np.array([100, 105, 112, 108, 115, 130, 125, 120, 118, 122, 135, 148], dtype=float)
|
|
50
|
+
|
|
51
|
+
result = generate_narrative(x, y, metric="total real expenditure")
|
|
52
|
+
print(result["narrative"])
|
|
53
|
+
# → "From 2010 to 2015, the total real expenditure showed an upward trend.
|
|
54
|
+
# Trend then shifted, reaching a peak in 2015 before reversing into a decline. ..."
|
|
55
|
+
print(f"CV: {result['cv_value']:.1f}%")
|
|
56
|
+
print(result["segments"])
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Step-by-step
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from trend_narrative import InsightExtractor, get_segment_narrative
|
|
63
|
+
|
|
64
|
+
extractor = InsightExtractor(x, y)
|
|
65
|
+
suite = extractor.extract_full_suite()
|
|
66
|
+
# suite = {"cv_value": 14.2, "segments": [...]}
|
|
67
|
+
|
|
68
|
+
narrative = get_segment_narrative(
|
|
69
|
+
suite["segments"],
|
|
70
|
+
cv_value=suite["cv_value"],
|
|
71
|
+
metric="health spending",
|
|
72
|
+
)
|
|
73
|
+
print(narrative)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## API reference
|
|
79
|
+
|
|
80
|
+
### `TrendDetector(max_segments=3, threshold=0.05)`
|
|
81
|
+
|
|
82
|
+
Fits a piecewise-linear model using BIC-optimised segment count and snaps
|
|
83
|
+
breakpoints to integer years / local extrema.
|
|
84
|
+
|
|
85
|
+
| Method | Returns | Description |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `extract_trend(x, y)` | `list[dict]` | Fit model; return per-segment stats |
|
|
88
|
+
| `fit_best_model(x, y)` | `pwlf model \| None` | Run both fitting passes |
|
|
89
|
+
| `calculate_bic(ssr, n, k)` | `float` | Static BIC helper |
|
|
90
|
+
|
|
91
|
+
Each segment dict contains: `start_year`, `end_year`, `start_value`,
|
|
92
|
+
`end_value`, `slope`, `p_value`.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### `InsightExtractor(x, y, detector=None)`
|
|
97
|
+
|
|
98
|
+
High-level facade combining volatility and trend detection.
|
|
99
|
+
|
|
100
|
+
| Method | Returns | Description |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `get_volatility()` | `float` | Coefficient of Variation (%) |
|
|
103
|
+
| `get_structural_segments()` | `list[dict]` | Delegates to `TrendDetector` |
|
|
104
|
+
| `extract_full_suite()` | `dict` | `{cv_value, segments}` |
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### `get_segment_narrative(segments, cv_value, metric="expenditure")`
|
|
109
|
+
|
|
110
|
+
Convert segment data into a multi-sentence English narrative.
|
|
111
|
+
|
|
112
|
+
- No segments + low CV → *"remained highly stable"*
|
|
113
|
+
- No segments + high CV → *"exhibited significant volatility"*
|
|
114
|
+
- Single segment → direction + % change sentence
|
|
115
|
+
- Multi-segment → transition phrases (peak / trough / continuation)
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### `consolidate_segments(segments)`
|
|
120
|
+
|
|
121
|
+
Merge consecutive segments sharing the same slope direction. Useful for
|
|
122
|
+
simplifying noisy multi-segment fits before narrative generation.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
### `millify(n)`
|
|
127
|
+
|
|
128
|
+
Format large numbers with suffix: `1_500_000 → "1.50 M"`.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Running tests
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pytest
|
|
136
|
+
# or with coverage:
|
|
137
|
+
pytest --cov=trend_narrative --cov-report=term-missing
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Project structure
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
trend-narrative/
|
|
146
|
+
├── trend_narrative/
|
|
147
|
+
│ ├── __init__.py # Public API
|
|
148
|
+
│ ├── detector.py # TrendDetector – piecewise-linear fitting
|
|
149
|
+
│ ├── extractor.py # InsightExtractor – volatility + trend facade
|
|
150
|
+
│ └── narrative.py # Narrative generation + millify helper
|
|
151
|
+
├── tests/
|
|
152
|
+
│ ├── test_detector.py
|
|
153
|
+
│ ├── test_extractor.py
|
|
154
|
+
│ └── test_narrative.py
|
|
155
|
+
├── pyproject.toml
|
|
156
|
+
└── README.md
|
|
157
|
+
```
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# trend-narrative
|
|
2
|
+
|
|
3
|
+
A standalone Python package that combines **piecewise-linear trend detection** and **plain-English narrative generation** for time-series data.
|
|
4
|
+
|
|
5
|
+
Originally developed across two repos:
|
|
6
|
+
- Trend detection logic → `dime-worldbank/mega-boost` (`feature/add_trend_detection_model`)
|
|
7
|
+
- Narrative generation → `dime-worldbank/rpf-country-dash` (`enhancement/trend_narrative`)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e ".[dev]" # editable install with dev dependencies
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Dependencies: `numpy`, `scipy`, `pwlf`
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
### One-liner
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import numpy as np
|
|
27
|
+
from trend_narrative import generate_narrative
|
|
28
|
+
|
|
29
|
+
x = np.arange(2010, 2022, dtype=float)
|
|
30
|
+
y = np.array([100, 105, 112, 108, 115, 130, 125, 120, 118, 122, 135, 148], dtype=float)
|
|
31
|
+
|
|
32
|
+
result = generate_narrative(x, y, metric="total real expenditure")
|
|
33
|
+
print(result["narrative"])
|
|
34
|
+
# → "From 2010 to 2015, the total real expenditure showed an upward trend.
|
|
35
|
+
# Trend then shifted, reaching a peak in 2015 before reversing into a decline. ..."
|
|
36
|
+
print(f"CV: {result['cv_value']:.1f}%")
|
|
37
|
+
print(result["segments"])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Step-by-step
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from trend_narrative import InsightExtractor, get_segment_narrative
|
|
44
|
+
|
|
45
|
+
extractor = InsightExtractor(x, y)
|
|
46
|
+
suite = extractor.extract_full_suite()
|
|
47
|
+
# suite = {"cv_value": 14.2, "segments": [...]}
|
|
48
|
+
|
|
49
|
+
narrative = get_segment_narrative(
|
|
50
|
+
suite["segments"],
|
|
51
|
+
cv_value=suite["cv_value"],
|
|
52
|
+
metric="health spending",
|
|
53
|
+
)
|
|
54
|
+
print(narrative)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## API reference
|
|
60
|
+
|
|
61
|
+
### `TrendDetector(max_segments=3, threshold=0.05)`
|
|
62
|
+
|
|
63
|
+
Fits a piecewise-linear model using BIC-optimised segment count and snaps
|
|
64
|
+
breakpoints to integer years / local extrema.
|
|
65
|
+
|
|
66
|
+
| Method | Returns | Description |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `extract_trend(x, y)` | `list[dict]` | Fit model; return per-segment stats |
|
|
69
|
+
| `fit_best_model(x, y)` | `pwlf model \| None` | Run both fitting passes |
|
|
70
|
+
| `calculate_bic(ssr, n, k)` | `float` | Static BIC helper |
|
|
71
|
+
|
|
72
|
+
Each segment dict contains: `start_year`, `end_year`, `start_value`,
|
|
73
|
+
`end_value`, `slope`, `p_value`.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
### `InsightExtractor(x, y, detector=None)`
|
|
78
|
+
|
|
79
|
+
High-level facade combining volatility and trend detection.
|
|
80
|
+
|
|
81
|
+
| Method | Returns | Description |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `get_volatility()` | `float` | Coefficient of Variation (%) |
|
|
84
|
+
| `get_structural_segments()` | `list[dict]` | Delegates to `TrendDetector` |
|
|
85
|
+
| `extract_full_suite()` | `dict` | `{cv_value, segments}` |
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
### `get_segment_narrative(segments, cv_value, metric="expenditure")`
|
|
90
|
+
|
|
91
|
+
Convert segment data into a multi-sentence English narrative.
|
|
92
|
+
|
|
93
|
+
- No segments + low CV → *"remained highly stable"*
|
|
94
|
+
- No segments + high CV → *"exhibited significant volatility"*
|
|
95
|
+
- Single segment → direction + % change sentence
|
|
96
|
+
- Multi-segment → transition phrases (peak / trough / continuation)
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### `consolidate_segments(segments)`
|
|
101
|
+
|
|
102
|
+
Merge consecutive segments sharing the same slope direction. Useful for
|
|
103
|
+
simplifying noisy multi-segment fits before narrative generation.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
### `millify(n)`
|
|
108
|
+
|
|
109
|
+
Format large numbers with suffix: `1_500_000 → "1.50 M"`.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Running tests
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pytest
|
|
117
|
+
# or with coverage:
|
|
118
|
+
pytest --cov=trend_narrative --cov-report=term-missing
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Project structure
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
trend-narrative/
|
|
127
|
+
├── trend_narrative/
|
|
128
|
+
│ ├── __init__.py # Public API
|
|
129
|
+
│ ├── detector.py # TrendDetector – piecewise-linear fitting
|
|
130
|
+
│ ├── extractor.py # InsightExtractor – volatility + trend facade
|
|
131
|
+
│ └── narrative.py # Narrative generation + millify helper
|
|
132
|
+
├── tests/
|
|
133
|
+
│ ├── test_detector.py
|
|
134
|
+
│ ├── test_extractor.py
|
|
135
|
+
│ └── test_narrative.py
|
|
136
|
+
├── pyproject.toml
|
|
137
|
+
└── README.md
|
|
138
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trend-narrative"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Piecewise-linear trend detection and plain-English narrative generation for time-series data."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Yuki Suzuki", email = "yukinko.iwasaki@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["trend", "narrative", "time-series", "piecewise", "statistics"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
dependencies = [
|
|
23
|
+
"numpy>=1.24",
|
|
24
|
+
"scipy>=1.10",
|
|
25
|
+
"pwlf>=2.2",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=7.4",
|
|
31
|
+
"pytest-cov>=4.1",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
addopts = "-v --tb=short"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["."]
|
|
40
|
+
include = ["trend_narrative*"]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Unit tests for trend_narrative.detector.TrendDetector."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from trend_narrative.detector import TrendDetector
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Fixtures
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def linear_up():
|
|
15
|
+
"""Strictly increasing linear series (2010–2021)."""
|
|
16
|
+
x = np.arange(2010, 2022, dtype=float)
|
|
17
|
+
y = 100.0 + 5.0 * (x - 2010)
|
|
18
|
+
return x, y
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def linear_down():
|
|
23
|
+
"""Strictly decreasing linear series."""
|
|
24
|
+
x = np.arange(2010, 2022, dtype=float)
|
|
25
|
+
y = 200.0 - 5.0 * (x - 2010)
|
|
26
|
+
return x, y
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def v_shape():
|
|
31
|
+
"""A V-shape with a clear trough in the middle."""
|
|
32
|
+
x = np.arange(2005, 2021, dtype=float)
|
|
33
|
+
y = np.concatenate([
|
|
34
|
+
100.0 - 4.0 * np.arange(8), # decline 2005-2012
|
|
35
|
+
68.0 + 4.0 * np.arange(8), # recovery 2013-2020
|
|
36
|
+
])
|
|
37
|
+
return x, y
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def flat():
|
|
42
|
+
"""Nearly flat series – no real trend."""
|
|
43
|
+
rng = np.random.default_rng(0)
|
|
44
|
+
x = np.arange(2010, 2022, dtype=float)
|
|
45
|
+
y = 100.0 + rng.normal(0, 0.5, len(x)) # tiny noise
|
|
46
|
+
return x, y
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# calculate_bic
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
class TestCalculateBic:
|
|
54
|
+
def test_lower_ssr_gives_lower_bic(self):
|
|
55
|
+
bic_good = TrendDetector.calculate_bic(ssr=10, n_data_points=20, n_segments=2)
|
|
56
|
+
bic_bad = TrendDetector.calculate_bic(ssr=100, n_data_points=20, n_segments=2)
|
|
57
|
+
assert bic_good < bic_bad
|
|
58
|
+
|
|
59
|
+
def test_more_segments_penalises_bic(self):
|
|
60
|
+
"""Extra segments should increase BIC when SSR is held constant."""
|
|
61
|
+
bic_simple = TrendDetector.calculate_bic(ssr=50, n_data_points=20, n_segments=1)
|
|
62
|
+
bic_complex = TrendDetector.calculate_bic(ssr=50, n_data_points=20, n_segments=3)
|
|
63
|
+
assert bic_complex > bic_simple
|
|
64
|
+
|
|
65
|
+
def test_returns_float(self):
|
|
66
|
+
result = TrendDetector.calculate_bic(100, 20, 2)
|
|
67
|
+
assert isinstance(result, float)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# find_local_maxima_years
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
class TestFindLocalMaximaYears:
|
|
75
|
+
def test_detects_peak(self):
|
|
76
|
+
detector = TrendDetector()
|
|
77
|
+
x = np.array([2010, 2011, 2012, 2013, 2014], dtype=float)
|
|
78
|
+
y = np.array([1, 3, 5, 3, 1], dtype=float) # peak at 2012
|
|
79
|
+
extrema = detector.find_local_maxima_years(x, y)
|
|
80
|
+
assert 2012.0 in extrema
|
|
81
|
+
|
|
82
|
+
def test_detects_valley(self):
|
|
83
|
+
detector = TrendDetector()
|
|
84
|
+
x = np.array([2010, 2011, 2012, 2013, 2014], dtype=float)
|
|
85
|
+
y = np.array([5, 3, 1, 3, 5], dtype=float) # valley at 2012
|
|
86
|
+
extrema = detector.find_local_maxima_years(x, y)
|
|
87
|
+
assert 2012.0 in extrema
|
|
88
|
+
|
|
89
|
+
def test_monotone_has_no_extrema(self):
|
|
90
|
+
detector = TrendDetector()
|
|
91
|
+
x = np.arange(2010, 2016, dtype=float)
|
|
92
|
+
y = np.arange(6, dtype=float)
|
|
93
|
+
extrema = detector.find_local_maxima_years(x, y)
|
|
94
|
+
assert extrema == []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# extract_trend – end-to-end
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
class TestExtractTrend:
|
|
102
|
+
def test_returns_list(self, linear_up):
|
|
103
|
+
x, y = linear_up
|
|
104
|
+
detector = TrendDetector()
|
|
105
|
+
result = detector.extract_trend(x, y)
|
|
106
|
+
assert isinstance(result, list)
|
|
107
|
+
|
|
108
|
+
def test_segment_keys_present(self, linear_up):
|
|
109
|
+
x, y = linear_up
|
|
110
|
+
detector = TrendDetector()
|
|
111
|
+
result = detector.extract_trend(x, y)
|
|
112
|
+
if result:
|
|
113
|
+
expected_keys = {"start_year", "end_year", "start_value", "end_value", "slope", "p_value"}
|
|
114
|
+
assert expected_keys.issubset(result[0].keys())
|
|
115
|
+
|
|
116
|
+
def test_upward_trend_positive_slope(self, linear_up):
|
|
117
|
+
x, y = linear_up
|
|
118
|
+
detector = TrendDetector()
|
|
119
|
+
result = detector.extract_trend(x, y)
|
|
120
|
+
if result:
|
|
121
|
+
assert all(seg["slope"] > 0 for seg in result)
|
|
122
|
+
|
|
123
|
+
def test_downward_trend_negative_slope(self, linear_down):
|
|
124
|
+
x, y = linear_down
|
|
125
|
+
detector = TrendDetector()
|
|
126
|
+
result = detector.extract_trend(x, y)
|
|
127
|
+
if result:
|
|
128
|
+
assert all(seg["slope"] < 0 for seg in result)
|
|
129
|
+
|
|
130
|
+
def test_v_shape_returns_two_segments(self, v_shape):
|
|
131
|
+
x, y = v_shape
|
|
132
|
+
detector = TrendDetector()
|
|
133
|
+
result = detector.extract_trend(x, y)
|
|
134
|
+
# Allow 0 (no valid fit) or 2 (correct detection)
|
|
135
|
+
assert len(result) in (0, 2)
|
|
136
|
+
if len(result) == 2:
|
|
137
|
+
assert result[0]["slope"] < 0 # declining first
|
|
138
|
+
assert result[1]["slope"] > 0 # recovering second
|
|
139
|
+
|
|
140
|
+
def test_accepts_list_input(self):
|
|
141
|
+
x = list(range(2010, 2022))
|
|
142
|
+
y = [float(i) * 3 for i in range(12)]
|
|
143
|
+
result = TrendDetector().extract_trend(x, y)
|
|
144
|
+
assert isinstance(result, list)
|
|
145
|
+
|
|
146
|
+
def test_segments_are_contiguous(self, v_shape):
|
|
147
|
+
x, y = v_shape
|
|
148
|
+
result = TrendDetector().extract_trend(x, y)
|
|
149
|
+
for i in range(1, len(result)):
|
|
150
|
+
assert result[i]["start_year"] == result[i - 1]["end_year"]
|
|
151
|
+
|
|
152
|
+
def test_too_few_points_returns_empty(self):
|
|
153
|
+
x = np.array([2010, 2011], dtype=float)
|
|
154
|
+
y = np.array([100, 110], dtype=float)
|
|
155
|
+
result = TrendDetector().extract_trend(x, y)
|
|
156
|
+
assert result == []
|
|
157
|
+
|
|
158
|
+
def test_max_segments_respected(self, v_shape):
|
|
159
|
+
x, y = v_shape
|
|
160
|
+
detector = TrendDetector(max_segments=1)
|
|
161
|
+
result = detector.extract_trend(x, y)
|
|
162
|
+
assert len(result) <= 1
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Unit tests for trend_narrative.extractor.InsightExtractor."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from trend_narrative.extractor import InsightExtractor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Fixtures
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def stable_series():
|
|
17
|
+
x = np.arange(2010, 2022, dtype=float)
|
|
18
|
+
rng = np.random.default_rng(1)
|
|
19
|
+
y = 100.0 + rng.normal(0, 1, len(x)) # CV ≈ 1 %
|
|
20
|
+
return x, y
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def volatile_series():
|
|
25
|
+
rng = np.random.default_rng(42)
|
|
26
|
+
x = np.arange(2000, 2020, dtype=float)
|
|
27
|
+
y = rng.uniform(10, 100, len(x)) # high CV
|
|
28
|
+
return x, y
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def trending_series():
|
|
33
|
+
x = np.arange(2010, 2022, dtype=float)
|
|
34
|
+
y = 100.0 + 10.0 * (x - 2010)
|
|
35
|
+
return x, y
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# get_volatility
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
class TestGetVolatility:
|
|
43
|
+
def test_stable_series_low_cv(self, stable_series):
|
|
44
|
+
x, y = stable_series
|
|
45
|
+
cv = InsightExtractor(x, y).get_volatility()
|
|
46
|
+
assert cv < 5.0
|
|
47
|
+
|
|
48
|
+
def test_volatile_series_high_cv(self, volatile_series):
|
|
49
|
+
x, y = volatile_series
|
|
50
|
+
cv = InsightExtractor(x, y).get_volatility()
|
|
51
|
+
assert cv > 15.0
|
|
52
|
+
|
|
53
|
+
def test_cv_is_float(self, stable_series):
|
|
54
|
+
x, y = stable_series
|
|
55
|
+
cv = InsightExtractor(x, y).get_volatility()
|
|
56
|
+
assert isinstance(cv, float)
|
|
57
|
+
|
|
58
|
+
def test_zero_mean_returns_nan(self):
|
|
59
|
+
x = np.arange(5, dtype=float)
|
|
60
|
+
y = np.zeros(5)
|
|
61
|
+
cv = InsightExtractor(x, y).get_volatility()
|
|
62
|
+
assert math.isnan(cv)
|
|
63
|
+
|
|
64
|
+
def test_constant_series_zero_cv(self):
|
|
65
|
+
x = np.arange(2010, 2022, dtype=float)
|
|
66
|
+
y = np.full(12, 50.0)
|
|
67
|
+
cv = InsightExtractor(x, y).get_volatility()
|
|
68
|
+
assert cv == pytest.approx(0.0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# get_structural_segments
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
class TestGetStructuralSegments:
|
|
76
|
+
def test_returns_list(self, trending_series):
|
|
77
|
+
x, y = trending_series
|
|
78
|
+
segments = InsightExtractor(x, y).get_structural_segments()
|
|
79
|
+
assert isinstance(segments, list)
|
|
80
|
+
|
|
81
|
+
def test_trending_series_segments(self, trending_series):
|
|
82
|
+
x, y = trending_series
|
|
83
|
+
segments = InsightExtractor(x, y).get_structural_segments()
|
|
84
|
+
# May be empty (no valid fit) or list of dicts
|
|
85
|
+
assert all(isinstance(s, dict) for s in segments)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# extract_full_suite
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
class TestExtractFullSuite:
|
|
93
|
+
def test_keys_present(self, stable_series):
|
|
94
|
+
x, y = stable_series
|
|
95
|
+
result = InsightExtractor(x, y).extract_full_suite()
|
|
96
|
+
assert "cv_value" in result
|
|
97
|
+
assert "segments" in result
|
|
98
|
+
|
|
99
|
+
def test_cv_value_is_numeric(self, stable_series):
|
|
100
|
+
x, y = stable_series
|
|
101
|
+
result = InsightExtractor(x, y).extract_full_suite()
|
|
102
|
+
assert isinstance(result["cv_value"], float)
|
|
103
|
+
|
|
104
|
+
def test_segments_is_list(self, stable_series):
|
|
105
|
+
x, y = stable_series
|
|
106
|
+
result = InsightExtractor(x, y).extract_full_suite()
|
|
107
|
+
assert isinstance(result["segments"], list)
|
|
108
|
+
|
|
109
|
+
def test_custom_detector_used(self, trending_series):
|
|
110
|
+
"""Passing a max_segments=1 detector limits segment count."""
|
|
111
|
+
from trend_narrative.detector import TrendDetector
|
|
112
|
+
x, y = trending_series
|
|
113
|
+
detector = TrendDetector(max_segments=1)
|
|
114
|
+
result = InsightExtractor(x, y, detector=detector).extract_full_suite()
|
|
115
|
+
assert len(result["segments"]) <= 1
|
|
116
|
+
|
|
117
|
+
def test_array_like_inputs_accepted(self):
|
|
118
|
+
x = list(range(2010, 2022))
|
|
119
|
+
y = [float(i ** 2) for i in range(12)]
|
|
120
|
+
result = InsightExtractor(x, y).extract_full_suite()
|
|
121
|
+
assert "cv_value" in result
|