이번 포스팅에서는 Python pandas의 문자열 Series에서 문자열 패턴 매칭을 통해서 특정 패턴이 포함되어 있는지 여부를 확인하고, 특정 매칭을 포함한 데이터를 가져오는 방법을 소개하겠습니다. 

 

(1) pandas 문자열 Series에서 한개의 문자열 패턴 매칭하기 
     : Series.str.contains(pattern)

(2) pandas DataFrame에서 한개의 문자열 패턴 매칭이 되는 데이터 가져오기

(3) pandas DataFrame에서 여러개의 문자열 패턴 매팅이 되는 데이터 가져오기

 

 

먼저, 예제로 사용할 문자열이 포함된 DataFrame을 만들어보겠습니다. pandas 의 contains() 함수를 사용해서 문자열 뿐만 아니라 NaN 값과 '1004' 숫자도 포함시켜서 문자열 매칭 시 처리방법을 소개하겠습니다. 

 

## importing modules
import numpy as np
import pandas as pd

## creating a pandas DataFrame with strings, NaN, digit
df = pd.DataFrame({
    'id': [1, 2, 3, 4, 5, 6, 7]
    , 'fruit': ['apple', 'PERSIMON', 'grapes', 'mango', 'peach and perl', 
                np.NaN, 
                '1004']
})


print(df)
#    id           fruit
# 0   1           apple
# 1   2        PERSIMON
# 2   3          grapes
# 3   4           mango
# 4   5  peach and perl
# 5   6             NaN
# 6   7            1004

 

 

 

(1) pandas 문자열 Series에서 한개의 문자열 패턴 매칭하기: Series.str.contains(pattern)

 

먼저, 위에서 만든 DataFrame에서 'fruit' 칼럼만 가져와서 's1' 이라는 이름의 Series 를 만들어보겠습니다. 

 

## pandas Series
s1 = df['fruit']

print(type(s1)) # padnas.Series
print(s1)

# <class 'pandas.core.series.Series'>
# 0             apple
# 1          PERSIMON
# 2            grapes
# 3             mango
# 4    peach and perl
# 5               NaN
# 6              1004
# Name: fruit, dtype: object

 

 

pandas 의 contains() 메소드는 '문자열 Series (Series of a string)' 을 대상으로 문자열 매칭을 해서 Boolean Series 를 반환합니다. contains() 메소드의 구문은 아래와 같습니다. 

 

Series.str.contains(pattern, case=True, flags=0, na=None, regex=True)

 

이때 Series.str.contains() 메소드는 문자열 Series에 대하여 패턴 매칭(pattern matching)을 할 때 문자열 그 자체(literal itself)와 함께 정규표현식(regex=True: regular expression)까지도 사용해서 패턴 매칭을 할 수 있으며, '대/소문자 구분 (case=True: case sensitive)하며, 'NaN' 값에 대해서는 'NaN'을 반환(na=None)합니다. 

 

아래 예에서는 문자열 Series 's1'에 대해서 문자열 'pe'가 들어있는 패턴 매칭을 해서 Boolean Series 를 반환한 예입니다. (대소문자 구분, NaN은  NaN 반환) 

 

## returning a Series of Booleans using a literal pattern
s1.str.contains('pe')

# 0    False
# 1    False   # <-- case sensitive
# 2     True
# 3    False
# 4     True
# 5      NaN   # <-- returning NaN for NaN values
# 6    False
# Name: fruit, dtype: object

 

 

 

(2) pandas DataFrame에서 한개의 문자열 패턴 매칭이 되는 데이터 가져오기

 

pandas DataFrame에서 특정 문자열 칼럼에 대해서 문자열 패턴 매칭한 결과인 Boolean Series 를 이용해서 해당 행의 값만 가져올 수 있습니다. 이때 만약 문자열 패턴 매칭 결과 Boolean Seires 에 NaN 값이 포함되어 있을 경우 아래와 같은 ValueError 가 발생합니다. 

 

ValueError: Cannot mask with non-boolean array containing NA / NaN valu

 

 ## ValueError: Cannot mask with non-boolean array containing NA / NaN values
df[df['fruit'].str.contains('pe')]

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-5-ee5e3bc73f2f> in <module>
      1 ## ValueError: Cannot mask with non-boolean array containing NA / NaN values
----> 2 df[s1.str.contains('pe')]

