How to remove outliers from multiple columns in pyspark using mean and standard deviation

Viewed 4381

I have the below data frame and I want to remove outliers from defined columns. In the below example price and income. Outliers should be removed for each group of data. In this example its 'cd' and 'segment' columns. Outliers should be removed based 5 standard deviations.

data = [
  ('a', '1',20,10),   
  ('a', '1',30,16),
  ('a', '1',50,91),
    ('a', '1',60,34),
    ('a', '1',200,23),
  ('a', '2',33,87),
  ('a', '2',86,90),
      ('a','2',89,35),
    ('a', '2',90,24),
    ('a', '2',40,97),
  ('a', '2',1,21),
  ('b', '1',45,96),   
  ('b', '1',56,99),
  ('b', '1',89,23),
    ('b', '1',98,64),
    ('b', '2',86,42),
  ('b', '2',45,54),
  ('b', '2',67,95),
      ('b','2',86,70),
    ('b', '2',91,64),
    ('b', '2',2,53),
  ('b', '2',4,87)
]
data = (spark.createDataFrame(data, ['cd','segment','price','income']))

I have used the code below to remove outliers but this would work only for one column.

mean_std = (
    data
    .groupBy('cd', 'segment')
    .agg(
      *[f.mean(colName).alias('{}{}'.format('mean_',colName)) for colName in ['price']],
      *[f.stddev(colName).alias('{}{}'.format('stddev_',colName)) for colName in ['price']])
)


mean_columns = ['mean_price']
std_columns = ['stddev_price']
upper = mean_std
for col_1 in mean_columns:
    for col_2 in std_columns:
      if col_1 != col_2:
        name = col_1 + '_upper_limit'
        upper = upper.withColumn(name, f.col(col_1) + f.col(col_2)*5)
        
lower = upper
for col_1 in mean_columns:
    for col_2 in std_columns:
      if col_1 != col_2:
        name = col_1 + '_lower_limit'
        lower = lower.withColumn(name, f.col(col_1) - f.col(col_2)*5)
        
outliers = (data.join(lower, 
                                how = 'left',
                                on = ['cd', 'segment'])
                           .withColumn('is_outlier_price', f.when((f.col('price')>f.col('mean_price_upper_limit')) |
                                                           (f.col('price')<f.col('mean_price_lower_limit')),1)
                                                      .otherwise(None))
           )

my final output should have a column for each variable stating whether its 1 = remove or 0 = keep.

Really appreciate any help on this.

2 Answers

Your code works almost 100% fine. All you have to do is to replace the single fixed column name with an array of column names and then loop over this array:

numeric_cols = ['price', 'income']
mean_std = \
    data \
    .groupBy('cd', 'segment') \
    .agg( \
      *[F.mean(colName).alias('mean_{}'.format(colName)) for colName in numeric_cols],\
      *[F.stddev(colName).alias('stddev_{}'.format(colName)) for colName in numeric_cols])

mean_std is now a dataframe with two columns (mean_... and stddev_...) per element of numeric_cols.

In the next step we calculate the lower and upper limit per element of numeric_cols:

mean_std_min_max = mean_std
for colName in numeric_cols:
    meanCol = 'mean_{}'.format(colName)
    stddevCol = 'stddev_{}'.format(colName)
    minCol = 'min_{}'.format(colName)
    maxCol = 'max_{}'.format(colName)
    mean_std_min_max = mean_std_min_max.withColumn(minCol, F.col(meanCol) - 5 * F.col(stddevCol))
    mean_std_min_max = mean_std_min_max.withColumn(maxCol, F.col(meanCol) + 5 * F.col(stddevCol))

mean_std_min_max now contains the two additional columns min_... and max... per element of numeric_cols.

Finally the join, followed by the calculation of the is_outliers_... columns as before:

outliers = data.join(mean_std_min_max, how = 'left', on = ['cd', 'segment'])
for colName in numeric_cols:
    isOutlierCol = 'is_outlier_{}'.format(colName)
    minCol = 'min_{}'.format(colName)
    maxCol = 'max_{}'.format(colName)
    meanCol = 'mean_{}'.format(colName)
    stddevCol = 'stddev_{}'.format(colName)
    outliers = outliers.withColumn(isOutlierCol, F.when((F.col(colName) > F.col(maxCol)) | (F.col(colName) < F.col(minCol)), 1).otherwise(0))    
    outliers = outliers.drop(minCol,maxCol, meanCol, stddevCol)

The last line of the loop is only to clean up and drop the intermediate columns. It might be helpful to comment it out.

The final result is:

+---+-------+-----+------+----------------+-----------------+
| cd|segment|price|income|is_outlier_price|is_outlier_income|
+---+-------+-----+------+----------------+-----------------+
|  b|      2|   86|    42|               0|                0|
|  b|      2|   45|    54|               0|                0|
|  b|      2|   67|    95|               0|                0|
|  b|      2|   86|    70|               0|                0|
|  b|      2|   91|    64|               0|                0|
+---+-------+-----+------+----------------+-----------------+
only showing top 5 rows

you can use the list comprehension using F.when. A very simplified example of your problem:

import pyspark.sql.functions as F
tst1= sqlContext.createDataFrame([(1,2,3,4,1,10),(1,3,5,7,2,11),(9,9,10,6,2,9),(2,4,90,9,1,2),(2,10,3,4,1,7),(3,5,11,5,7,8),(10,9,12,6,7,9),(3,6,99,8,1,9)],schema=['val1','val1_low_lim','val1_upper_lim','val2','val2_low_lim','val2_upper_lim'])
tst_res = tst1.select(tst1.columns+[(F.when((F.col(coln)<F.col(coln+'_upper_lim'))&(F.col(coln)>F.col(coln+'_low_lim')),1).otherwise(0)).alias(coln+'_valid') for coln in tst1.columns if "_lim" not in coln ])

The results:

tst_res.show()
+----+------------+--------------+----+------------+--------------+----------+----------+
|val1|val1_low_lim|val1_upper_lim|val2|val2_low_lim|val2_upper_lim|val1_valid|val2_valid|
+----+------------+--------------+----+------------+--------------+----------+----------+
|   1|           2|             3|   4|           1|            10|         0|         1|
|   1|           3|             5|   7|           2|            11|         0|         1|
|   9|           9|            10|   6|           2|             9|         0|         1|
|   2|           4|            90|   9|           1|             2|         0|         0|
|   2|          10|             3|   4|           1|             7|         0|         1|
|   3|           5|            11|   5|           7|             8|         0|         0|
|  10|           9|            12|   6|           7|             9|         1|         0|
|   3|           6|            99|   8|           1|             9|         0|         1|
+----+------------+--------------+----+------------+--------------+----------+----------+
Related