From ee9609cf525e468a0dd09aca3a94d22e78803183 Mon Sep 17 00:00:00 2001 From: Habiby Date: Mon, 8 Jun 2020 15:45:52 +0300 Subject: [PATCH 1/9] Added plot function v01. Fixed 2 bugs. --- tad/anomaly_detect_ts.py | 72 +++++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/tad/anomaly_detect_ts.py b/tad/anomaly_detect_ts.py index a042a46..4a03503 100755 --- a/tad/anomaly_detect_ts.py +++ b/tad/anomaly_detect_ts.py @@ -141,7 +141,7 @@ import datetime import statsmodels.api as sm import logging - +import matplotlib.pyplot as plt #this will be used for plotting. logger = logging.getLogger(__name__) @@ -155,7 +155,11 @@ def _handle_granularity_error(level): level : String the granularity that is below the min threshold """ - e_message = '%s granularity is not supported. Ensure granularity => minute or enable resampling' % level + + #improving the message as if user selects Timestamp, Dimension, Value sort of data then repeated timelines + #will cause issues with the module. Ideally, user should only supply single KPI for a single dimension with timestamp. + + e_message = '%s granularity is not supported. Ensure granularity => minute or enable resampling. Please check if you are using multiple dimensions with same timestamps in the data which cause repetition of same timestamps.' % level raise ValueError(e_message) @@ -426,7 +430,8 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N # validation assert isinstance(x, pd.Series), 'Data must be a series(Pandas.Series)' - assert x.values.dtype in [int, float], 'Values of the series must be number' + #changing below as apparantly the large integer data like int64 was not captured by below + assert x.values.dtype in [int, float, 'int64'], 'Values of the series must be number' assert x.index.dtype == np.dtype('datetime64[ns]'), 'Index of the series must be datetime' assert max_anoms <= 0.49 and max_anoms >= 0, 'max_anoms must be non-negative and less than 50% ' assert direction in ['pos', 'neg', 'both'], 'direction options: pos | neg | both' @@ -488,19 +493,47 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N 'plot': None } + ret_val = { + 'anoms': all_anoms, + 'expected': seasonal_plus_trend if e_value else None, + 'plot': 'TODO' if plot else None + } + if plot: # TODO additional refactoring and logic needed to support plotting - num_days_per_line + #num_days_per_line #breaks = _get_plot_breaks(granularity, only_last) # x_subset_week - raise Exception('TODO: Unsupported now') + + ret_plot = _plot_anomalies(data, ret_val) + ret_val['plot'] = ret_plot - return { - 'anoms': all_anoms, - 'expected': seasonal_plus_trend if e_value else None, - 'plot': 'TODO' if plot else None - } + + #raise Exception('TODO: Unsupported now') + return ret_val + +def _plot_anomalies(data, results): + """ + Tries to plot the data and the anomalies detected in this data. + + ArgsL + data: Time series on which we are performing the anomaly detection. (full data) + results: the results dictionary which contains anomalies grouped in the key called 'anoms' + """ + anoms = pd.DataFrame(results) + df_plot = pd.DataFrame(data).join(anoms, how='left') + #df_plot = df_plot.fillna(0) #if no anomaly, then we will plot a zero. can be improved. + df_plot['anoms'].unique() + plt.subplots(figsize=(14,6)) + plt.plot(df_plot['anoms'], color='r', marker='o', + label='Anomaly', linestyle="None") + plt.plot(data, label=data.name) + plt.title(data.name) + plt.legend(loc='best') + plt.grid(b=True) + #plt.show() + return plt def _detect_anoms(data, k=0.49, alpha=0.05, num_obs_per_period=None, use_decomp=True, use_esd=False, direction="pos", verbose=False): @@ -522,11 +555,26 @@ def _detect_anoms(data, k=0.49, alpha=0.05, num_obs_per_period=None, """ # validation + assert num_obs_per_period, "must supply period length for time series decomposition" assert direction in ['pos', 'neg', 'both'], 'direction options: pos | neg | both' - assert data.size >= num_obs_per_period * \ - 2, 'Anomaly detection needs at least 2 periods worth of data' + ########################################################################### + # Changing below code. If the data contains broken dates then the data.size may be less than observation periods + # so for such cases, we should return empty obsevations + ########################################################################### + #assert data.size >= num_obs_per_period * \ + # 2, 'Anomaly detection needs at least 2 periods worth of data' + if data.size < num_obs_per_period * 2: + return { + 'anoms': pd.Series(), #return empty series + 'stl': data #return untouched data... + } + # test case can be any data set which has large gapes in the dates. + # like data contains dates from year 2000 till 2020 but for 2001, 2001-01-01 till 2001-01-04 and then from 2001-06-01. + # this will break the obs_period and data.size check. So I have just removed anomaly detection for these small patches. + ########################################################################### + assert data[data.isnull( )].empty, 'Data contains NA. We suggest replacing NA with interpolated values before detecting anomaly' From 2c7dbae4e5390e7978eea6e99c32793a00b39ce1 Mon Sep 17 00:00:00 2001 From: Habiby Date: Mon, 8 Jun 2020 15:51:27 +0300 Subject: [PATCH 2/9] Requirements.txt updated with matplotlib. --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6552e43..b7aa2fd 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ numpy scipy pandas -statsmodels \ No newline at end of file +statsmodels +matplotlib \ No newline at end of file From d086211c545c49527506bb8844bacce344f8ef0d Mon Sep 17 00:00:00 2001 From: Habiby Date: Mon, 8 Jun 2020 16:09:31 +0300 Subject: [PATCH 3/9] emoved Assert blocks with Value/AttributeErrors --- tad/anomaly_detect_ts.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/tad/anomaly_detect_ts.py b/tad/anomaly_detect_ts.py index 4a03503..696c106 100755 --- a/tad/anomaly_detect_ts.py +++ b/tad/anomaly_detect_ts.py @@ -57,8 +57,7 @@ title: Title for the output plot. - verbose: Enable debug messages - + verbose: Enable debug messages resampling: whether ms or sec granularity should be resampled to min granularity. Defaults to False. @@ -155,7 +154,6 @@ def _handle_granularity_error(level): level : String the granularity that is below the min threshold """ - #improving the message as if user selects Timestamp, Dimension, Value sort of data then repeated timelines #will cause issues with the module. Ideally, user should only supply single KPI for a single dimension with timestamp. @@ -398,8 +396,9 @@ def _get_max_outliers(data, max_percent_anomalies): the input maximum number of anomalies per percent of data set values """ max_outliers = int(np.trunc(data.size * max_percent_anomalies)) - assert max_outliers, 'With longterm=True, AnomalyDetection splits the data into 2 week periods by default. You have {0} observations in a period, which is too few. Set a higher piecewise_median_period_weeks.'.format( - data.size) + if not max_outliers: + raise ValueError('With longterm=True, AnomalyDetection splits the data into 2 week periods by default. You have {0} observations in a period, which is too few. Set a higher piecewise_median_period_weeks.'.format( + data.size)) return max_outliers @@ -429,15 +428,23 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N logger.debug("The debug logs will be logged because verbose=%s", verbose) # validation - assert isinstance(x, pd.Series), 'Data must be a series(Pandas.Series)' + if isinstance(x, pd.Series) == False: + raise AttributeError('Data must be a series(Pandas.Series)') #changing below as apparantly the large integer data like int64 was not captured by below - assert x.values.dtype in [int, float, 'int64'], 'Values of the series must be number' - assert x.index.dtype == np.dtype('datetime64[ns]'), 'Index of the series must be datetime' - assert max_anoms <= 0.49 and max_anoms >= 0, 'max_anoms must be non-negative and less than 50% ' - assert direction in ['pos', 'neg', 'both'], 'direction options: pos | neg | both' - assert only_last in [None, 'day', 'hr'], 'only_last options: None | day | hr' - assert threshold in [None, 'med_max', 'p95', 'p99'], 'threshold options: None | med_max | p95 | p99' - assert piecewise_median_period_weeks >= 2, 'piecewise_median_period_weeks must be greater than 2 weeks' + if x.values.dtype not in [int, float, 'int64']: + raise ValueError('Values of the series must be number') + if x.index.dtype != np.dtype('datetime64[ns]'): + raise ValueError('Index of the series must be datetime') + if max_anoms > 0.49 or max_anoms < 0: + raise AttributeError('max_anoms must be non-negative and less than 50% ') + if direction not in ['pos', 'neg', 'both']: + raise AttributeError('direction options: pos | neg | both') + if only_last not in [None, 'day', 'hr']: + raise AttributeError('only_last options: None | day | hr') + if threshold not in [None, 'med_max', 'p95', 'p99']: + raise AttributeError('threshold options: None | med_max | p95 | p99') + if piecewise_median_period_weeks < 2: + raise AttributeError('piecewise_median_period_weeks must be greater than 2 weeks') logger.debug('Completed validation of input parameters') if alpha < 0.01 or alpha > 0.1: From 7e848588099c75f39664003db2f0bb1e68530cc5 Mon Sep 17 00:00:00 2001 From: Habiby Date: Mon, 8 Jun 2020 16:20:47 +0300 Subject: [PATCH 4/9] Fixed the whitespaces in line ends from Codacy --- tad/anomaly_detect_ts.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tad/anomaly_detect_ts.py b/tad/anomaly_detect_ts.py index 696c106..a7cb6aa 100755 --- a/tad/anomaly_detect_ts.py +++ b/tad/anomaly_detect_ts.py @@ -57,7 +57,8 @@ title: Title for the output plot. - verbose: Enable debug messages + verbose: Enable debug messages + resampling: whether ms or sec granularity should be resampled to min granularity. Defaults to False. @@ -154,7 +155,7 @@ def _handle_granularity_error(level): level : String the granularity that is below the min threshold """ - #improving the message as if user selects Timestamp, Dimension, Value sort of data then repeated timelines + #improving the message as if user selects Timestamp, Dimension, Value sort of data then repeated timelines #will cause issues with the module. Ideally, user should only supply single KPI for a single dimension with timestamp. e_message = '%s granularity is not supported. Ensure granularity => minute or enable resampling. Please check if you are using multiple dimensions with same timestamps in the data which cause repetition of same timestamps.' % level @@ -431,11 +432,11 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N if isinstance(x, pd.Series) == False: raise AttributeError('Data must be a series(Pandas.Series)') #changing below as apparantly the large integer data like int64 was not captured by below - if x.values.dtype not in [int, float, 'int64']: + if x.values.dtype not in [int, float, 'int64']: raise ValueError('Values of the series must be number') if x.index.dtype != np.dtype('datetime64[ns]'): raise ValueError('Index of the series must be datetime') - if max_anoms > 0.49 or max_anoms < 0: + if max_anoms > 0.49 or max_anoms < 0: raise AttributeError('max_anoms must be non-negative and less than 50% ') if direction not in ['pos', 'neg', 'both']: raise AttributeError('direction options: pos | neg | both') @@ -451,8 +452,9 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N logger.warning('alpha is the statistical significance and is usually between 0.01 and 0.1') data, period, granularity = _get_data_tuple(x, period_override, resampling) - if granularity is 'day': + if granularity == 'day': num_days_per_line = 7 + logger.info("Recording the variable in case plot function needs it. gran = day. {}".format(num_days_per_line)) only_last = 'day' if only_last == 'hr' else only_last max_anoms = _get_max_anoms(data, max_anoms) @@ -533,8 +535,7 @@ def _plot_anomalies(data, results): #df_plot = df_plot.fillna(0) #if no anomaly, then we will plot a zero. can be improved. df_plot['anoms'].unique() plt.subplots(figsize=(14,6)) - plt.plot(df_plot['anoms'], color='r', marker='o', - label='Anomaly', linestyle="None") + plt.plot(df_plot['anoms'], color='r', marker='o', label='Anomaly', linestyle="None") plt.plot(data, label=data.name) plt.title(data.name) plt.legend(loc='best') @@ -578,7 +579,7 @@ def _detect_anoms(data, k=0.49, alpha=0.05, num_obs_per_period=None, 'stl': data #return untouched data... } # test case can be any data set which has large gapes in the dates. - # like data contains dates from year 2000 till 2020 but for 2001, 2001-01-01 till 2001-01-04 and then from 2001-06-01. + # like data contains dates from year 2000 till 2020 but for 2001, 2001-01-01 till 2001-01-04 and then from 2001-06-01. # this will break the obs_period and data.size check. So I have just removed anomaly detection for these small patches. ########################################################################### From 3cdb4a67973334c6d26c61f64807b17aa9828122 Mon Sep 17 00:00:00 2001 From: Habiby Date: Mon, 8 Jun 2020 18:31:19 +0300 Subject: [PATCH 5/9] mproved plot. --- tad/anomaly_detect_ts.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tad/anomaly_detect_ts.py b/tad/anomaly_detect_ts.py index a7cb6aa..9e388c4 100755 --- a/tad/anomaly_detect_ts.py +++ b/tad/anomaly_detect_ts.py @@ -513,7 +513,6 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N #num_days_per_line #breaks = _get_plot_breaks(granularity, only_last) # x_subset_week - ret_plot = _plot_anomalies(data, ret_val) ret_val['plot'] = ret_plot @@ -534,14 +533,14 @@ def _plot_anomalies(data, results): df_plot = pd.DataFrame(data).join(anoms, how='left') #df_plot = df_plot.fillna(0) #if no anomaly, then we will plot a zero. can be improved. df_plot['anoms'].unique() - plt.subplots(figsize=(14,6)) - plt.plot(df_plot['anoms'], color='r', marker='o', label='Anomaly', linestyle="None") - plt.plot(data, label=data.name) - plt.title(data.name) - plt.legend(loc='best') - plt.grid(b=True) + f, ax = plt.subplots(figsize=(14,6)) + ax.plot(df_plot['anoms'], color='r', marker='o', label='Anomaly', linestyle="None") + ax.plot(data, label=data.name) + ax.set_title(data.name) + ax.legend(loc='best') + ax.grid(b=True) #plt.show() - return plt + return ax def _detect_anoms(data, k=0.49, alpha=0.05, num_obs_per_period=None, use_decomp=True, use_esd=False, direction="pos", verbose=False): From 71c6de9bf80c503faffa801db71fe78942d531f5 Mon Sep 17 00:00:00 2001 From: Habiby Date: Sun, 14 Jun 2020 13:01:30 +0300 Subject: [PATCH 6/9] README Improved --- README.md | 32 ++++++++ tad/anomaly_detect_ts.py | 34 +++++---- tests/test_detect_ts.py | 158 ++++++++++++++++++++------------------- 3 files changed, 134 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 6922d00..bcb4903 100755 --- a/README.md +++ b/README.md @@ -17,8 +17,40 @@ This repo aims for rewriting twitter's Anomaly Detection algorithms in Python, a pip3 install tad ``` +## Requirement + +1. The data should have the Index which is a datetime type. Single series is processed so only pass single numeric series at a time. +2. Plotting function is based on matplotlib, the plot is retured in the results if user wants to change any appearnaces etc. + ## Usage ``` import tad + +import pandas as pd +import matplotlib.pyplot as plt + +a = pd.DataFrame({'numeric_data_col1': [1,1, 1, 1, 1, 1, 10, 1, 1, 1, 1, 1, 1, 1]}, index=pd.to_datetime(['2020-01-01', '2020-01-02', '2020-01-03','2020-01-04','2020-01-05','2020-01-06','2020-01-07','2020-01-08','2020-01-09','2020-01-10','2020-01-11','2020-01-12','2020-01-13','2020-01-14'])) + +results = anomaly_detect_ts(a['numeric_data_col1'], + direction='both', alpha=0.02, max_anoms=0.20, + plot=True, longterm=True) +if results['plot']: #some anoms were detected and plot was also True. + plt.show() + ``` +results +{'anoms': 2020-01-14 1 + 2020-01-07 10 + dtype: int64, + 'expected': None, + 'plot': } + + + +Output shall be in the results dict + +results['anoms'] : contains the anomalies detected +results['plot']: contains a matplotlib plot if anoms were detected and plot was True +results['expected'] : tries to return expected values for certain dates. TODO: inconsistent as provides different outputs compared to anoms + diff --git a/tad/anomaly_detect_ts.py b/tad/anomaly_detect_ts.py index 9e388c4..fa4f3e2 100755 --- a/tad/anomaly_detect_ts.py +++ b/tad/anomaly_detect_ts.py @@ -58,10 +58,10 @@ title: Title for the output plot. verbose: Enable debug messages - - resampling: whether ms or sec granularity should be resampled to min granularity. + + resampling: whether ms or sec granularity should be resampled to min granularity. Defaults to False. - + period_override: Override the auto-generated period Defaults to None @@ -157,7 +157,7 @@ def _handle_granularity_error(level): """ #improving the message as if user selects Timestamp, Dimension, Value sort of data then repeated timelines #will cause issues with the module. Ideally, user should only supply single KPI for a single dimension with timestamp. - + e_message = '%s granularity is not supported. Ensure granularity => minute or enable resampling. Please check if you are using multiple dimensions with same timestamps in the data which cause repetition of same timestamps.' % level raise ValueError(e_message) @@ -328,20 +328,26 @@ def _get_only_last_results(data, all_anoms, granularity, only_last): only_last : string day | hr The subset of anomalies to be returned """ - start_date = data.index[-1] - datetime.timedelta(days=7) + + #Unused variables start_date and x_subset_week were commented by aliasgherman + # on 2020-06-13 as the plot logic does not utilize them for now. + #start_date = data.index[-1] - datetime.timedelta(days=7) start_anoms = data.index[-1] - datetime.timedelta(days=1) if only_last == 'hr': # We need to change start_date and start_anoms for the hourly only_last option - start_date = datetime.datetime.combine( - (data.index[-1] - datetime.timedelta(days=2)).date(), datetime.time.min) + #start_date = datetime.datetime.combine( + # (data.index[-1] - datetime.timedelta(days=2)).date(), datetime.time.min) start_anoms = data.index[-1] - datetime.timedelta(hours=1) # subset the last days worth of data x_subset_single_day = data.loc[data.index > start_anoms] # When plotting anoms for the last day only we only show the previous weeks data - x_subset_week = data.loc[lambda df: ( - df.index <= start_anoms) & (df.index > start_date)] + ## Below was commented out by aliasgherman as the plot logic (v001) + ## does not use this variable and plots whole dataset. + ##x_subset_week = data.loc[lambda df: ( + ## df.index <= start_anoms) & (df.index > start_date)] + # return all_anoms.loc[all_anoms.index >= x_subset_single_day.index[0]] @@ -430,7 +436,7 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N # validation if isinstance(x, pd.Series) == False: - raise AttributeError('Data must be a series(Pandas.Series)') + raise AssertionError('Data must be a series(Pandas.Series)') #changing below as apparantly the large integer data like int64 was not captured by below if x.values.dtype not in [int, float, 'int64']: raise ValueError('Values of the series must be number') @@ -460,7 +466,7 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N max_anoms = _get_max_anoms(data, max_anoms) # If longterm is enabled, break the data into subset data frames and store in all_data - all_data = _process_long_term_data(data, period, granularity, piecewise_median_period_weeks) if longterm else [data] + all_data = _process_long_term_data(data, period, granularity, piecewise_median_period_weeks) if longterm else [data] all_anoms = pd.Series() seasonal_plus_trend = pd.Series() @@ -516,7 +522,7 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N ret_plot = _plot_anomalies(data, ret_val) ret_val['plot'] = ret_plot - + #raise Exception('TODO: Unsupported now') return ret_val @@ -524,7 +530,7 @@ def anomaly_detect_ts(x, max_anoms=0.1, direction="pos", alpha=0.05, only_last=N def _plot_anomalies(data, results): """ Tries to plot the data and the anomalies detected in this data. - + ArgsL data: Time series on which we are performing the anomaly detection. (full data) results: the results dictionary which contains anomalies grouped in the key called 'anoms' @@ -581,7 +587,7 @@ def _detect_anoms(data, k=0.49, alpha=0.05, num_obs_per_period=None, # like data contains dates from year 2000 till 2020 but for 2001, 2001-01-01 till 2001-01-04 and then from 2001-06-01. # this will break the obs_period and data.size check. So I have just removed anomaly detection for these small patches. ########################################################################### - + assert data[data.isnull( )].empty, 'Data contains NA. We suggest replacing NA with interpolated values before detecting anomaly' diff --git a/tests/test_detect_ts.py b/tests/test_detect_ts.py index e9ce44c..684c462 100755 --- a/tests/test_detect_ts.py +++ b/tests/test_detect_ts.py @@ -24,22 +24,22 @@ def setUp(self): self.data1 = pd.read_csv(TEST_DATA_DIR / 'test_data_1.csv', index_col='timestamp', parse_dates=True, squeeze=True, date_parser=self.dparserfunc) - + self.data2 = pd.read_csv(TEST_DATA_DIR / 'test_data_2.csv', index_col='timestamp', parse_dates=True, squeeze=True, date_parser=self.dparserfunc) - + self.data3 = pd.read_csv(TEST_DATA_DIR / 'test_data_3.csv', index_col='timestamp', parse_dates=True, squeeze=True, date_parser=self.dparserfunc) - + self.data4 = pd.read_csv(TEST_DATA_DIR / 'test_data_4.csv', index_col='timestamp', parse_dates=True, squeeze=True, date_parser=self.dparserfunc) - + self.data5 = pd.read_csv(TEST_DATA_DIR / 'test_data_5.csv', index_col='timestamp', parse_dates=True, squeeze=True, - date_parser=self.dparserfunc) + date_parser=self.dparserfunc) def get_test_value(self, raw_value): return np.float64(raw_value) @@ -51,9 +51,9 @@ def test_anomaly_detect_ts_1(self): results = anomaly_detect_ts(self.data1, direction='both', alpha=0.05, plot=False, longterm=True) - values = results['anoms'].get_values() + values = results['anoms'].array - self.assertEquals(132, len(values)) + self.assertEqual(132, len(values)) self.assertTrue(self.get_test_value(40.0) in values) self.assertTrue(self.get_test_value(250.0) in values) self.assertTrue(self.get_test_value(210.0) in values) @@ -74,82 +74,82 @@ def test_anomaly_detect_ts_1(self): self.assertTrue(self.get_test_value(151.549) in values) self.assertTrue(self.get_test_value(147.028) in values) self.assertTrue(self.get_test_value(31.2614) in values) - + def test_anomaly_detect_ts_2(self): results = anomaly_detect_ts(self.data2, direction='both', alpha=0.02, max_anoms=0.02, plot=False, longterm=True) - values = results['anoms'].get_values() - - self.assertEquals(2, len(values)) + values = results['anoms'].array + + self.assertEqual(2, len(values)) self.assertTrue(self.get_test_value(-549.97419676451) in values) self.assertTrue(self.get_test_value(-3241.79887765979) in values) - + def test_anomaly_detect_ts_3(self): results = anomaly_detect_ts(self.data3, direction='both', alpha=0.02, max_anoms=0.02, plot=False, longterm=True) - values = results['anoms'].get_values() - - self.assertEquals(6, len(values)) + values = results['anoms'].array + + self.assertEqual(6, len(values)) self.assertTrue(self.get_test_value(677.306772096232) in values) self.assertTrue(self.get_test_value(3003.3770260296196) in values) - self.assertTrue(self.get_test_value(375.68211544563) in values) + self.assertTrue(self.get_test_value(375.68211544563) in values) self.assertTrue(self.get_test_value(4244.34731650009) in values) self.assertTrue(self.get_test_value(2030.44357652981) in values) self.assertTrue(self.get_test_value(4223.461867236129) in values) - + def test_anomaly_detect_ts_4(self): results = anomaly_detect_ts(self.data4, direction='both', alpha=0.02, max_anoms=0.02, plot=False, longterm=True) - values = results['anoms'].get_values() - - self.assertEquals(1, len(values)) + values = results['anoms'].array + + self.assertEqual(1, len(values)) self.assertTrue(self.get_test_value(-1449.62440286) in values) - + def test_anomaly_detect_ts_5(self): results = anomaly_detect_ts(self.data5, direction='both', alpha=0.02, max_anoms=0.02, plot=False, longterm=True) - values = results['anoms'].get_values() - - self.assertEquals(4, len(values)) + values = results['anoms'].array + + self.assertEqual(4, len(values)) self.assertTrue(self.get_test_value(-3355.47215640248) in values) self.assertTrue(self.get_test_value(941.905602754994) in values) self.assertTrue(self.get_test_value(-2428.98882200991) in values) self.assertTrue(self.get_test_value(-1263.4494013677302) in values) - + def test_detect_anoms(self): shesd = _detect_anoms(self.data1, k=0.02, alpha=0.05, num_obs_per_period=1440, use_decomp=True, use_esd=False, direction='both') - self.assertEquals(133, len(shesd['anoms'])) - + self.assertEqual(133, len(shesd['anoms'])) + def test__detect_anoms_pos(self): shesd = _detect_anoms(self.data1, k=0.02, alpha=0.05, num_obs_per_period=1440, use_decomp=True, use_esd=False, direction='pos') - self.assertEquals(50, len(shesd['anoms'])) + self.assertEqual(50, len(shesd['anoms'])) def test__detect_anoms_neg(self): shesd = _detect_anoms(self.data1, k=0.02, alpha=0.05, num_obs_per_period=1440, use_decomp=True, use_esd=False, direction='neg') - self.assertEquals(85, len(shesd['anoms'])) + self.assertEqual(85, len(shesd['anoms'])) def test__detect_anoms_use_decomp_false(self): shesd = _detect_anoms(self.data1, k=0.02, alpha=0.05, num_obs_per_period=1440, use_decomp=False, use_esd=False, direction='both') - self.assertEquals(133, len(shesd['anoms'])) + self.assertEqual(133, len(shesd['anoms'])) def test__detect_anoms_no_num_obs_per_period(self): - with self.assertRaises(AssertionError): + with self.assertRaises(AssertionError): _detect_anoms(self.data1, k=0.02, alpha=0.05, num_obs_per_period=None, use_decomp=False, use_esd=False, @@ -160,125 +160,131 @@ def test__detect_anoms_use_esd_true(self): num_obs_per_period=1440, use_decomp=True, use_esd=True, direction='both') - self.assertEquals(133, len(shesd['anoms'])) - + self.assertEqual(133, len(shesd['anoms'])) + def test_anomaly_detect_ts_last_only_none(self): results = anomaly_detect_ts(self.data1, max_anoms=0.02, direction='both', only_last=None, plot=False) - self.assertEquals(132, len(results['anoms'])) + self.assertEqual(132, len(results['anoms'])) def test_anomaly_detect_ts_last_only_day(self): results = anomaly_detect_ts(self.data1, max_anoms=0.02, direction='both', only_last='day', plot=False) - self.assertEquals(23, len(results['anoms'])) + self.assertEqual(23, len(results['anoms'])) def test_anomaly_detect_ts_last_only_hr(self): results = anomaly_detect_ts(self.data1, max_anoms=0.02, direction='both', only_last='hr', plot=False) - values = results['anoms'].get_values() - - self.assertEquals(3, len(values)) + values = results['anoms'].array + + self.assertEqual(3, len(values)) self.assertTrue(self.get_test_value(40.0) in values) self.assertTrue(self.get_test_value(250.0) in values) - self.assertTrue(self.get_test_value(210.0) in values) - + self.assertTrue(self.get_test_value(210.0) in values) + def test_anomaly_detect_ts_pos_only(self): results = anomaly_detect_ts(self.data1, max_anoms=0.02, - direction='pos', + direction='pos', only_last=None, plot=False) - self.assertEquals(50, len(results['anoms'])) - + self.assertEqual(50, len(results['anoms'])) + def test_anomaly_detect_ts_neg_only(self): results = anomaly_detect_ts(self.data1, max_anoms=0.02, - direction='neg', + direction='neg', only_last=None, plot=False) - self.assertEquals(84, len(results['anoms'])) + self.assertEqual(84, len(results['anoms'])) def test_anomaly_detect_ts_med_max_threshold(self): results = anomaly_detect_ts(self.data1, max_anoms=0.02, direction='both', threshold='med_max', only_last=None, plot=False) - values = results['anoms'].get_values() + values = results['anoms'].array - self.assertEquals(4, len(values)) + self.assertEqual(4, len(values)) self.assertTrue(self.get_test_value(203.231) in values) self.assertTrue(self.get_test_value(203.90099999999998) in values) - self.assertTrue(self.get_test_value(250.0) in values) - self.assertTrue(self.get_test_value(210.0) in values) + self.assertTrue(self.get_test_value(250.0) in values) + self.assertTrue(self.get_test_value(210.0) in values) def test_anomaly_detect_ts_longterm(self): results = anomaly_detect_ts(self.data1, max_anoms=0.02, direction='both', threshold=None, only_last=None, longterm=True) - self.assertEquals(132, len(results['anoms'])) + self.assertEqual(132, len(results['anoms'])) def test_anomaly_detect_ts_piecewise_median_period_weeks(self): results = anomaly_detect_ts(self.data1, max_anoms=0.02, piecewise_median_period_weeks=4, direction='both', threshold=None, only_last=None, longterm=False) - self.assertEquals(132, len(results['anoms'])) + self.assertEqual(132, len(results['anoms'])) def test_invalid_data_parameter(self): - with self.assertRaises(AssertionError): + with self.assertRaises(AssertionError): anomaly_detect_ts(['invalid'], max_anoms=0.02, direction='both', threshold=None, only_last=None, longterm=False) def test_invalid_piecewise_median_period_weeks(self): - with self.assertRaises(AssertionError): + with self.assertRaises(AssertionError): anomaly_detect_ts(['invalid'], max_anoms=0.02, piecewise_median_period_weeks=1, direction='both', threshold=None, only_last=None, longterm=False, plot=False) - + def test_get_data_tuple(self): d_tuple = _get_data_tuple(self.data1, 24, None) raw_data = d_tuple[0] period = d_tuple[1] granularity = d_tuple[2] - - self.assertTrue(isinstance(raw_data, Series)) + + self.assertTrue(isinstance(raw_data, Series)) self.assertTrue(isinstance(period, int)) - self.assertTrue(isinstance(granularity, str)) - - self.assertEquals(24, period) - self.assertEquals('min', granularity) - self.assertEquals(14398, len(raw_data)) - + self.assertTrue(isinstance(granularity, str)) + + self.assertEqual(24, period) + self.assertEqual('min', granularity) + self.assertEqual(14398, len(raw_data)) + def test_get_max_outliers(self): - self.assertEquals(719, _get_max_outliers(self.data1, 0.05)) - + self.assertEqual(719, _get_max_outliers(self.data1, 0.05)) + def test_get_decomposed_data_tuple(self): data, smoothed_data = _get_decomposed_data_tuple(self.data1, 1440) self.assertTrue(isinstance(data, Series)) self.assertTrue(isinstance(smoothed_data, Series)) - self.assertEquals(14398, len(data)) - self.assertEquals(14398, len(smoothed_data)) - + self.assertEqual(14398, len(data)) + self.assertEqual(14398, len(smoothed_data)) + def test_perform_threshold_filter(self): results = anomaly_detect_ts(self.data1, max_anoms=0.02, direction='both', only_last=None, plot=False) periodic_max = self.data1.resample('1D').max() filtered_results = _perform_threshold_filter(results['anoms'], periodic_max, 'med_max') self.assertTrue(isinstance(filtered_results, Series)) - self.assertEquals(4, len(filtered_results)) - + self.assertEqual(4, len(filtered_results)) + def test_get_plot_breaks(self): - self.assertEquals(36, _get_plot_breaks('day', 'day')) - self.assertEquals(12, _get_plot_breaks('min', 'day')) - self.assertEquals(3, _get_plot_breaks('min', 'min')) - + self.assertEqual(36, _get_plot_breaks('day', 'day')) + self.assertEqual(12, _get_plot_breaks('min', 'day')) + self.assertEqual(3, _get_plot_breaks('min', 'min')) + def test_get_only_last_results(self): - results = anomaly_detect_ts(self.data1, max_anoms=0.02, direction='both', + results = anomaly_detect_ts(self.data1, max_anoms=0.02, direction='both', only_last=None, plot=False) last_day = _get_only_last_results(self.data1, results['anoms'], 'min', 'day') last_hr = _get_only_last_results(self.data1, results['anoms'], 'min', 'hr') - self.assertEquals(23, len(last_day)) - self.assertEquals(3, len(last_hr)) + self.assertEqual(23, len(last_day)) + self.assertEqual(3, len(last_hr)) + + def test_plot_data_files(self): + results = anomaly_detect_ts(self.data1, + direction='both', alpha=0.02, max_anoms=0.35, + plot=True, longterm=True) + self.assertEqual("count", results['plot'].get_title()) #just checking that the plot was returned for the test file if __name__ == '__main__': unittest.main() From d6431aee8bc576dd4e960681901d1bebb63fc6e9 Mon Sep 17 00:00:00 2001 From: Ali Asgher Mansoor Habiby Date: Sun, 14 Jun 2020 18:55:11 +0300 Subject: [PATCH 7/9] Readme updated, test cases updated --- README.md | 6 ++++++ resources/images/sample_01.png | Bin 0 -> 36977 bytes resources/images/sample_execution.png | Bin 0 -> 15874 bytes 3 files changed, 6 insertions(+) create mode 100644 resources/images/sample_01.png create mode 100644 resources/images/sample_execution.png diff --git a/README.md b/README.md index bcb4903..9b24727 100755 --- a/README.md +++ b/README.md @@ -54,3 +54,9 @@ results['anoms'] : contains the anomalies detected results['plot']: contains a matplotlib plot if anoms were detected and plot was True results['expected'] : tries to return expected values for certain dates. TODO: inconsistent as provides different outputs compared to anoms +![Sample Script output](/resources/images/sample_execution.png) + + +## Other Sample Images + +![Another sample of detecction using default parameters](/resources/images/sample_01.png) \ No newline at end of file diff --git a/resources/images/sample_01.png b/resources/images/sample_01.png new file mode 100644 index 0000000000000000000000000000000000000000..569a213108305078e66016007032b0d4d1b64cb5 GIT binary patch literal 36977 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 J-+A=n{{zlsFr)wg literal 0 HcmV?d00001 From 49686486d0697cd09fbd3ec0e68b714a4fe0e217 Mon Sep 17 00:00:00 2001 From: Ali Asgher Mansoor Habiby Date: Sun, 14 Jun 2020 19:50:34 +0300 Subject: [PATCH 8/9] Small fixes --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b24727..65af300 100755 --- a/README.md +++ b/README.md @@ -30,10 +30,16 @@ import tad import pandas as pd import matplotlib.pyplot as plt -a = pd.DataFrame({'numeric_data_col1': [1,1, 1, 1, 1, 1, 10, 1, 1, 1, 1, 1, 1, 1]}, index=pd.to_datetime(['2020-01-01', '2020-01-02', '2020-01-03','2020-01-04','2020-01-05','2020-01-06','2020-01-07','2020-01-08','2020-01-09','2020-01-10','2020-01-11','2020-01-12','2020-01-13','2020-01-14'])) +a = pd.DataFrame({'numeric_data_col1': + [1,1, 1, 1, 1, 1, 10, 1, 1, 1, 1, 1, 1, 1]}, + index=pd.to_datetime(['2020-01-01', '2020-01-02', '2020-01-03', + '2020-01-04', '2020-01-05','2020-01-06','2020-01-07','2020-01-08', + '2020-01-09','2020-01-10','2020-01-11','2020-01-12','2020-01-13', + '2020-01-14'])) results = anomaly_detect_ts(a['numeric_data_col1'], - direction='both', alpha=0.02, max_anoms=0.20, + direction='both', alpha=0.02, + max_anoms=0.20, plot=True, longterm=True) if results['plot']: #some anoms were detected and plot was also True. plt.show() @@ -51,7 +57,9 @@ results Output shall be in the results dict results['anoms'] : contains the anomalies detected + results['plot']: contains a matplotlib plot if anoms were detected and plot was True + results['expected'] : tries to return expected values for certain dates. TODO: inconsistent as provides different outputs compared to anoms ![Sample Script output](/resources/images/sample_execution.png) From 3845f9aa7dc64d71cafc8521762d038613512f51 Mon Sep 17 00:00:00 2001 From: Ali Asgher Mansoor Habiby Date: Sun, 14 Jun 2020 20:06:55 +0300 Subject: [PATCH 9/9] Codacy Fixes --- README.md | 15 +++++---------- tad/anomaly_detect_ts.py | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 65af300..6bc416c 100755 --- a/README.md +++ b/README.md @@ -10,17 +10,15 @@ Although there are some repos for python to run twitter's anomaly detection algo This repo aims for rewriting twitter's Anomaly Detection algorithms in Python, and providing same functions for user. - ## Install ``` pip3 install tad ``` - ## Requirement -1. The data should have the Index which is a datetime type. Single series is processed so only pass single numeric series at a time. -2. Plotting function is based on matplotlib, the plot is retured in the results if user wants to change any appearnaces etc. +1.The data should have the Index which is a datetime type. Single series is processed so only pass single numeric series at a time. +2.Plotting function is based on matplotlib, the plot is retured in the results if user wants to change any appearnaces etc. ## Usage @@ -52,19 +50,16 @@ results 'expected': None, 'plot': } - - Output shall be in the results dict -results['anoms'] : contains the anomalies detected +results.anoms shall contain the anomalies detected -results['plot']: contains a matplotlib plot if anoms were detected and plot was True +results.plot shall contain a matplotlib plot if anoms were detected and plot was True -results['expected'] : tries to return expected values for certain dates. TODO: inconsistent as provides different outputs compared to anoms +results.expected tries to return expected values for certain dates. TODO: inconsistent as provides different outputs compared to anoms ![Sample Script output](/resources/images/sample_execution.png) - ## Other Sample Images ![Another sample of detecction using default parameters](/resources/images/sample_01.png) \ No newline at end of file diff --git a/tad/anomaly_detect_ts.py b/tad/anomaly_detect_ts.py index fa4f3e2..0316905 100755 --- a/tad/anomaly_detect_ts.py +++ b/tad/anomaly_detect_ts.py @@ -539,7 +539,7 @@ def _plot_anomalies(data, results): df_plot = pd.DataFrame(data).join(anoms, how='left') #df_plot = df_plot.fillna(0) #if no anomaly, then we will plot a zero. can be improved. df_plot['anoms'].unique() - f, ax = plt.subplots(figsize=(14,6)) + _, ax = plt.subplots(figsize=(14,6)) ax.plot(df_plot['anoms'], color='r', marker='o', label='Anomaly', linestyle="None") ax.plot(data, label=data.name) ax.set_title(data.name)