~/opt/anaconda3/lib/python3.8/site-packages/pandas/core/frame.py in __getitem__(self, key)
   2890 
   2891         # Do we have a (boolean) 1d indexer?
-> 2892         if com.is_bool_indexer(key):
   2893             return self._getitem_bool_array(key)
   2894 

~/opt/anaconda3/lib/python3.8/site-packages/pandas/core/common.py in is_bool_indexer(key)
    132                 na_msg = "Cannot mask with non-boolean array containing NA / NaN values"
    133                 if isna(key).any():
--> 134                     raise ValueError(na_msg)
    135                 return False
    136             return True

ValueError: Cannot mask with non-boolean array containing NA / NaN values

 

 

'ValueError: Cannot mask with non-boolean array containing NA/NaN values' 를 해결하기 위해서는 Series.str.contains(pattern, na=False) 처럼 NaN 값을 Boolean의 'False'로 설정해주면 됩니다. 

 

## Specifying na to be False instead of NaN replaces NaN values with False.
df[df['fruit'].str.contains('pe'
                   , na=False) # specifying NA to be False
  ]

#  id	fruit
# 2	3	grapes
# 4	5	peach and perl

 

 

만약 문자열 매칭을 할 때 '대/소문자 구분없이 (case insensitive)' 하려면 'case=False' 옵션을 설정해주면 됩니다. 

아래 예에서는 case=False 로 설정한 상태에서 'pe' 문자열 매칭을 했더니 'PERSIMON' 대문자도 매칭이 되어서 가져오기가 되었습니다. 

 

## Specifying case sensitivity using case.
df[df['fruit'].str.contains('pe'
                   , na=False
                   , case=False) # case = False
  ] 

#   id	fruit
# 1	2	PERSIMON   # <-- case insensitive
# 2	3	grapes
# 4	5	peach and perl

 

 

Series.str.contains() 함수에는 정규표현식(regex=True: Regular Expression)을 사용해서 문자열 매칭을 할 수 있습니다.  아래의 예에서는 정규표현식을 이용해서 '숫자가 포함된 ('\\d' : returning any digits)' 문자열을 가져와보겠습니다. 

 

## returning any digit using regular expression
df[df['fruit'].str.contains(
    '\\d'        # returning any digit
    , regex=True # using regular expression
    , na=False
    )
  ]

#   id	fruit
# 6	7	1004

 

 

 

(3) pandas DataFrame에서 여러개의 문자열 패턴 매팅이 되는 데이터 가져오기

 

이번에는 문자열 매칭을 할 때 '여러개의 문자열 패턴 (multiple strings of pattern)' 과 매칭되는 문자열을 확인하고, pandas DataFrame으로 부터 해당 행의 데이터를 가져와보겠습니다. 

 

여러개의 문자열 패턴을 표현할 때 '|' 가 'or' 를 나타냅니다. 아래의 예의 경우, ['ap' or 'ma' or 'gr'] 이 포함된 문자열을 매칭해서 Boolean String을 반환하고 싶을 때 ['ap'|'ma'|'gr'] 을 패턴으로 입력해주면 됩니다. Python의 내장함수(built-in function) 중에서 join() 메소드를 이용하면 여러개의 문자열을 '|' 구분자(separator)를 넣어서 하나의 문자열로 묶어줄 수 있습니다. ('|'.join(['ap', 'ma', 'gr']) 은 ==> 'ap|ma|gr' 을 반환하며, ==> ['ap' or 'ma' or 'gr'] 을 의미함)

 

## join() method joins all itmes in a tuple into a string with a separartor
'|'.join(['ap', 'ma', 'gr'])

# 'ap|ma|gr'


## Returning ‘apple’ or ‘mango’ or 'grapes' 
## when either expression occurs in a string.
s1.str.contains(
    '|'.join(['ap', 'ma', 'gr']) # 'ap|ma|gr', ie. 'ap' or 'ma' or 'gr'
    , na=False
    , case=False
)

# 0     True
# 1    False
# 2     True
# 3     True
# 4    False
# 5    False
# 6    False
# Name: fruit, dtype: bool

 

 

이제 pandas DataFrame 에서 'fruit' 칼럼에서 'ap' or 'ma' or 'gr' 문자열이 포함되어 있는 모든 행을 가져와보겠습니다. 

 

