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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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