pytrendy 1.4.0.dev4__tar.gz → 1.4.0.dev5__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.
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/PKG-INFO +1 -1
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pyproject.toml +1 -1
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/detect_trends.py +34 -13
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/plot_pytrendy.py +144 -35
- pytrendy-1.4.0.dev5/pytrendy/io/prepare_index.py +216 -0
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/results_pytrendy.py +25 -3
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_analyse.py +1 -1
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_get.py +9 -9
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/__init__.py +2 -2
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/abrupt_shaving.py +21 -23
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/artifact_cleanup.py +69 -70
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/gradual_expand_contract.py +23 -25
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/segment_grouping.py +2 -2
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/trend_classify.py +3 -3
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/update_neighbours.py +23 -21
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/process_signals.py +19 -15
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/LICENSE +0 -0
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/README.md +0 -0
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/__init__.py +0 -0
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/__init__.py +0 -0
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/data/classes_signals.csv +0 -0
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/data/series_synthetic.csv +0 -0
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/data_loader.py +0 -0
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/__init__.py +0 -0
- {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/simpledtw.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pytrendy"
|
|
3
|
-
version = "1.4.0.
|
|
3
|
+
version = "1.4.0.dev5"
|
|
4
4
|
description = "Trend Detection in Python. Applicable for real-world industry use cases in time series."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Russell Sammut Bonnici", email = "r.sammutbonnici@gmail.com" },
|
|
@@ -8,8 +8,17 @@ from .post_processing.segments_refine import refine_segments
|
|
|
8
8
|
from .post_processing.segments_analyse import analyse_segments
|
|
9
9
|
from .io.plot_pytrendy import plot_pytrendy
|
|
10
10
|
from .io.results_pytrendy import PyTrendyResults
|
|
11
|
+
from .io import prepare_index
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
|
|
14
|
+
def detect_trends(df: pd.DataFrame,
|
|
15
|
+
value_col: str,
|
|
16
|
+
date_col: str|None=None,
|
|
17
|
+
plot: bool=True,
|
|
18
|
+
method_params: dict|None=None,
|
|
19
|
+
plot_params: dict|None=None,
|
|
20
|
+
debug: bool=False
|
|
21
|
+
) -> PyTrendyResults:
|
|
13
22
|
"""
|
|
14
23
|
This is the main function that runs trend detection end-to-end.
|
|
15
24
|
|
|
@@ -29,18 +38,17 @@ def detect_trends(df: pd.DataFrame, date_col: str, value_col: str, plot=True, me
|
|
|
29
38
|
Args:
|
|
30
39
|
df (pd.DataFrame):
|
|
31
40
|
Input time series data containing at least the specified `date_col` and `value_col`.
|
|
32
|
-
The `date_col` must contain datetime-like values (daily frequency recommended).
|
|
33
|
-
date_col (str):
|
|
34
|
-
Name of the column representing timestamps. This column is converted to datetime and set as the index.
|
|
35
41
|
value_col (str):
|
|
36
42
|
Name of the column containing the primary signal to analyse for trend detection.
|
|
43
|
+
date_col (str|None):
|
|
44
|
+
Column giving the x-axis position of each observation. Typically dates, but any unique, sortable values (integer, float, or string) are supported. If not specified, the DataFrame's index is used.
|
|
37
45
|
plot (bool, optional):
|
|
38
46
|
If `True`, generates a matplotlib plot showing the detected trend segments over the original signal.
|
|
39
47
|
Defaults to `True`.
|
|
40
48
|
method_params (dict, optional):
|
|
41
49
|
Optional parameters to customize detection heuristics. Supported keys:
|
|
42
50
|
|
|
43
|
-
- **abrupt_padding** (`int`): Number of days to pad
|
|
51
|
+
- **abrupt_padding** (`int`): Number of days to pad after abrupt transitions. Defaults to `0`.
|
|
44
52
|
- **gradual_padding** (`int`): Number of days to pad after gradual trend ends. Defaults to `0`.
|
|
45
53
|
- **avoid_noise** (`bool`): Whether to avoid noisy segments in trend detection. Defaults to `True`.
|
|
46
54
|
plot_params (dict, optional):
|
|
@@ -64,11 +72,17 @@ def detect_trends(df: pd.DataFrame, date_col: str, value_col: str, plot=True, me
|
|
|
64
72
|
An object encapsulating the detected segments and associated metadata.
|
|
65
73
|
Use this object to access segment statistics, rankings, and export utilities.
|
|
66
74
|
"""
|
|
67
|
-
df
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
# Reject the deprecated positional order detect_trends(df, date_col, value_col).
|
|
76
|
+
if date_col is not None and prepare_index.is_legacy_positional_order(df, value_col, date_col):
|
|
77
|
+
raise TypeError(
|
|
78
|
+
"detect_trends received arguments in the deprecated (date_col, value_col) order. "
|
|
79
|
+
"Pass value_col first: detect_trends(df, value_col, date_col=...)."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Stage the DataFrame on an internal integer index, keeping the external index
|
|
83
|
+
# values and a lookup so boundaries can be remapped back later.
|
|
84
|
+
df, external_index, index_lookup, index_type = prepare_index.prepare_index(df, date_col, value_col)
|
|
85
|
+
|
|
72
86
|
if method_params is None:
|
|
73
87
|
method_params = {} # Avoid mutable default argument by accepting None and constructing a new dict here
|
|
74
88
|
|
|
@@ -93,7 +107,14 @@ def detect_trends(df: pd.DataFrame, date_col: str, value_col: str, plot=True, me
|
|
|
93
107
|
segments = get_segments(df)
|
|
94
108
|
segments = refine_segments(df, value_col, segments, method_params)
|
|
95
109
|
segments = analyse_segments(df, value_col, segments)
|
|
96
|
-
if plot: plot_pytrendy(df, value_col, segments, plot_params=plot_params)
|
|
97
110
|
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
# Translate internal segment boundaries back to the user's external index values.
|
|
112
|
+
segments = prepare_index.remap_boundaries(segments, index_lookup)
|
|
113
|
+
|
|
114
|
+
if plot:
|
|
115
|
+
# Restore the external index for plotting before rendering the segments.
|
|
116
|
+
plot_df = prepare_index.prepare_plot_frame(df, date_col, external_index, index_type)
|
|
117
|
+
plot_pytrendy(df=plot_df, value_col=value_col, segments_enhanced=segments, index_type=index_type, plot_params=plot_params)
|
|
118
|
+
|
|
119
|
+
results = PyTrendyResults(segments=segments, index_type=index_type)
|
|
120
|
+
return results
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
"""**Visualize Detected Trends Over Time Series**"""
|
|
2
2
|
|
|
3
3
|
import pandas as pd
|
|
4
|
+
import numpy as np
|
|
4
5
|
import matplotlib.pyplot as plt
|
|
5
6
|
import matplotlib.dates as mdates
|
|
6
7
|
import matplotlib.patches as mpatches
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
|
|
10
|
+
def _safe_adjacent(index, pos, offset):
|
|
11
|
+
"""
|
|
12
|
+
Safely get an adjacent index value with bounds checking.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
index: The index to access
|
|
16
|
+
pos: Current position in the index
|
|
17
|
+
offset: Offset from current position (+1 or -1)
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The adjacent index value if within bounds, None otherwise
|
|
21
|
+
"""
|
|
22
|
+
new_pos = pos + offset
|
|
23
|
+
if 0 <= new_pos < len(index):
|
|
24
|
+
return index[new_pos]
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict], index_type: str = "date", suppress_show: bool = False, plot_params: dict = None) -> plt.Figure:
|
|
9
28
|
"""
|
|
10
29
|
Visualizes detected trend segments over the original time series signal.
|
|
11
30
|
|
|
@@ -19,6 +38,8 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
|
|
|
19
38
|
Name of the column containing the signal to plot.
|
|
20
39
|
segments_enhanced (list):
|
|
21
40
|
List of segment dictionaries containing keys like `'start'`, `'end'`, `'direction'`, `'trend_class'`, and `'change_rank'`.
|
|
41
|
+
index_type (str):
|
|
42
|
+
The type of index passed by the user. Different index types require different logic. Currently Accepted Index Types are: "date", "integer", "float".
|
|
22
43
|
suppress_show (bool, optional):
|
|
23
44
|
If True, suppresses the automatic display of the plot with plt.show(). Defaults to False.
|
|
24
45
|
plot_params (dict, optional):
|
|
@@ -78,16 +99,29 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
|
|
|
78
99
|
# Plot the value line
|
|
79
100
|
ax.plot(df.index, df[value_col], color='black', lw=1)
|
|
80
101
|
|
|
102
|
+
|
|
81
103
|
# Add shaded regions with fill_between
|
|
82
104
|
ymin, ymax = ax.get_ylim() # get plot's visible y-range
|
|
83
105
|
for i, seg in enumerate(segments_enhanced):
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
|
|
107
|
+
if index_type == "date":
|
|
108
|
+
start = pd.to_datetime(seg['start'])
|
|
109
|
+
end = pd.to_datetime(seg['end'])
|
|
110
|
+
else:
|
|
111
|
+
start = seg['start']
|
|
112
|
+
end = seg['end']
|
|
113
|
+
|
|
86
114
|
color = color_map.get(seg['direction'], 'gray')
|
|
87
115
|
|
|
88
116
|
# Get context on prev seg if possible
|
|
89
117
|
prev_seg = segments_enhanced[i-1] if i-1 >= 0 else None
|
|
90
|
-
|
|
118
|
+
if index_type == "date":
|
|
119
|
+
prev_neighbouring = prev_seg and (pd.to_datetime(prev_seg['end']) == (start - pd.Timedelta(days=1)))
|
|
120
|
+
elif index_type == 'string':
|
|
121
|
+
prev_neighbouring = prev_seg and (prev_seg['end'] == df.index[df.index.get_loc(start) - 1])
|
|
122
|
+
else:
|
|
123
|
+
prev_neighbouring = prev_seg and (prev_seg['end'] == (start - 1))
|
|
124
|
+
|
|
91
125
|
is_prev_not_trend = prev_seg and (not ('trend_class' in prev_seg))
|
|
92
126
|
|
|
93
127
|
# Current seg context
|
|
@@ -97,18 +131,32 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
|
|
|
97
131
|
|
|
98
132
|
# Get context on next seg if possible
|
|
99
133
|
next_seg = segments_enhanced[i+1] if i+1 < len(segments_enhanced) else None
|
|
100
|
-
|
|
134
|
+
if index_type == 'date':
|
|
135
|
+
next_neighbouring = next_seg and (pd.to_datetime(next_seg['start']) == (end + pd.Timedelta(days=1)))
|
|
136
|
+
elif index_type == 'string':
|
|
137
|
+
end_pos = df.index.get_loc(end)
|
|
138
|
+
next_neighbouring = next_seg and (next_seg['start'] == _safe_adjacent(df.index, end_pos, 1))
|
|
139
|
+
else:
|
|
140
|
+
next_neighbouring = next_seg and (next_seg['start'] == (end + 1))
|
|
141
|
+
|
|
101
142
|
next_seg_abrupt = next_seg and (('trend_class' in next_seg) and (next_seg['trend_class'] == 'abrupt'))
|
|
102
143
|
next_seg_noise = next_seg and (next_seg['direction'] == 'Noise')
|
|
103
144
|
|
|
104
145
|
# Adjust starts when appropriate
|
|
105
146
|
if is_abrupt or is_noise:
|
|
106
|
-
|
|
147
|
+
pass # Keep start as-is for abrupt/noise segments
|
|
107
148
|
else:
|
|
108
|
-
|
|
149
|
+
if index_type == 'date':
|
|
150
|
+
new_start = start - pd.Timedelta(days=1) # Everything else displaced left start
|
|
151
|
+
elif index_type == 'string':
|
|
152
|
+
start_pos = df.index.get_loc(start)
|
|
153
|
+
new_start = _safe_adjacent(df.index, start_pos, -1)
|
|
154
|
+
else:
|
|
155
|
+
new_start = start - 1 # Everything else displaced left start
|
|
109
156
|
|
|
110
157
|
# Check validity of plot start adjustment
|
|
111
|
-
value_new_start = df.loc[new_start, value_col] if new_start in df.index else None
|
|
158
|
+
value_new_start = df.loc[new_start, value_col] if new_start is not None and new_start in df.index else None
|
|
159
|
+
|
|
112
160
|
value = df.loc[start, value_col]
|
|
113
161
|
|
|
114
162
|
valid_up_start = (value_new_start) and (seg['direction'] == 'Up') and (value_new_start < value)
|
|
@@ -117,20 +165,38 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
|
|
|
117
165
|
start = new_start # Apply left displacement only if valid
|
|
118
166
|
else:
|
|
119
167
|
# if not displaced and prev is not trend, adjust by plotting (as prev has already been drawn)
|
|
120
|
-
if is_prev_not_trend and prev_neighbouring:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
168
|
+
if is_prev_not_trend and prev_neighbouring:
|
|
169
|
+
if index_type == 'date':
|
|
170
|
+
prev_end = pd.to_datetime(segments_enhanced[i-1]['end'])
|
|
171
|
+
prev_new_end = (prev_end + pd.Timedelta(days=1)).strftime('%Y-%m-%d')
|
|
172
|
+
elif index_type == 'string':
|
|
173
|
+
prev_end = segments_enhanced[i-1]['end']
|
|
174
|
+
prev_end_pos = df.index.get_loc(prev_end)
|
|
175
|
+
prev_new_end = _safe_adjacent(df.index, prev_end_pos, 1)
|
|
176
|
+
else:
|
|
177
|
+
prev_end = segments_enhanced[i-1]['end']
|
|
178
|
+
prev_new_end = prev_end + 1
|
|
179
|
+
|
|
180
|
+
if prev_new_end is not None:
|
|
181
|
+
if index_type == 'string':
|
|
182
|
+
mask = (np.arange(len(df)) >= df.index.get_loc(prev_end)) & (np.arange(len(df)) <= df.index.get_loc(prev_new_end))
|
|
183
|
+
else:
|
|
184
|
+
mask = (df.index >= prev_end) & (df.index <= prev_new_end)
|
|
185
|
+
prev_color = color_map.get(segments_enhanced[i-1]['direction'], 'gray')
|
|
186
|
+
ax.fill_between(df.index[mask], ymin, ymax, color=prev_color, alpha=default_params['alpha'])
|
|
127
187
|
|
|
128
188
|
# Adjust ends when appropriate
|
|
129
189
|
if (next_seg_abrupt or next_seg_noise) and next_neighbouring:
|
|
130
|
-
|
|
190
|
+
if index_type == 'date':
|
|
191
|
+
new_end = end + pd.Timedelta(days=1)
|
|
192
|
+
elif index_type == 'string':
|
|
193
|
+
end_pos = df.index.get_loc(end)
|
|
194
|
+
new_end = _safe_adjacent(df.index, end_pos, 1)
|
|
195
|
+
else:
|
|
196
|
+
new_end = end + 1
|
|
131
197
|
|
|
132
198
|
# Check validity of plot end adjustment
|
|
133
|
-
value_new_end = df.loc[new_end, value_col] if new_end in df.index else None
|
|
199
|
+
value_new_end = df.loc[new_end, value_col] if new_end is not None and new_end in df.index else None
|
|
134
200
|
value = df.loc[end, value_col]
|
|
135
201
|
|
|
136
202
|
valid_up_end = (value_new_end) and (seg['direction'] == 'Up') and (value_new_end > value)
|
|
@@ -141,16 +207,34 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
|
|
|
141
207
|
else:
|
|
142
208
|
# if not displaced and next is noise, adjust for next plotting round
|
|
143
209
|
if next_seg_noise and next_neighbouring:
|
|
144
|
-
|
|
210
|
+
if index_type == 'date':
|
|
211
|
+
segments_enhanced[i+1]['start'] = (pd.to_datetime(segments_enhanced[i+1]['start']) - pd.Timedelta(days=1)).strftime('%Y-%m-%d')
|
|
212
|
+
elif index_type == 'string':
|
|
213
|
+
next_start_pos = df.index.get_loc(segments_enhanced[i+1]['start'])
|
|
214
|
+
segments_enhanced[i+1]['start'] = _safe_adjacent(df.index, next_start_pos, -1)
|
|
215
|
+
else:
|
|
216
|
+
segments_enhanced[i+1]['start'] = (segments_enhanced[i+1]['start'] - 1)
|
|
145
217
|
else:
|
|
146
|
-
|
|
218
|
+
pass # Keep end as-is
|
|
219
|
+
|
|
220
|
+
if index_type == 'string':
|
|
221
|
+
mask = (np.arange(len(df)) >= df.index.get_loc(start)) & (np.arange(len(df)) <= df.index.get_loc(end))
|
|
222
|
+
else:
|
|
223
|
+
mask = (df.index >= start) & (df.index <= end)
|
|
224
|
+
|
|
147
225
|
|
|
148
|
-
mask = (df.index >= start) & (df.index <= end)
|
|
149
226
|
ax.fill_between(df.index[mask], ymin, ymax, color=color, alpha=default_params['alpha'])
|
|
150
227
|
|
|
151
228
|
# Add ranking if up/down trend
|
|
152
229
|
if 'change_rank' in seg and seg['direction'] in ['Up', 'Down']:
|
|
153
|
-
|
|
230
|
+
|
|
231
|
+
if index_type in ['string']:
|
|
232
|
+
midpoint = int((df.index.get_loc(end) - df.index.get_loc(start))/2)
|
|
233
|
+
mid_date = df.index[df.index.get_loc(start) + midpoint]
|
|
234
|
+
else:
|
|
235
|
+
mid_date = start + (end - start) / 2
|
|
236
|
+
|
|
237
|
+
|
|
154
238
|
y_pos = ymax - (ymax - ymin) * 0.05
|
|
155
239
|
ax.text(mid_date, y_pos, str(seg['change_rank']), fontsize=12,
|
|
156
240
|
fontweight='bold', ha='center', va='top',
|
|
@@ -158,35 +242,60 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
|
|
|
158
242
|
|
|
159
243
|
# Add vertical line if next seg is same & touching
|
|
160
244
|
if next_seg and next_neighbouring and next_seg['direction'] == seg['direction']:
|
|
161
|
-
|
|
245
|
+
if index_type == 'date':
|
|
246
|
+
line_date = pd.to_datetime(seg['end'])
|
|
247
|
+
else:
|
|
248
|
+
line_date = seg['end']
|
|
162
249
|
ax.axvline(x=line_date, color=color[5:], linewidth=0.5)
|
|
163
250
|
|
|
164
251
|
# Set limits
|
|
165
|
-
|
|
166
|
-
|
|
252
|
+
if index_type == 'string':
|
|
253
|
+
first_date = df.index[0]
|
|
254
|
+
last_date = df.index[-1]
|
|
255
|
+
else:
|
|
256
|
+
first_date = df.index.min()
|
|
257
|
+
last_date = df.index.max()
|
|
258
|
+
|
|
167
259
|
ax.set_xlim(first_date, last_date)
|
|
168
260
|
ax.set_ylim(ymin, ymax)
|
|
169
261
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
262
|
+
if index_type == 'date':
|
|
263
|
+
# Major ticks: every 7 days (with labels)
|
|
264
|
+
ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1))
|
|
265
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
|
|
173
266
|
|
|
174
|
-
|
|
175
|
-
|
|
267
|
+
# Minor ticks: every day (no labels, just tick marks/grid)
|
|
268
|
+
ax.xaxis.set_minor_locator(mdates.DayLocator())
|
|
176
269
|
|
|
177
270
|
# Rotate major tick labels
|
|
178
271
|
plt.setp(ax.get_xticklabels(), rotation=90, ha='right')
|
|
179
272
|
|
|
180
273
|
# Optional: show grid lines for both
|
|
181
|
-
|
|
182
|
-
if
|
|
183
|
-
ax.grid(True,
|
|
274
|
+
grid_cfg = default_params['grid']
|
|
275
|
+
if grid_cfg.get('visible', True):
|
|
276
|
+
ax.grid(True, which=grid_cfg.get('which', 'major'),
|
|
277
|
+
color=grid_cfg.get('color', 'gray'), alpha=grid_cfg.get('alpha', 0.3))
|
|
184
278
|
else:
|
|
185
|
-
ax.grid(False
|
|
279
|
+
ax.grid(False)
|
|
280
|
+
|
|
281
|
+
if index_type == 'string':
|
|
282
|
+
ticks = ax.get_xticks()
|
|
283
|
+
labels = [t.get_text() for t in ax.get_xticklabels()]
|
|
284
|
+
n = 10
|
|
285
|
+
ax.set_xticks(ticks[::n])
|
|
286
|
+
ax.set_xticklabels(labels[::n], rotation=90, ha='center')
|
|
287
|
+
|
|
186
288
|
|
|
187
289
|
ax.set_title(default_params['title'], fontsize=20)
|
|
188
|
-
|
|
189
|
-
|
|
290
|
+
|
|
291
|
+
if index_type == 'date':
|
|
292
|
+
ax.set_xlabel(default_params.get('xlabel', 'Date'))
|
|
293
|
+
elif index_type == 'string':
|
|
294
|
+
ax.set_xlabel(default_params.get('xlabel', 'Label'))
|
|
295
|
+
else:
|
|
296
|
+
ax.set_xlabel(default_params.get('xlabel', 'Index'))
|
|
297
|
+
|
|
298
|
+
ax.set_ylabel(default_params.get('ylabel', 'Value'))
|
|
190
299
|
|
|
191
300
|
# Create custom legend handles (colored boxes)
|
|
192
301
|
legend_handles = [
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""**Index Preparation for the Detection Pipeline**
|
|
2
|
+
|
|
3
|
+
PyTrendy's pipeline operates on a positional integer index internally, regardless of
|
|
4
|
+
the index the user supplies. This decouples the detection logic from any particular
|
|
5
|
+
time axis (daily, weekly, or otherwise) and lets the same pipeline accept datetime,
|
|
6
|
+
integer, float, or string indexes.
|
|
7
|
+
|
|
8
|
+
The preparation is a two-way translation:
|
|
9
|
+
|
|
10
|
+
1. **Inbound** — the user's index column (``date_col``) is inspected to determine its
|
|
11
|
+
type, its values are captured, and the working DataFrame is re-staged on an internal
|
|
12
|
+
integer index (``0..n-1``). A lookup table maps internal positions back to the
|
|
13
|
+
original external index values.
|
|
14
|
+
|
|
15
|
+
2. **Outbound** — once segments are detected on the internal index, their boundaries are
|
|
16
|
+
remapped back to the external index values before the results are returned or plotted.
|
|
17
|
+
|
|
18
|
+
The functions in this module encapsulate that translation so ``detect_trends`` stays
|
|
19
|
+
focused on orchestrating the pipeline.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import warnings
|
|
23
|
+
from copy import deepcopy
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def detect_index_type(values) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Detect the index type from a Series or Index of values.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
values: A pandas Series or Index of index values.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
str: Index type (``'date'``, ``'datetime64'``, ``'integer'``, ``'float'``, or ``'string'``).
|
|
38
|
+
"""
|
|
39
|
+
if pd.api.types.is_string_dtype(values):
|
|
40
|
+
with warnings.catch_warnings():
|
|
41
|
+
warnings.filterwarnings(
|
|
42
|
+
"ignore",
|
|
43
|
+
message="Could not infer format.*"
|
|
44
|
+
)
|
|
45
|
+
parsed = pd.to_datetime(values, errors="coerce")
|
|
46
|
+
|
|
47
|
+
if parsed.notna().all():
|
|
48
|
+
return "date"
|
|
49
|
+
else:
|
|
50
|
+
return "string"
|
|
51
|
+
elif pd.api.types.is_datetime64_any_dtype(values):
|
|
52
|
+
return "datetime64"
|
|
53
|
+
elif pd.api.types.is_integer_dtype(values):
|
|
54
|
+
return "integer"
|
|
55
|
+
elif pd.api.types.is_float_dtype(values):
|
|
56
|
+
return "float"
|
|
57
|
+
else:
|
|
58
|
+
raise NotImplementedError(f"unimplemented dtype {values.dtype}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_legacy_positional_order(df: pd.DataFrame, value_col: str, date_col: str) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Detect the deprecated ``detect_trends(df, date_col, value_col)`` positional order.
|
|
64
|
+
|
|
65
|
+
Under the old API the first positional column was always date-like (it was passed
|
|
66
|
+
through ``pd.to_datetime``) and the second was the numeric value column. So if the
|
|
67
|
+
column currently bound as ``value_col`` is date-like while ``date_col`` is numeric,
|
|
68
|
+
the caller almost certainly used the legacy order.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
df (pd.DataFrame): Input DataFrame.
|
|
72
|
+
value_col (str): Column currently bound as the value/signal column.
|
|
73
|
+
date_col (str): Column currently bound as the date/index column.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
bool: True if the arguments appear to be in the legacy (date_col, value_col) order.
|
|
77
|
+
"""
|
|
78
|
+
value_dtype = df[value_col].dtype
|
|
79
|
+
date_dtype = df[date_col].dtype
|
|
80
|
+
|
|
81
|
+
value_is_datelike = pd.api.types.is_datetime64_any_dtype(value_dtype) or (
|
|
82
|
+
pd.api.types.is_string_dtype(value_dtype)
|
|
83
|
+
and pd.to_datetime(df[value_col], errors="coerce").notna().all()
|
|
84
|
+
)
|
|
85
|
+
date_is_numeric = pd.api.types.is_numeric_dtype(date_dtype)
|
|
86
|
+
|
|
87
|
+
return value_is_datelike and date_is_numeric
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_index_lookup(external_index) -> dict:
|
|
91
|
+
"""
|
|
92
|
+
Build a lookup mapping internal integer positions to external index values.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
external_index: The original index values captured before staging.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
dict: Mapping from internal position (``int``) to external index value.
|
|
99
|
+
"""
|
|
100
|
+
internal_index = np.arange(len(external_index))
|
|
101
|
+
return dict(zip(internal_index, np.asarray(external_index)))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def prepare_index(df: pd.DataFrame, date_col: str | None, value_col: str) -> tuple:
|
|
105
|
+
"""
|
|
106
|
+
Prepare the internal index framework used by the pipeline.
|
|
107
|
+
|
|
108
|
+
Detects the index type, captures the external index values, builds the
|
|
109
|
+
internal integer index and its lookup, and stages the working DataFrame
|
|
110
|
+
on a dedicated scratch column so the user's columns are never clobbered.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
df (pd.DataFrame): Input time series DataFrame.
|
|
114
|
+
date_col (str|None): Name of the column representing the external index.
|
|
115
|
+
value_col (str): Name of the signal column.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
tuple: ``(df, external_index, index_lookup, index_type)`` where ``df`` is
|
|
119
|
+
the internal-indexed working copy, ``external_index`` holds the original
|
|
120
|
+
index values, ``index_lookup`` maps internal to external index values, and
|
|
121
|
+
``index_type`` is the detected index type.
|
|
122
|
+
"""
|
|
123
|
+
df = df.copy()
|
|
124
|
+
|
|
125
|
+
if date_col is not None:
|
|
126
|
+
index_type = detect_index_type(df[date_col])
|
|
127
|
+
index_values = df[date_col]
|
|
128
|
+
|
|
129
|
+
# Sort sortable index types ascending so the internal positional index
|
|
130
|
+
# reflects chronological (date) / numeric order. Non-sortable types
|
|
131
|
+
# ('string') keep their given order.
|
|
132
|
+
if index_type in ('date', 'datetime64', 'integer', 'float'):
|
|
133
|
+
sort_key = pd.to_datetime(index_values) if index_type == 'date' else index_values
|
|
134
|
+
df = df.iloc[np.asarray(sort_key).argsort(kind='stable')].reset_index(drop=True)
|
|
135
|
+
external_index = df[date_col].copy()
|
|
136
|
+
|
|
137
|
+
if index_type == 'string':
|
|
138
|
+
warnings.warn(
|
|
139
|
+
f"Attempting to cast {date_col} to date failed, "
|
|
140
|
+
"treating as string lookup.",
|
|
141
|
+
UserWarning,
|
|
142
|
+
stacklevel=2,
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
# No date column: fall back to the DataFrame's own index.
|
|
146
|
+
index_type = detect_index_type(df.index)
|
|
147
|
+
index_values = df.index
|
|
148
|
+
|
|
149
|
+
# Sort sortable index types ascending; non-sortable ('string') and the
|
|
150
|
+
# default integer index keep their given order.
|
|
151
|
+
if index_type in ('datetime64', 'integer', 'float'):
|
|
152
|
+
df = df.sort_index(kind='stable')
|
|
153
|
+
elif index_type == 'date': # string-date index: sort chronologically, keep labels
|
|
154
|
+
order = np.asarray(pd.to_datetime(df.index)).argsort(kind='stable')
|
|
155
|
+
df = df.iloc[order]
|
|
156
|
+
external_index = np.asarray(df.index)
|
|
157
|
+
|
|
158
|
+
if index_type == 'float' and pd.isna(index_values).any():
|
|
159
|
+
warnings.warn(
|
|
160
|
+
"float index contains NaN values; they sort to the end and may "
|
|
161
|
+
"produce unexpected segment boundaries.",
|
|
162
|
+
UserWarning,
|
|
163
|
+
stacklevel=2,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
index_lookup = build_index_lookup(external_index)
|
|
167
|
+
|
|
168
|
+
# Use a dedicated scratch column name to avoid clobbering user's columns
|
|
169
|
+
_pytrendy_idx = '_pytrendy_idx'
|
|
170
|
+
df[_pytrendy_idx] = np.arange(len(df))
|
|
171
|
+
df.set_index(_pytrendy_idx, inplace=True)
|
|
172
|
+
df = df[[value_col]]
|
|
173
|
+
|
|
174
|
+
return df, external_index, index_lookup, index_type
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def remap_boundaries(segments: list[dict], index_lookup: dict) -> list[dict]:
|
|
178
|
+
"""
|
|
179
|
+
Remap internal segment boundaries back to external index values.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
segments (list): Segment list with internal index boundaries.
|
|
183
|
+
index_lookup (dict): Mapping from internal to external index values.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
list: A new segment list with boundaries expressed in external index values.
|
|
187
|
+
"""
|
|
188
|
+
remapped = deepcopy(segments)
|
|
189
|
+
for segment in remapped:
|
|
190
|
+
segment['start'] = index_lookup[segment['start']]
|
|
191
|
+
segment['end'] = index_lookup[segment['end']]
|
|
192
|
+
return remapped
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def prepare_plot_frame(df: pd.DataFrame, date_col: str | None, external_index, index_type: str) -> pd.DataFrame:
|
|
196
|
+
"""
|
|
197
|
+
Restore the external index onto the working DataFrame for plotting.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
df (pd.DataFrame): Internal-indexed working DataFrame.
|
|
201
|
+
date_col (str|None): Name of the external index column.
|
|
202
|
+
external_index: External index values captured before staging.
|
|
203
|
+
index_type (str): Detected index type.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
pd.DataFrame: DataFrame with the external index restored for plotting.
|
|
207
|
+
"""
|
|
208
|
+
if index_type == 'date':
|
|
209
|
+
external_index = pd.to_datetime(external_index)
|
|
210
|
+
|
|
211
|
+
# Use a sentinel name when no date column was supplied, so the restored
|
|
212
|
+
# index has a meaningful label rather than a None-named column.
|
|
213
|
+
index_name = date_col if date_col is not None else '_index'
|
|
214
|
+
df[index_name] = np.asarray(external_index)
|
|
215
|
+
df.set_index(index_name, inplace=True)
|
|
216
|
+
return df
|
|
@@ -14,17 +14,23 @@ class PyTrendyResults:
|
|
|
14
14
|
enhanced metrics such as rankings and signal-to-noise ratios.
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
def __init__(self, segments: list[dict]) -> None:
|
|
17
|
+
def __init__(self, segments: list[dict], index_type: str = 'date') -> None:
|
|
18
18
|
"""
|
|
19
19
|
Initializes the results object with a list of segments.
|
|
20
20
|
|
|
21
21
|
Args:
|
|
22
22
|
segments (list):
|
|
23
23
|
List of dictionaries representing individual trend segments.
|
|
24
|
+
index_type (str):
|
|
25
|
+
The type of the index used for the segments (``'date'``, ``'datetime64'``,
|
|
26
|
+
``'integer'``, ``'float'``, or ``'string'``). Used to render summaries with the
|
|
27
|
+
appropriate descriptor (e.g. ``'days'`` vs ``'index steps'``). Defaults to ``'date'``.
|
|
24
28
|
"""
|
|
25
29
|
self.segments = segments
|
|
26
30
|
self.trend_segments = [seg for seg in self.segments if 'trend_class' in seg] # Get segments that are trends (exclude flats and noise)
|
|
27
31
|
|
|
32
|
+
self.index_type = index_type
|
|
33
|
+
|
|
28
34
|
self.set_best()
|
|
29
35
|
self.set_df()
|
|
30
36
|
self.set_summary()
|
|
@@ -70,7 +76,17 @@ class PyTrendyResults:
|
|
|
70
76
|
|
|
71
77
|
# Set summary df (without extra details)
|
|
72
78
|
df = pd.DataFrame(self.segments)
|
|
73
|
-
|
|
79
|
+
|
|
80
|
+
unit_descriptor = {
|
|
81
|
+
'date': 'days',
|
|
82
|
+
'integer': 'index steps',
|
|
83
|
+
'float': 'index steps',
|
|
84
|
+
'string': 'index steps',
|
|
85
|
+
}.get(self.index_type, 'days')
|
|
86
|
+
|
|
87
|
+
df = df.rename({'days' : unit_descriptor}, axis = 1)
|
|
88
|
+
|
|
89
|
+
cols = ['time_index', 'direction', 'start', 'end', unit_descriptor, 'total_change', 'change_rank']
|
|
74
90
|
if len(changes) > 1: # only include trend_class if atleast one trend exists
|
|
75
91
|
cols += ['trend_class']
|
|
76
92
|
df = df[cols]
|
|
@@ -94,11 +110,17 @@ class PyTrendyResults:
|
|
|
94
110
|
noise = self.summary['direction_counts']['Noise'] if 'Noise' in self.summary['direction_counts'] else 0
|
|
95
111
|
print(f'Detected: \n- {uptrends} Uptrends. \n- {downtrends} Downtrends.\n- {flats} Flats.\n- {noise} Noise.\n')
|
|
96
112
|
|
|
113
|
+
descriptor = 'dates'
|
|
114
|
+
if self.index_type in ['integer', 'float']:
|
|
115
|
+
descriptor = 'indexes'
|
|
116
|
+
elif self.index_type in ['string']:
|
|
117
|
+
descriptor = 'labels'
|
|
118
|
+
|
|
97
119
|
if len(self.filter_segments(direction='Up/Down')) == 0:
|
|
98
120
|
print('Detected no trends...')
|
|
99
121
|
return
|
|
100
122
|
else:
|
|
101
|
-
print(f'The best detected trend is {self.best["direction"]} between
|
|
123
|
+
print(f'The best detected trend is {self.best["direction"]} between {descriptor} {self.best["start"]} - {self.best["end"]}\n')
|
|
102
124
|
|
|
103
125
|
print('Full Results:')
|
|
104
126
|
print('-------------------------------------------------------------------------------\n',
|
|
@@ -53,7 +53,7 @@ def analyse_segments(df: pd.DataFrame, value_col: str, segments: list[dict]) ->
|
|
|
53
53
|
segment_enhanced['pct_change'] = (float(val_end / val_start - 1) if val_start != 0 else np.nan)
|
|
54
54
|
|
|
55
55
|
# Calculate days & cumulative total change
|
|
56
|
-
days =
|
|
56
|
+
days = segment['end'] - segment['start']
|
|
57
57
|
if days == 0:
|
|
58
58
|
days = 1 # edge case for 1 day flat between noise spike & trend
|
|
59
59
|
segment_enhanced['days'] = days # set days
|