## indexing data using a Series of Booleans 
df[df['fruit'].str.contains(
    '|'.join(['ap', 'ma', 'gr'])
    , na=False
    , case=False
    )
  ]

#   id	fruit
# 0	1	apple
# 2	3	grapes
# 3	4	mango

 

 

[ Reference ]

[1] pandas.Series.str.contains()
    : https://pandas.pydata.org/docs/reference/api/pandas.Series.str.contains.html

 

 

이번 포스팅이 많은 도움이 되었기를 바랍니다. 

행복한 데이터 과학자 되세요!  :-)

 

반응형
Posted by Rfriend

댓글을 달아 주세요

지난번 포스팅에서는 두 개의 sf 클래스 객체의 지리 벡터 데이터 테이블을 R dplyr 패키지의 함수를 사용하여 Mutating Joins, Filtering Joins, Nesting Joins 하는 방법을 소개하였습니다(rfriend.tistory.com/625). 

 

이번 포스팅에서는 여기서 특수한 경우로 조금 더 깊이 들어가서, 두 테이블을 Join 하는 기준이 되는 Key 칼럼이 문자열로 되어 있고, 데이터 표준화가 미흡한 문제로 인해 정확하게 매칭이 안되어서 Join 이 안되는 경우에, R의 stringr 패키지를 사용해 정규 표현식의 문자열 매칭(a string matching using regular expression)으로 Key 값을 변환하여 두 테이블을 Join 하는 방법을 소개하겠습니다. 

 

 

 

먼저, 전세계 국가별 지리기하와 속성 정보를 모아놓은 sf 클래스 객체의 지리 벡터 데이터셋인 "world" 와, 2016년과 2017년 국가별 커피 생산량을 집계한 data frame 인 "coffee_data" 의 두 개 데이터셋을 spData 로 부터 가져오겠습니다. 

그리고 두 개 테이블 Join 을 위해 dplyr 패키지를 불러오고, 정규 표현식을 이용한 문자열 매칭을 위해 stringr 패키지를 불러오겠습니다. 

 

"world" 데이터셋은 177개의 행(국가)과 11개의 열(속성(attritubes)과 지리기하 칼럼(gemgraphy column)) 으로 이루어져 있습니다. "coffee_data"는 47개의 행과 3개의 열로 구성되어 있습니다. 

 

## =========================================================
## inner join using a string matching
## - reference: https://geocompr.robinlovelace.net/attr.html
## =========================================================

library(sf)
library(spData)
library(dplyr)
library(stringr) # for a string matching

## -- two geography vector dataset tables : world, coffee_data
## -- (a) world: World country pologons in spData
names(world)
# [1] "iso_a2"  "name_long" "continent" "region_un" "subregion" "type"  "area_km2"  "pop"  "lifeExp"   "gdpPercap"
# [11] "geom"

dim(world)
# [1] 177  11


## -- (b) coffee_data: World coffee productiond data in spData
## : estimated values for coffee production in units of 60-kg bags in each year
names(coffee_data)
# [1] "name_long"      "coffee_production_2016" "coffee_production_2017"

dim(coffee_data)
# [1] 47  3

 

 

(1) 두 테이블 inner join 하기: inner_join(x, y, by)

 

"world"와 "coffee_data"의 두개 데이터 테이블을 inner join 해보면 45개의 행(즉, 국가)과 13개의 열(= "world"로 부터 11개의 칼럼 + "coffee_data"로 부터 2개의 칼럼) 으로 이루어진 Join 결과를 반환합니다. 

위에서 "coffee_data" 데이터셋이 47개의 행으로 이루어졌다고 했는데요, inner join 한 결과는 행이 45개로서 2개가 서로 차이가 나는군요. 

 

## -- inner join
world_coffee_inner = inner_join(x = world, 
                                y = coffee_data, 
                                by = "name_long")

## or shortly
world_coffee_inner = inner_join(world, coffee_data)
# Joining, by = "name_long"


dim(world_coffee_inner)
# [1] 45 13


nrow(world_coffee_inner)
# [1] 45

 

 

(2) 두 문자열의 원소 차이 알아보고 문자열 매칭으로 찾아보기: setdiff(), str_subset()

 

Join 전과 후에 어느 국가에서 차이가 나는지 확인해 보기 위해 setdiff() 함수를 사용해서 Join의 Key로 사용이 되었던 'name_long' (긴 국가 이름)에 대해 "coffee_data" 와 "world" 데이터의 원소 간 차이를 구해보았습니다. 그랬더니 ["Congo, Dem. Rep. of", "Others"] 의 2개 'name_long' 에서 차이가 있네요. 

 

다음으로, "world" 의 'name_long' 칼럼의 원소 중에서  "Dem"으로 시작하고 "Congo"를 포함하고 있는 문자열을 stringr 패키지의 str_subset(string, pattern) 함수를 사용해 정규 표현식의 문자열 매칭으로 찾아보겠습니다. "world" 데이터셋의 'name_long' 칼럼에는 "Democratic Republic of the Congo" 라는 이름으로 데이터가 들어가 있네요. ("coffee_data"  데이터셋에는 "Confo, Dem. Rep. of" 라고 들어가 있다보니, 서로 같은 국가임에도 left_join() 을 했을 때 서로 정확하게 매칭이 안되어 Join 이 안되었습니다.)

 

참고로, str_subset() 은 x[str_detect(x, pattern)] 의 wrapper 입니다. 그리고 grep(pattern, x, value = TRUE) 와 동일한 역할을 수행합니다. 

 

## setdiff(): calculates the set difference of subsets of two data frames
setdiff(coffee_data$name_long, world$name_long)
# [1] "Congo, Dem. Rep. of" "Others"


## string matching (regex) function from the stringr package
str_subset(world$name_long, "Dem*.+Congo")
# [1] "Democratic Republic of the Congo"

 

 

 

(3) 문자열 매칭으로 Key 값 업데이트 하고, 다시 두 테이블 inner join 하기

 

이제 Join Key로 사용하는 'name_long' 칼럼에서 "Congo" 국가에 대한 표기가 "world" 와 "coffee_data" 의 두 개 데이터셋이 서로 조금 다르다는 이유로 Join 이 안된다는 문제를 해결해 보겠습니다. 

grepl(pattern, x) 함수로 "coffee_data" 데이터셋의 'name_long' 칼럼에서 "Congo" 가 들어있는 행을 찾아서, 그 행의 값의 str_subset() 의 정규표현식 문자열 매칭으로 찾은 (str_subset(world$name_long, "Dem*.+Congo") 이름인 "Demogratic Republic of the Congo" 라는 이름으로 대체를 해보겠습니다. 이렇게 하면 "world"와 "coffee_data"에 있는 "Congo" 국가의 긴 이름이 동일하게 "Demogratic Republic of Congo"로 되어 Join 이 제대로 될 것입니다. 

 

## updating 'name_long' values using a string matching
coffee_data$name_long[grepl("Congo", coffee_data$name_long)] = 
  str_subset(world$name_long, "Dem*.+Congo")

## inner join again using an updated key
world_coffee_match = inner_join(world, coffee_data)
#> Joining, by = "name_long"

nrow(world_coffee_match)
#> [1] 46

 

 

 참고로, R에서 문자열 패턴 매칭을 할 때 grepl(pattern, x) 은 패턴 매칭되는 여부에 대해 TRUE, FALSE 로 블러언 값을 반환하는 반면에, grep(pattern, x) 은 패턴 매칭이 되는(TRUE) 위치 인덱스(Position Index)를 반환합니다. 

 

## -- grepl: pattern matching and returns boolean
grepl("Congo", coffee_data$name_long)
# [1] FALSE FALSE FALSE FALSE FALSE FALSE  TRUE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE
# [21] FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE
# [41] FALSE FALSE FALSE FALSE FALSE FALSE FALSE


## -- grep: pattern matching and returns position
grep("Congo", coffee_data$name_long)
# [1] 7

 

 

 

(4) Join 할 때 테이블 쓰는 순서의 중요성

 

dplyr 패키지로 두 테이블을 Join 할 때 왼쪽(x, LHS, Left Hand Side)에 써주는 테이블의 데이터 구조로 Join 한 결과를 반환합니다. 즉, Join 할 테이블을 써주는 순서가 중요합니다. 

가령, 아래의 예에서는 "world" 가 'sf' 클래스의 지리 벡터 객체이고, 'coffee_data'는 tydiverse의 tibble, data.frame 객체입니다. left_join(world, coffee_data) 로 'world' 의 'sf' 지리 벡터 객체를 Join 할 때 왼쪽(LHS, x)에 먼저 써주면 Join 한 결과도 'sf' 클래스의 지리 벡터 객체가 됩니다.(R이 지리공간 벡터 데이터임을 알고 'sf' 클래스를 적용한 지리공간 데이터 처리 및 분석이 가능함). 

반면에, left_join(coffee_data, world) 로 'coffee_data'의 'data.frame'을 Join 할 때 왼쪽(LHS, x)에 먼저 써주면 Join 한 결과도 'data.frame' 객체가 반환됩니다. (지리공간 'sf' 클래스가 더이상 아님) 

 

## starting with a non-spatial dataset and 
## adding variables from a simple features object.
## the result is not another simple feature object, 
## but a data frame in the form of a tidyverse tibble: 
## the output of a join tends to match its first argument.

## -- (a) 'sf' object first, then returns 'sf' object.
world_coffee = left_join(world, coffee_data)
#> Joining, by = "name_long"

class(world_coffee)
# [1] "sf"         "tbl_df"     "tbl"        "data.frame"


## -- (b) 'data.frame' object first, then returns 'data.frame' object.
coffee_world = left_join(coffee_data, world)
#> Joining, by = "name_long"

class(coffee_world)
#> [1] "tbl_df"     "tbl"        "data.frame"

 

 

(5) data.frame을 'sf' 클래스 객체로 변환하기

 

'sf' 패키지의 st_as_df() 함수를 사용하면 data.frame 을 'sf' 클래스 객체로 변환할 수 있습니다. 

## -- converting data.frame to 'sf' class object

st_as_sf(coffee_world)

# imple feature collection with 47 features and 12 fields (with 2 geometries empty)
# geometry type:  MULTIPOLYGON
# dimension:      XY
# bbox:           xmin: -117.1278 ymin: -33.76838 xmax: 156.02 ymax: 35.49401
# geographic CRS: WGS 84
# # A tibble: 47 x 13
# name_long coffee_producti~ coffee_producti~ iso_a2 continent region_un subregion type  area_km2     pop lifeExp gdpPercap
# <chr>                <int>            <int> <chr>  <chr>     <chr>     <chr>     <chr>    <dbl>   <dbl>   <dbl>     <dbl>
#   1 "Angola"                NA               NA AO     Africa    Africa    Middle A~ Sove~ 1245464.  2.69e7    60.9     6257.
# 2 "Bolivia"                3                4 BO     South Am~ Americas  South Am~ Sove~ 1085270.  1.06e7    68.4     6325.
# 3 "Brazil"              3277             2786 BR     South Am~ Americas  South Am~ Sove~ 8508557.  2.04e8    75.0    15374.
# 4 "Burundi"               37               38 BI     Africa    Africa    Eastern ~ Sove~   26239.  9.89e6    56.7      803.
# 5 "Cameroo~                8                6 CM     Africa    Africa    Middle A~ Sove~  460325.  2.22e7    57.1     3196.
# 6 "Central~               NA               NA CF     Africa    Africa    Middle A~ Sove~  621860.  4.52e6    50.6      597.
# 7 "Congo, ~                4               12 NA     NA        NA        NA        NA         NA  NA         NA         NA 
# 8 "Colombi~             1330             1169 CO     South Am~ Americas  South Am~ Sove~ 1151883.  4.78e7    74.0    12716.
# 9 "Costa R~               28               32 CR     North Am~ Americas  Central ~ Sove~   53832.  4.76e6    79.4    14372.
# 10 "C\u00f4~              114              130 CI     Africa    Africa    Western ~ Sove~  329826.  2.25e7    52.5     3055.
# # ... with 37 more rows, and 1 more variable: geom <MULTIPOLYGON [arc_degree]>

 

다음번 포스팅에서는  '지리공간 벡터 데이터에서 새로운 속성을 만들고 지리공간 정보를 제거하는 방법'에 대해서 알아보겠습니다. 

 

[Reference]

- Geocomputation with R, 'Attribute data operations': geocompr.robinlovelace.net/attr.html

 

 

이번 포스팅이 많은 도움이 되었기를 바랍니다. 

행복한 데이터 과학자 되세요!  :-)

 

반응형
Posted by Rfriend

댓글을 달아 주세요