Thanks to visit codestin.com
Credit goes to gist.github.com

Skip to content

Instantly share code, notes, and snippets.

@drtagkim
Last active March 11, 2026 02:02
Show Gist options
  • Select an option

  • Save drtagkim/f65a68d28556d19a43efccdcf598992e to your computer and use it in GitHub Desktop.

Select an option

Save drtagkim/f65a68d28556d19a43efccdcf598992e to your computer and use it in GitHub Desktop.
R Question 300

R 프로그래밍 마스터를 위한 300제: 기초부터 고급 Data Analytics까지

R 프로그래밍 초급 문제 (1-20번)

안녕하세요! 경희대학교 빅데이터응용학과 김태경 교수입니다. 빅데이터프로그래밍2 수강생 여러분의 R 여정의 첫걸음을 돕기 위해 가장 핵심적이고 재미있는 문제들을 준비했습니다. 각 문제를 통해 R의 기본기를 탄탄하게 다져봅시다. 윤이나 조교가 수고 많았습니다. 짝짝짝.


1. 첫 변수 만들기: 카페의 하루 커피 판매량

문제 상황: 당신은 작은 카페의 사장입니다. 오늘 하루 동안 아메리카노가 총 50잔 팔렸습니다. 이 판매량을 R에서 기록하고 싶습니다.

과제: sold_coffee라는 이름의 변수를 만들고, 숫자 50을 할당(저장)하세요. 그리고 print() 함수를 사용해 이 변수의 값을 화면에 출력하세요.

정답 코드

# 'sold_coffee' 변수에 50을 할당합니다.
sold_coffee <- 50

# 변수의 값을 출력합니다.
print(sold_coffee)

해설

이 문제는 R 프로그래밍의 가장 기본인 변수 할당을 다룹니다.

  • 변수(Variable): 데이터를 저장하는 공간에 붙이는 이름입니다. 여기서는 sold_coffee가 변수명입니다.
  • 할당 연산자(Assignment Operator) <-: 화살표(<-)는 오른쪽의 값을 왼쪽의 변수에 저장하라는 의미입니다. R에서는 =도 사용 가능하지만, 전통적으로 <-가 선호됩니다. 이는 함수 인자 할당(=)과 변수 할당(<-)을 시각적으로 구분하여 코드 가독성을 높여주기 때문입니다.
  • print() 함수: 괄호 안의 값을 화면(콘솔)에 보여주는 가장 기본적인 출력 함수입니다.

2. 게임 캐릭터 이름 짓기

문제 상황: 당신은 새로운 롤플레잉 게임(RPG)을 개발 중입니다. 게임의 주인공 이름을 '루나'로 정했습니다. 이 캐릭터의 이름을 변수에 저장해야 합니다.

과제: character_name이라는 변수에 문자열 '루나'를 할당하고, 이 변수를 출력하세요.

정답 코드

# 'character_name' 변수에 문자열 '루나'를 할당합니다.
character_name <- "루나"

# 변수를 출력합니다. (print() 함수를 생략해도 인터랙티브 환경에서는 출력됩니다)
character_name

해설

이번 문제는 문자열(Character) 데이터 타입을 다룹니다.

  • 문자열: 글자들의 나열을 의미하며, R에서는 큰따옴표(")나 작은따옴표(')로 감싸서 표현합니다. "루나"는 문자열 데이터입니다. 숫자 50과 달리 연산에 사용되지 않고 텍스트 그대로 저장됩니다.
  • R 스크립트나 콘솔에서 마지막 줄에 변수명만 입력하면 print() 함수를 사용한 것과 동일하게 결과가 출력됩니다. 이는 코드를 간결하게 만드는 데 도움이 됩니다.

3. 상점 영업 상태 확인하기

문제 상황: 스마트 상점 시스템을 만들고 있습니다. 현재 상점이 열려있는지(Open) 아니면 닫혔는지(Closed) 상태를 저장해야 합니다. 지금은 영업 중인 상태입니다.

과제: is_open이라는 변수에 '참'을 의미하는 논리값 TRUE를 할당하고 출력하세요.

정답 코드

# 'is_open' 변수에 논리값 TRUE를 할당합니다.
is_open <- TRUE

# 변수를 출력합니다.
is_open

해설

이 문제는 논리(Logical) 데이터 타입을 다룹니다.

  • 논리값: '참(True)' 또는 '거짓(False)' 두 가지 상태만 가지는 데이터 타입입니다. R에서는 대문자로 TRUEFALSE를 사용합니다. (축약형 T, F도 가능하지만, 코드의 명확성을 위해 전체 단어를 쓰는 것이 좋습니다.)
  • 논리값은 프로그램의 흐름을 제어하는 조건문(if 등)에서 아주 중요하게 사용됩니다. 예를 들어, "만약 is_openTRUE라면, '환영합니다' 메시지를 보여줘"와 같은 로직을 구현할 수 있습니다.

4. 게임 레벨업! 변수 값 업데이트하기

문제 상황: 게임 캐릭터 '루나'가 몬스터를 사냥하여 경험치를 얻어 레벨 1에서 레벨 2로 상승했습니다.

과제: player_level 이라는 변수를 만들고 초기값으로 1을 할당하세요. 그 다음 줄에서, 이 player_level 변수의 값을 2로 변경(재할당)하고 최종 값을 출력하세요.

정답 코드

# 'player_level' 변수에 초기 레벨 1을 할당합니다.
player_level <- 1
print(player_level) # 초기값 확인

# 레벨업! 'player_level' 변수에 2를 재할당합니다.
player_level <- 2
print(player_level) # 최종값 확인

해설

이 문제는 변수의 재할당(Re-assignment) 개념을 보여줍니다.

  • 변수는 이름 그대로 '변할 수 있는 수'입니다. 한 번 값이 할당되었다고 해서 영원히 그 값을 가지는 것이 아니라, 언제든지 새로운 값을 덮어쓸 수 있습니다.
  • player_level <- 2 코드가 실행되는 순간, 기존에 player_level이 가지고 있던 1이라는 값은 사라지고 새로운 값 2가 그 자리를 차지하게 됩니다. 이는 프로그램의 상태가 시간에 따라 변하는 것을 표현하는 핵심적인 방법입니다.

5. 오늘의 기온 기록하기

문제 상황: 당신은 기상 데이터를 수집하는 과학자입니다. 오늘 서울의 평균 기온은 섭씨 25.5도였습니다. 이 데이터를 기록해봅시다.

과제: today_temp라는 변수에 25.5를 할당하고, class() 함수를 사용해 이 변수의 데이터 타입을 확인해보세요.

정답 코드

# 'today_temp' 변수에 25.5를 할당합니다.
today_temp <- 25.5

# 변수의 데이터 타입을 확인합니다.
class(today_temp)

해설

이 문제는 숫자형(Numeric) 데이터 타입데이터 타입 확인 방법을 다룹니다.

  • numeric 타입: R에서 소수점을 포함하는 숫자는 기본적으로 numeric (또는 double) 타입으로 처리됩니다. 정수와 실수를 모두 포함하는 일반적인 숫자 타입입니다.
  • class() 함수: 괄호 안에 들어있는 변수나 값의 데이터 타입이 무엇인지 알려주는 매우 유용한 함수입니다. 데이터 분석 과정에서 내가 다루는 데이터가 어떤 종류인지 항상 확인하는 습관은 매우 중요합니다.

6. 장바구니 아이템 개수 합산하기

문제 상황: 온라인 쇼핑몰에서 장바구니 시스템을 개발하고 있습니다. 한 고객이 사과 3개와 바나나 5개를 담았습니다. 장바구니에 담긴 총 과일의 개수를 계산해야 합니다.

과제: apples 변수에 3, bananas 변수에 5를 할당하세요. 그리고 이 두 변수를 더하여 total_fruits라는 새로운 변수에 저장하고, 그 결과를 출력하세요.

정답 코드

# 각 과일의 개수를 변수에 할당합니다.
apples <- 3
bananas <- 5

# 덧셈 연산자를 사용하여 총 개수를 계산합니다.
total_fruits <- apples + bananas

# 결과를 출력합니다.
print(total_fruits)

해설

이 문제는 R의 기본 산술 연산자(Arithmetic Operator) 중 덧셈(+)을 사용합니다.

  • R은 변수를 실제 값이 담긴 것처럼 취급합니다. 따라서 apples + bananas는 R 내부적으로 3 + 5로 해석되어 계산됩니다.
  • 이처럼 이미 만들어진 변수들을 이용해 새로운 계산을 하고 그 결과를 다른 변수에 저장하는 것은 데이터 처리의 가장 기본적인 흐름입니다.

7. 아르바이트 급여 계산하기

문제 상황: 당신은 카페에서 시급 9,860원을 받으며 아르바이트를 합니다. 오늘 총 4시간을 일했습니다. 오늘의 총 급여를 계산해봅시다.

과제: hourly_wage 변수에 9860, hours_worked 변수에 4를 할당하세요. 곱셈 연산자 *를 사용하여 오늘의 총 급여를 계산하고 daily_wage 변수에 저장한 뒤 출력하세요.

정답 코드

# 시급과 근무 시간을 변수에 할당합니다.
hourly_wage <- 9860
hours_worked <- 4

# 곱셈 연산자를 사용하여 일급을 계산합니다.
daily_wage <- hourly_wage * hours_worked

# 결과를 출력합니다.
print(daily_wage)

해설

이번 문제는 곱셈 연산자 * 의 사용법을 보여줍니다. R에서 별표(*)는 수학의 곱셈 기호(×)와 같은 역할을 합니다. 이처럼 실생활의 문제를 코드로 옮기는 과정에서 어떤 연산자를 사용해야 할지 생각하는 것이 프로그래밍의 첫걸음입니다.

8. 파티 비용 공평하게 나누기

문제 상황: 친구 5명과 함께 피자 파티를 했습니다. 피자 값으로 총 45,000원이 나왔습니다. 각자 얼마씩 내야 하는지 계산해야 합니다.

과제: total_cost 변수에 45000, num_friends 변수에 5를 할당하세요. 나눗셈 연산자 /를 사용하여 한 사람당 내야 할 금액을 계산하고, cost_per_person 변수에 저장한 뒤 출력하세요.

정답 코드

# 총 비용과 친구 수를 변수에 할당합니다.
total_cost <- 45000
num_friends <- 5

# 나눗셈 연산자를 사용하여 1인당 비용을 계산합니다.
cost_per_person <- total_cost / num_friends

# 결과를 출력합니다.
print(cost_per_person)

해설

나눗셈 연산자 / 는 수학의 나눗셈 기호(÷)와 동일한 기능을 합니다. R을 계산기처럼 사용하여 간단한 실생활 문제를 해결할 수 있음을 보여주는 좋은 예제입니다.

9. 로켓 발사 카운트다운

문제 상황: 우주선 발사 시뮬레이션을 만들고 있습니다. 발사 10초 전부터 카운트다운을 시작합니다.

과제: countdown 변수에 10을 할당하세요. 그 다음, 뺄셈 연산자 -를 사용하여 countdown 값에서 1을 뺀 결과를 다시 countdown 변수에 저장(업데이트)하는 코드를 작성하고, 최종 countdown 값을 출력하세요.

정답 코드

# 카운트다운 초기값을 설정합니다.
countdown <- 10

# 자기 자신에게서 1을 빼서 값을 업데이트합니다.
countdown <- countdown - 1

# 결과를 출력합니다.
print(countdown)

해설

이 문제는 변수 값을 자기 자신을 이용하여 업데이트하는 매우 흔한 프로그래밍 패턴을 보여줍니다.

  • countdown <- countdown - 1 이라는 코드는 수학적으로는 말이 안 되지만(x = x - 1), 프로그래밍에서는 "기존 countdown의 값(10)을 가져와 1을 뺀 후(9), 그 결과를 다시 countdown이라는 변수 공간에 덮어쓴다"는 의미로 해석됩니다.
  • 이러한 패턴은 값을 1씩 증가시키거나(카운터), 누적 합계를 구할 때(누산기) 등 매우 광범위하게 사용됩니다.

10. 용돈의 거듭제곱

문제 상황: 한 아이가 매일 용돈을 받는데, 첫날은 2원, 둘째 날은 $2^2=4$원, 셋째 날은 $2^3=8$원... 이런 식으로 2의 거듭제곱 형태로 받기로 했습니다. 10일째 되는 날 받게 될 용돈은 얼마일까요?

과제: base 변수에 2, day 변수에 10을 할당하세요. 거듭제곱 연산자 ^를 사용하여 10일째 용돈을 계산하고 allowance_on_day10 변수에 저장한 뒤 출력하세요.

정답 코드

# 밑과 지수를 변수에 할당합니다.
base <- 2
day <- 10

# 거듭제곱 연산자를 사용하여 계산합니다.
allowance_on_day10 <- base ^ day

# 결과를 출력합니다.
print(allowance_on_day10)

해설

거듭제곱 연산자 ^ 는 밑(base)을 지수(exponent)만큼 곱하는 연산을 수행합니다. 즉, base ^ day$2^{10}$을 의미합니다. R에서는 ** 연산자도 동일한 거듭제곱 기능을 수행합니다. (2 ** 10도 결과는 같습니다.)

11. 빵 나누어 주기 (나머지와 몫)

문제 상황: 빵집에서 빵 20개를 6명의 아이들에게 똑같이 나누어 주려고 합니다. 한 명당 몇 개의 빵을 받을 수 있고, 몇 개가 남는지 계산해야 합니다.

과제: breads 변수에 20, kids 변수에 6을 할당하세요.

  1. 정수 나눗셈 연산자 %/%를 사용하여 한 명당 받을 수 있는 빵의 개수(breads_per_kid)를 구하세요.
  2. 나머지 연산자 %%를 사용하여 나누어주고 남은 빵의 개수(remaining_breads)를 구하세요.
  3. 두 결과를 각각 출력하세요.

정답 코드

# 빵의 개수와 아이들의 수를 변수에 할당합니다.
breads <- 20
kids <- 6

# 정수 나눗셈으로 1인당 받는 빵의 개수(몫)를 계산합니다.
breads_per_kid <- breads %/% kids
print(paste("한 명당 받는 빵:", breads_per_kid))

# 나머지 연산으로 남는 빵의 개수를 계산합니다.
remaining_breads <- breads %% kids
print(paste("남는 빵:", remaining_breads))

해설

이 문제는 정수 연산에서 매우 유용한 두 연산자를 소개합니다.

  • 정수 나눗셈(Integer Division) %/%: 나눗셈의 결과에서 소수점 이하를 버리고 정수 부분(몫)만 반환합니다. 20 / 63.333...이지만, 20 %/% 63이 됩니다.
  • 나머지(Modulo) %%: 나눗셈을 한 후 남는 나머지를 반환합니다. 206으로 나누면 몫이 3이고 나머지가 2이므로, 20 %% 6의 결과는 2입니다. 이 연산자는 짝수/홀수 판별, 주기적인 패턴 찾기 등에 매우 유용합니다.
  • paste() 함수는 여러 텍스트와 변수를 하나로 합쳐서 출력할 때 사용됩니다. 여기서는 결과를 더 명확하게 보여주기 위해 사용했습니다.

12. 퀘스트 완료 조건 확인하기

문제 상황: 게임에서 '드래곤 슬레이어' 퀘스트를 받으려면 플레이어의 레벨이 50 이상이어야 합니다. 현재 플레이어의 레벨은 52입니다. 퀘스트를 받을 수 있는지 확인해봅시다.

과제: my_level 변수에 52, required_level 변수에 50을 할당하세요. 비교 연산자 >=를 사용하여 my_levelrequired_level보다 크거나 같은지 확인하고, 그 결과를 can_start_quest 변수에 저장한 뒤 출력하세요.

정답 코드

# 나의 레벨과 요구 레벨을 변수에 할당합니다.
my_level <- 52
required_level <- 50

# 비교 연산자를 사용하여 조건을 확인합니다.
can_start_quest <- my_level >= required_level

# 결과를 출력합니다.
print(can_start_quest)

해설

이 문제는 비교 연산자(Comparison Operator) 를 소개합니다. 비교 연산자는 두 값을 비교하여 그 결과로 논리값(TRUE 또는 FALSE)을 반환합니다.

  • >=: 왼쪽 값이 오른쪽 값보다 크거나 같다.
  • my_level >= required_level52 >= 50으로 해석되며, 이 조건은 참(True)이므로 결과적으로 TRUEcan_start_quest 변수에 저장됩니다.
  • 이 외에도 >, <, <=, ==(같다), !=(다르다) 등의 비교 연산자가 있습니다.

13. 비밀번호 일치 여부 확인

문제 상황: 로그인 시스템을 만들고 있습니다. 사용자가 입력한 비밀번호가 시스템에 저장된 비밀번호와 정확히 일치하는지 확인해야 합니다.

과제: saved_password 변수에 "R_is_fun!", input_password 변수에 "R_is_fun!"을 할당하세요. '같다'를 의미하는 비교 연산자 ==를 사용하여 두 비밀번호가 일치하는지 확인하고 결과를 출력하세요.

정답 코드

# 저장된 비밀번호와 입력된 비밀번호를 변수에 할당합니다.
saved_password <- "R_is_fun!"
input_password <- "R_is_fun!"

# '같다' 비교 연산자로 일치 여부를 확인합니다.
is_match <- saved_password == input_password

# 결과를 출력합니다.
print(is_match)

해설

**'같다' 비교 연산자 ==**는 양쪽의 값이 정확히 일치하는지 확인합니다.

  • 중요: 할당 연산자 <- 또는 =와 비교 연산자 ==를 절대 헷갈리면 안 됩니다.
    • x <- 5 : x에 5를 저장하라 (명령)
    • x == 5 : x가 5와 같은가? (질문, 결과는 TRUE 또는 FALSE)
  • 이 실수는 초보자들이 가장 흔하게 저지르는 실수 중 하나이므로 꼭 기억해두세요.

14. 아이템이 희귀 등급인가?

문제 상황: 게임에서 얻은 아이템의 등급이 '희귀' 등급이 아닌지 확인하고 싶습니다. 현재 아이템의 등급은 '전설'입니다.

과제: item_grade 변수에 "전설"을 할당하세요. '같지 않다'를 의미하는 비교 연산자 !=를 사용하여 item_grade"희귀"와 다른지 확인하고 결과를 출력하세요.

정답 코드

# 아이템 등급을 변수에 할당합니다.
item_grade <- "전설"

# '같지 않다' 비교 연산자로 "희귀"가 아닌지 확인합니다.
is_not_rare <- item_grade != "희귀"

# 결과를 출력합니다.
print(is_not_rare)

해설

**'같지 않다' 비교 연산자 !=**는 양쪽의 값이 서로 다르면 TRUE, 같으면 FALSE를 반환합니다. item_grade의 값인 "전설""희귀"와 다르므로, 이 비교의 결과는 TRUE가 됩니다.

15. 데이터 타입의 정체 밝히기

문제 상황: 동료에게서 세 개의 데이터(A, B, C)를 받았습니다. 각 데이터가 어떤 타입인지 명확히 알아야 다음 분석을 진행할 수 있습니다.

  • A는 값 100
  • B는 값 "100"
  • C는 값 A > 50의 결과

과제: 변수 data_A, data_B, data_C에 각각 위 값을 할당하세요. 그리고 class() 함수를 사용하여 세 변수의 데이터 타입을 각각 출력하여 확인하세요.

정답 코드

# 데이터 A 할당 및 타입 확인
data_A <- 100
print(paste("data_A의 타입:", class(data_A)))

# 데이터 B 할당 및 타입 확인
data_B <- "100"
print(paste("data_B의 타입:", class(data_B)))

# 데이터 C 할당 및 타입 확인
data_C <- data_A > 50
print(paste("data_C의 타입:", class(data_C)))

해설

이 문제는 R이 데이터 타입을 얼마나 엄격하게 구분하는지 보여줍니다.

  1. data_A <- 100: 따옴표가 없는 순수한 숫자이므로 numeric 타입입니다.
  2. data_B <- "100": 따옴표로 감싸져 있으므로 숫자가 아닌 글자로 취급됩니다. 따라서 character 타입입니다. data_A + 1101이지만, data_B + 1은 에러를 발생시킵니다.
  3. data_C <- data_A > 50: 100 > 50 이라는 비교 연산의 결과가 저장됩니다. 이 비교는 참이므로 TRUE라는 논리값이 data_C에 저장됩니다. 따라서 logical 타입입니다.

16. VIP 고객 조건 확인하기 (AND 연산)

문제 상황: 한 백화점에서 VIP 고객을 선정하는 기준은 두 가지를 모두 만족해야 합니다.

  1. 연간 구매 금액이 1,000만원 이상
  2. 연간 방문 횟수가 12회 이상

한 고객의 데이터가 연간 구매 금액 1,200만원, 방문 횟수 15회일 때, 이 고객이 VIP인지 확인해봅시다.

과제: purchase_amount12000000, visit_count15를 할당하세요. 두 조건이 모두 참일 때 TRUE를 반환하는 논리 연산자 &를 사용하여 VIP 여부를 is_vip 변수에 저장하고 출력하세요.

정답 코드

# 고객 데이터를 변수에 할당합니다.
purchase_amount <- 12000000
visit_count <- 15

# 두 조건을 & 연산자로 연결하여 VIP 여부를 판단합니다.
is_vip <- (purchase_amount >= 10000000) & (visit_count >= 12)

# 결과를 출력합니다.
print(is_vip)

해설

**논리 연산자 & (AND)**는 연결된 모든 조건이 TRUE일 경우에만 최종 결과로 TRUE를 반환합니다. 하나라도 FALSE가 있으면 결과는 FALSE가 됩니다.

  • (purchase_amount >= 10000000)12000000 >= 10000000 이므로 TRUE 입니다.
  • (visit_count >= 12)15 >= 12 이므로 TRUE 입니다.
  • TRUE & TRUE의 최종 결과는 TRUE가 됩니다.
  • 괄호 ()는 연산의 우선순위를 명확하게 하기 위해 사용되었습니다. 비교 연산을 먼저 수행한 후, 그 결과들을 가지고 논리 연산을 하라는 의미입니다.

17. 주말 또는 공휴일 할인 (OR 연산)

문제 상황: 놀이공원에서 주말이거나 또는 공휴일이면 할인을 적용합니다. 오늘은 주말이지만 공휴일은 아닙니다. 할인을 받을 수 있을까요?

과제: is_weekend 변수에 TRUE, is_holiday 변수에 FALSE를 할당하세요. 두 조건 중 하나라도 참이면 TRUE를 반환하는 논리 연산자 |를 사용하여 할인 적용 여부를 apply_discount 변수에 저장하고 출력하세요.

정답 코드

# 오늘 날짜의 상태를 변수에 할당합니다.
is_weekend <- TRUE
is_holiday <- FALSE

# 두 조건 중 하나라도 만족하는지 | 연산자로 확인합니다.
apply_discount <- is_weekend | is_holiday

# 결과를 출력합니다.
print(apply_discount)

해설

**논리 연산자 | (OR)**는 연결된 조건 중 단 하나라도 TRUE이면 최종 결과로 TRUE를 반환합니다. 모든 조건이 FALSE일 경우에만 FALSE가 됩니다.

  • is_weekendTRUE이므로, is_holidayFALSE이더라도 TRUE | FALSE의 최종 결과는 TRUE가 됩니다.
  • 이처럼 &|를 사용하면 복잡한 비즈니스 규칙을 간단한 코드로 표현할 수 있습니다.

18. 관리자가 아닌지 확인하기 (NOT 연산)

문제 상황: 웹사이트에서 현재 접속한 사용자가 관리자(admin)가 아닐 경우에만 일반 사용자 메뉴를 보여주려고 합니다. 현재 접속한 사용자는 관리자가 맞습니다.

과제: is_admin 변수에 TRUE를 할당하세요. 논리값을 뒤집는 ! (NOT) 연산자를 사용하여 이 사용자가 관리자가 아닌지를 확인하고, 그 결과를 is_not_admin 변수에 저장한 뒤 출력하세요.

정답 코드

# 사용자의 관리자 여부를 변수에 할당합니다.
is_admin <- TRUE

# ! 연산자를 사용하여 논리값을 반전시킵니다.
is_not_admin <- !is_admin

# 결과를 출력합니다.
print(is_not_admin)

해설

**논리 연산자 ! (NOT)**는 피연산자의 논리값을 정반대로 바꿉니다.

  • !TRUEFALSE가 됩니다.
  • !FALSETRUE가 됩니다.
  • is_adminTRUE이므로, !is_adminFALSE가 됩니다. 이 결과는 "이 사용자는 관리자가 아닌가?"라는 질문에 "아니다 (관리자가 맞다)"라고 답하는 것과 같습니다.

19. R 환경에 있는 변수 목록 보기

문제 상황: 지금까지 여러 문제를 풀면서 많은 변수를 만들었습니다. 현재 R 작업 공간(environment)에 어떤 변수들이 만들어져 있는지 목록을 확인하고 싶습니다.

과제: ls() 함수를 사용하여 현재까지 생성된 모든 변수의 이름을 출력하세요.

정답 코드

# 현재 작업 공간의 모든 변수 목록을 출력합니다.
ls()

해설

ls() 함수는 "list"의 약자로, 현재 R 세션의 전역 환경(Global Environment)에 생성되어 있는 모든 객체(변수, 함수 등)의 이름을 문자형 벡터로 반환합니다. 복잡한 분석을 하다가 내가 어떤 변수를 만들었는지 확인하고 싶을 때 매우 유용한 함수입니다.

20. 특정 변수 메모리에서 삭제하기

문제 상황: 앞서 만들었던 hourly_wage, hours_worked, daily_wage 변수들은 급여 계산을 위해 임시로 사용했습니다. 이제 더 이상 필요 없으니 메모리를 효율적으로 사용하기 위해 삭제하려고 합니다.

과제: rm() 함수를 사용하여 daily_wage 변수를 삭제하세요. 그 다음, ls() 함수를 다시 실행하여 daily_wage가 정말로 목록에서 사라졌는지 확인하세요.

정답 코드

# 'daily_wage' 변수를 삭제합니다.
rm(daily_wage)

# 변수 목록을 다시 확인하여 삭제되었는지 봅니다.
ls()

해설

rm() 함수는 "remove"의 약자로, 괄호 안에 지정된 객체를 현재 환경에서 삭제합니다.

  • rm(daily_wage) 실행 후 daily_wage 변수를 출력하려고 하면 "Error: object 'daily_wage' not found" 라는 에러 메시지가 나타납니다.
  • 더 이상 사용하지 않는 거대한 데이터나 변수들을 rm()으로 정리해주면 메모리를 확보하여 R을 더 쾌적하게 사용할 수 있습니다.
  • 여러 변수를 한 번에 지우고 싶으면 rm(list = c("var1", "var2")) 와 같이 사용할 수 있습니다.

축하합니다! R의 가장 기본적인 문법, 데이터 타입, 연산자에 대한 20개의 문제를 모두 해결하셨습니다. 이 개념들은 앞으로 R로 데이터를 분석하는 모든 과정의 뼈대가 될 것입니다. 꾸준히 연습하여 익숙해지시길 바랍니다

R 프로그래밍 문제 (초급 2단계: 21~40번)

주제: 벡터(Vector)의 생성, 인덱싱, 연산 및 기본 함수


21. 게임 아이템 수량 기록하기

문제 상황: 당신은 게임 개발자입니다. 플레이어의 인벤토리에 있는 5가지 아이템(물약, 붕대, 화살, 폭탄, 열쇠)의 수량을 기록해야 합니다. 각 아이템의 수량은 순서대로 10, 5, 100, 3, 1개입니다.

과제: c() 함수를 사용하여 이 아이템 수량 데이터를 inventory_counts라는 이름의 숫자형 벡터(numeric vector)로 생성하세요.

정답 코드

inventory_counts <- c(10, 5, 100, 3, 1)
print(inventory_counts)

해설

R에서 가장 기본적인 데이터 구조인 벡터를 생성하는 문제입니다.

  • 벡터(Vector): 동일한 데이터 타입(예: 숫자, 문자, 논리)의 값들을 순서대로 저장하는 1차원 배열입니다.
  • c() 함수: 'Combine' 또는 'Concatenate'의 약자로, 여러 개의 값들을 묶어 하나의 벡터로 만들어주는 가장 기본적인 함수입니다.
  • <- 연산자: 할당(assignment) 연산자입니다. 오른쪽의 값을 계산하여 왼쪽의 변수에 저장합니다. inventory_counts = c(10, 5, 100, 3, 1)과 같이 =을 사용할 수도 있지만, R 커뮤니티에서는 전통적으로 <-를 선호합니다.

22. 챌린지 스테이지 번호 생성하기

문제 상황: 새로운 모바일 게임에 1단계부터 15단계까지의 챌린지 스테이지를 만들려고 합니다. 각 스테이지 번호를 일일이 입력하는 것은 비효율적입니다.

과제: 콜론(:) 연산자를 사용하여 1부터 15까지의 정수 시퀀스를 포함하는 stage_numbers 벡터를 생성하세요.

정답 코드

stage_numbers <- 1:15
print(stage_numbers)

해설

연속된 정수 시퀀스를 만드는 가장 간편한 방법은 콜론(:) 연산자를 사용하는 것입니다. start:end 형식으로 사용하며, start부터 end까지 1씩 증가하는 정수 벡터를 생성합니다. 이는 for 루프나 반복적인 작업을 위한 인덱스를 생성할 때 매우 유용하게 사용됩니다.


23. 우주 탐사선의 온도 센서 데이터 시뮬레이션

문제 상황: 화성 탐사선이 일정한 시간 간격으로 온도를 측정합니다. 섭씨 -20도에서 0도까지 2.5도 간격으로 온도가 변하는 상황을 시뮬레이션하려고 합니다.

과제: seq() 함수를 사용하여 -20에서 시작하여 0으로 끝나고, 각 단계가 2.5씩 증가하는 숫자 시퀀스를 temp_readings 벡터로 생성하세요.

정답 코드

temp_readings <- seq(from = -20, to = 0, by = 2.5)
print(temp_readings)

해설

콜론(:) 연산자는 1씩 증가(또는 감소)하는 정수 시퀀스만 만들 수 있지만, seq() 함수는 훨씬 더 유연한 시퀀스 생성을 지원합니다.

  • seq() 함수: Sequence Generation의 약자입니다.
  • from: 시퀀스의 시작 값입니다.
  • to: 시퀀스의 끝 값입니다.
  • by: 각 값 사이의 간격(증가량 또는 감소량)입니다. 이 외에도 length.out (원하는 벡터의 길이 지정) 등 다양한 인자를 사용할 수 있어 복잡한 시퀀스 생성에 필수적인 함수입니다.

24. 고객 만족도 설문 데이터 만들기

문제 상황: 고객 10명에게 만족도 설문조사를 실시했습니다. 초기 테스트 단계로, 5명의 고객은 "만족"(값: 3)을, 나머지 5명의 고객은 "보통"(값: 2)을 선택했다고 가정하는 데이터를 만들려고 합니다.

과제: rep() 함수를 사용하여 숫자 3이 5번 반복되고, 이어서 숫자 2가 5번 반복되는 satisfaction_scores 벡터를 생성하세요.

정답 코드

satisfaction_scores <- rep(c(3, 2), each = 5)
print(satisfaction_scores)

다른 정답 방식:

satisfaction_scores_alt <- c(rep(3, 5), rep(2, 5))
print(satisfaction_scores_alt)

해설

rep() 함수는 특정 값이나 벡터를 반복하여 새로운 벡터를 생성할 때 사용됩니다.

  • rep() 함수: Replicate의 약자입니다.
  • rep(x, times): xtimes번 반복합니다. rep(c(3, 2), 5)3, 2, 3, 2, ... 와 같이 벡터 전체를 5번 반복합니다.
  • rep(x, each): x의 각 요소를 each번 반복합니다. rep(c(3, 2), each = 5)3을 5번, 2를 5번 반복하여 3, 3, 3, 3, 3, 2, 2, 2, 2, 2를 생성합니다. 문제의 의도에 더 적합한 방식입니다. 두 번째 정답 방식은 rep()을 두 번 사용하여 각각의 반복된 벡터를 만든 후 c() 함수로 결합하는, 보다 직관적인 방법입니다.

25. RPG 캐릭터 정보 벡터로 표현하기

문제 상황: 당신은 RPG 게임의 캐릭터 정보를 R로 관리하고 있습니다. 한 캐릭터의 이름, 레벨, HP, 그리고 전설 아이템 소유 여부를 각각 다른 벡터에 저장해야 합니다.

과제: 다음 정보를 담은 3개의 벡터를 각각 생성하세요.

  1. char_name: "Arin" (문자형 벡터)
  2. char_stats: 레벨 50, HP 2500 (숫자형 벡터)
  3. has_legendary: TRUE (논리형 벡터)

정답 코드

char_name <- "Arin"
char_stats <- c(50, 2500)
has_legendary <- TRUE

# 생성된 벡터들 확인
print(char_name)
print(char_stats)
print(has_legendary)

해설

R 벡터는 저장하는 데이터의 종류에 따라 타입이 결정됩니다.

  • 문자형(Character) 벡터: 따옴표(" 또는 ')로 감싸진 텍스트 데이터를 저장합니다.
  • 숫자형(Numeric) 벡터: 정수(integer)나 실수(double)와 같은 숫자 데이터를 저장합니다.
  • 논리형(Logical) 벡터: TRUE, FALSE (또는 축약형 T, F) 두 가지 값만 저장합니다. 벡터에 단 하나의 값만 있더라도 R은 이를 길이가 1인 벡터로 취급합니다. 이는 R의 중요한 특징 중 하나인 "모든 것은 벡터다(everything is a vector)"라는 개념을 보여줍니다.

26. 두 매장의 일일 판매량 합치기

문제 상황: A 매장과 B 매장의 오전, 오후 판매량이 각각 기록되어 있습니다. A 매장은 오전에 50개, 오후에 80개를 팔았고, B 매장은 오전에 45개, 오후에 95개를 팔았습니다. 이 두 매장의 판매량 데이터를 하나의 벡터로 합쳐 순서대로 관리하고 싶습니다.

과제:

  1. A 매장의 판매량을 sales_A 벡터로, B 매장의 판매량을 sales_B 벡터로 각각 생성하세요.
  2. c() 함수를 사용하여 sales_Asales_B를 순서대로 결합한 total_sales_log 벡터를 만드세요.

정답 코드

sales_A <- c(50, 80)
sales_B <- c(45, 95)
total_sales_log <- c(sales_A, sales_B)
print(total_sales_log)

해설

c() 함수는 개별 값들뿐만 아니라 기존에 생성된 벡터들을 결합하는 데에도 사용됩니다. c(vector1, vector2, ...)와 같이 사용하면 벡터들을 순서대로 이어 붙여 새로운, 더 긴 벡터를 생성합니다. 이는 여러 소스에서 나온 데이터를 하나로 통합할 때 매우 유용한 기능입니다.


27. 태양계 행성 순서에서 특정 행성 찾기

문제 상황: 태양으로부터의 거리를 기준으로 한 태양계 행성들의 이름이 순서대로 벡터에 저장되어 있습니다. ("수성", "금성", "지구", "화성", "목성", "토성", "천왕성", "해왕성")

과제:

  1. 위 행성 이름들을 planets라는 문자형 벡터로 생성하세요.
  2. 인덱싱(indexing)을 사용하여 세 번째 행성인 "지구"를 추출하여 출력하세요.

정답 코드

planets <- c("수성", "금성", "지구", "화성", "목성", "토성", "천왕성", "해왕성")
earth <- planets[3]
print(earth)

해설

벡터의 특정 위치에 있는 요소에 접근하는 것을 인덱싱이라고 합니다.

  • R의 인덱스는 1부터 시작합니다. (Python, Java 등 많은 언어는 0부터 시작하므로 주의해야 합니다.)
  • 대괄호 [] 안에 원하는 위치의 번호(인덱스)를 넣어 해당 요소를 추출할 수 있습니다. vector[index] 형식으로 사용합니다.
  • planets[3]planets 벡터의 3번째 요소를 의미하므로, "지구"를 반환합니다.

28. 분기별 강수량 데이터 추출하기

문제 상황: 1월부터 6월까지의 월별 평균 강수량 데이터가 있습니다. (50, 60, 100, 120, 150, 200 mm) 이 중에서 봄에 해당하는 3월, 4월, 5월의 강수량 데이터만 따로 보고 싶습니다.

과제:

  1. 월별 강수량 데이터를 monthly_rainfall 벡터로 생성하세요.
  2. 여러 인덱스를 한 번에 지정하는 방식으로 3, 4, 5번째 요소(3월, 4월, 5월 데이터)를 추출하여 spring_rainfall 벡터에 저장하세요.

정답 코드

monthly_rainfall <- c(50, 60, 100, 120, 150, 200)
spring_rainfall <- monthly_rainfall[c(3, 4, 5)]
print(spring_rainfall)

해설

벡터에서 여러 개의 요소를 한 번에 추출하려면, 인덱스 번호들을 c() 함수로 묶어 벡터 형태로 대괄호 안에 넣어주면 됩니다.

  • c(3, 4, 5)3, 4, 5라는 값을 가지는 인덱스 벡터를 생성합니다.
  • monthly_rainfall[c(3, 4, 5)]monthly_rainfall 벡터의 3번째, 4번째, 5번째 요소를 순서대로 가져와 새로운 벡터를 만듭니다. 이처럼 인덱싱 자체에 벡터를 사용하는 강력한 기능은 R 데이터 처리의 핵심입니다.

29. 주간 주가 데이터에서 첫 3일 데이터 가져오기

문제 상황: 어떤 주식의 월요일부터 금요일까지의 종가 데이터가 벡터로 저장되어 있습니다. (10100, 10300, 10200, 10500, 10400) 이 중 주 초반 3일(월, 화, 수)의 데이터만 잘라내어 분석하고 싶습니다.

과제:

  1. 주간 종가 데이터를 stock_prices 벡터로 생성하세요.
  2. 콜론(:) 연산자를 인덱스에 사용하여 첫 번째부터 세 번째 요소까지를 추출하여 early_week_prices에 저장하세요.

정답 코드

stock_prices <- c(10100, 10300, 10200, 10500, 10400)
early_week_prices <- stock_prices[1:3]
print(early_week_prices)

해설

연속된 범위의 요소들을 추출할 때는 콜론(:) 연산자를 인덱스 내에서 사용할 수 있어 매우 편리합니다. vector[start:end]start 인덱스부터 end 인덱스까지의 모든 요소를 포함하는 서브 벡터(sub-vector)를 반환합니다. 이는 시계열 데이터에서 특정 기간을 잘라내거나, 데이터의 일부 샘플을 확인할 때 자주 사용됩니다.


30. 이상치 데이터 제거하기

문제 상황: 센서에서 수집한 5개의 데이터 포인트가 있습니다: c(25.1, 25.3, 50.2, 24.9, 25.0). 확인 결과, 세 번째 데이터인 50.2는 센서 오류로 인한 이상치(outlier)로 밝혀졌습니다. 이 값을 제외한 나머지 데이터만 사용하고 싶습니다.

과제: 음수 인덱싱(negative indexing)을 사용하여 sensor_data 벡터에서 세 번째 요소를 제외한 나머지 요소들을 filtered_data에 저장하세요.

정답 코드

sensor_data <- c(25.1, 25.3, 50.2, 24.9, 25.0)
filtered_data <- sensor_data[-3]
print(filtered_data)

해설

R 인덱싱의 독특하고 강력한 기능 중 하나는 음수 인덱싱입니다.

  • 인덱스에 음수를 사용하면 해당 위치의 요소를 제외합니다.
  • sensor_data[-3]sensor_data 벡터에서 3번째 요소를 제외한 나머지 모든 요소로 새로운 벡터를 만듭니다.
  • 여러 개를 제외하고 싶다면 sensor_data[-c(1, 3)]과 같이 음수 인덱스 벡터를 사용할 수도 있습니다. 데이터 정제(data cleaning) 과정에서 특정 관측치를 제거할 때 매우 유용합니다.

31. 합격 점수만 골라내기

문제 상황: 학생 5명의 시험 점수가 c(85, 92, 78, 65, 95)로 주어졌습니다. 합격 기준은 80점 이상입니다. 80점 이상인 점수들만 골라내고 싶습니다.

과제: 논리적 인덱싱(logical indexing)을 사용하여 scores 벡터에서 80 이상인 값들만 추출하여 passing_scores에 저장하세요.

정답 코드

scores <- c(85, 92, 78, 65, 95)
passing_scores <- scores[scores >= 80]
print(passing_scores)

해설

논리적 인덱싱은 R 데이터 분석의 핵심 중 하나입니다.

  1. scores >= 80 부분은 scores 벡터의 각 요소에 대해 "80 이상인가?"라는 조건을 검사합니다.
  2. 이 결과로 c(TRUE, TRUE, FALSE, FALSE, TRUE) 와 같은 논리형 벡터가 생성됩니다.
  3. 이 논리형 벡터를 원래 벡터의 인덱스로 사용하면(scores[...]), TRUE에 해당하는 위치의 값들만 추출하여 새로운 벡터를 만듭니다. 이 방식은 특정 조건을 만족하는 데이터만 필터링하는 가장 효율적이고 R다운(R-ish) 방법입니다.

32. 과일 가게 가격표에서 사과 가격 찾기

문제 상황: 과일 가게의 가격표를 벡터로 관리하고 있습니다. 각 과일의 가격에 이름을 붙여두면 더 편리할 것입니다. (사과: 1500원, 바나나: 3000원, 체리: 5000원)

과제:

  1. c(1500, 3000, 5000) 이라는 가격 벡터를 만드세요.
  2. names() 함수를 사용하여 이 벡터의 각 요소에 "apple", "banana", "cherry" 라는 이름을 붙여 fruit_prices라는 이름의 벡터(named vector)를 생성하세요.
  3. 이름을 이용한 인덱싱으로 사과("apple")의 가격을 추출하여 출력하세요.

정답 코드

prices <- c(1500, 3000, 5000)
names(prices) <- c("apple", "banana", "cherry")
fruit_prices <- prices # 변수명 변경

# 이름으로 인덱싱
apple_price <- fruit_prices["apple"]
print(apple_price)

해설

벡터의 각 요소에 이름을 부여할 수 있으며, 이를 **이름 있는 벡터(named vector)**라고 합니다.

  • names(vector) <- name_vector 구문을 사용하여 기존 벡터에 이름 벡터를 할당할 수 있습니다.
  • 이름이 부여된 벡터는 숫자 인덱스뿐만 아니라 vector["name"] 형식으로 이름 자체를 인덱스로 사용할 수 있습니다.
  • 이는 코드의 가독성을 크게 향상시킵니다. fruit_prices[1]보다 fruit_prices["apple"]이 훨씬 이해하기 쉽습니다. 데이터 프레임의 열(column) 이름과 유사한 개념으로 생각할 수 있습니다.

33. 모든 상품 가격 10% 인상하기

문제 상황: 카페 메뉴의 가격이 c(4.5, 5.0, 5.5, 6.0) 달러로 벡터에 저장되어 있습니다. 원가 상승으로 인해 모든 메뉴의 가격을 일괄적으로 10% 인상하기로 결정했습니다.

과제: menu_prices 벡터의 모든 요소에 1.1을 곱하여 가격이 인상된 new_prices 벡터를 생성하세요.

정답 코드

menu_prices <- c(4.5, 5.0, 5.5, 6.0)
new_prices <- menu_prices * 1.1
print(new_prices)

해설

R에서 벡터와 단일 값(스칼라, scalar) 간의 산술 연산은 벡터화(vectorization) 방식으로 동작합니다.

  • menu_prices * 1.1을 실행하면, R은 menu_prices 벡터의 각 요소에 1.1을 곱합니다.
  • 즉, c(4.5 * 1.1, 5.0 * 1.1, 5.5 * 1.1, 6.0 * 1.1)이 내부적으로 계산됩니다.
  • for 루프를 사용하지 않고도 이처럼 간단하게 전체 요소에 대한 연산을 수행할 수 있다는 점이 R의 가장 큰 장점 중 하나이며, 코드를 간결하고 빠르게 만들어 줍니다.

34. 두 선수의 경기별 득점 합산하기

문제 상황: 농구팀의 두 주전 선수 A와 B의 최근 4경기 득점 기록이 있습니다.

  • 선수 A: c(25, 30, 22, 28)
  • 선수 B: c(18, 20, 25, 21) 각 경기별로 두 선수의 득점 합계를 계산하고 싶습니다.

과제: 선수 A의 득점 벡터 player_A와 선수 B의 득점 벡터 player_B를 생성하고, 두 벡터를 더하여 경기별 합산 득점 벡터 total_scores_per_game을 만드세요.

정답 코드

player_A <- c(25, 30, 22, 28)
player_B <- c(18, 20, 25, 21)
total_scores_per_game <- player_A + player_B
print(total_scores_per_game)

해설

길이가 같은 두 벡터 간의 산술 연산은 **요소별(element-wise)**로 수행됩니다.

  • player_A + player_B는 첫 번째 요소끼리(25 + 18), 두 번째 요소끼리(30 + 20), 세 번째 요소끼리(22 + 25), 네 번째 요소끼리(28 + 21) 더한 결과를 새로운 벡터로 반환합니다.
  • 이는 벡터화의 또 다른 예시로, 데이터의 각 행(row)이나 관측치별로 연산을 수행할 때 매우 강력한 기능입니다. 덧셈뿐만 아니라 뺄셈, 곱셈, 나눗셈 등 모든 기본 산술 연산에 동일하게 적용됩니다.

35. 목표 판매량 달성 여부 확인하기

문제 상황: 한 주 동안의 일일 목표 판매량은 c(50, 50, 50, 70, 70) 이었고, 실제 판매량은 c(55, 48, 60, 68, 75) 였습니다. 각 요일별로 목표를 달성했는지 여부를 TRUE/FALSE로 확인하고 싶습니다.

과제: target_sales 벡터와 actual_sales 벡터를 생성한 후, 비교 연산자를 사용하여 actual_salestarget_sales보다 크거나 같았는지를 나타내는 논리형 벡터 goal_achieved를 생성하세요.

정답 코드

target_sales <- c(50, 50, 50, 70, 70)
actual_sales <- c(55, 48, 60, 68, 75)
goal_achieved <- actual_sales >= target_sales
print(goal_achieved)

해설

산술 연산과 마찬가지로, 비교 연산자(>, <, >=, <=, ==, !=) 역시 길이가 같은 벡터 간에 요소별로 적용됩니다.

  • actual_sales >= target_salesc(55 >= 50, 48 >= 50, 60 >= 50, 68 >= 70, 75 >= 70)을 각각 계산합니다.
  • 그 결과로 c(TRUE, FALSE, TRUE, FALSE, TRUE)라는 논리형 벡터가 생성됩니다.
  • 이 결과 벡터는 어떤 조건이 충족되었는지 한눈에 파악하게 해주며, 나중에 배울 if문이나 데이터 필터링에 직접적으로 사용될 수 있습니다.

36. 수강 신청 학생 수 세기

문제 상황: '데이터 과학 입문' 과목의 수강 신청자 명단이 벡터로 있습니다: c("Kim", "Lee", "Park", "Choi", "Jung", "Kang"). 총 몇 명의 학생이 신청했는지 알고 싶습니다.

과제: length() 함수를 사용하여 students 벡터에 몇 개의 요소(학생 이름)가 있는지 계산하여 출력하세요.

정답 코드

students <- c("Kim", "Lee", "Park", "Choi", "Jung", "Kang")
num_students <- length(students)
print(num_students)

해설

length() 함수는 벡터에 포함된 요소의 개수를 반환하는 가장 기본적인 함수입니다. 데이터의 크기, 즉 관측치의 수나 변수의 개수를 확인할 때 가장 먼저 사용되는 함수 중 하나입니다. 데이터 분석의 첫 단계인 '데이터 이해' 과정에서 데이터의 규모를 파악하는 데 필수적입니다.


37. 한 달간의 카페 일일 매출 분석하기

문제 상황: 어느 작은 카페의 7일간 일일 매출 데이터가 c(35, 42, 38, 50, 55, 60, 48) 만 원으로 기록되어 있습니다. 이 데이터의 기본 통계량을 파악하고 싶습니다.

과제: daily_revenue 벡터를 생성하고, 다음 값들을 각각 계산하여 출력하세요.

  1. 일주일간의 총매출 (합계)
  2. 일주일간의 평균 일일 매출 (평균)
  3. 매출이 가장 높았던 날의 매출액 (최댓값)
  4. 매출이 가장 낮았던 날의 매출액 (최솟값)

정답 코드

daily_revenue <- c(35, 42, 38, 50, 55, 60, 48)

total_revenue <- sum(daily_revenue)
average_revenue <- mean(daily_revenue)
max_revenue <- max(daily_revenue)
min_revenue <- min(daily_revenue)

print(paste("총매출:", total_revenue))
print(paste("평균 매출:", average_revenue))
print(paste("최고 매출:", max_revenue))
print(paste("최저 매출:", min_revenue))

해설

R은 벡터 전체에 적용되는 다양한 통계 함수를 내장하고 있습니다.

  • sum(x): 벡터 x의 모든 요소의 합계를 계산합니다.
  • mean(x): 벡터 x의 모든 요소의 산술 평균을 계산합니다. 수학적으로는 $$ \bar{x} = \frac{1}{n}\sum_{i=1}^{n}x_i $$ 와 같습니다.
  • max(x): 벡터 x에서 가장 큰 값을 찾습니다.
  • min(x): 벡터 x에서 가장 작은 값을 찾습니다. 이 함수들은 벡터화 연산의 일종으로, 데이터를 요약하고 특성을 파악하는 기술 통계(descriptive statistics)의 기본입니다.

38. 게임 점수 오름차순으로 정렬하기

문제 상황: 온라인 게임 한 판이 끝나고 5명 플레이어의 최종 점수가 c(1250, 3200, 980, 2500, 1890)으로 집계되었습니다. 순위를 매기기 위해 점수를 낮은 순서부터 높은 순서로 정렬해야 합니다.

과제: sort() 함수를 사용하여 game_scores 벡터를 오름차순으로 정렬한 sorted_scores 벡터를 생성하세요.

정답 코드

game_scores <- c(1250, 3200, 980, 2500, 1890)
sorted_scores <- sort(game_scores)
print(sorted_scores)

# 내림차순 정렬 (참고)
sorted_scores_desc <- sort(game_scores, decreasing = TRUE)
print(sorted_scores_desc)

해설

sort() 함수는 벡터의 요소들을 정렬하는 기능을 합니다.

  • 기본적으로 **오름차순(ascending order)**으로 정렬합니다 (작은 값에서 큰 값 순서).
  • decreasing = TRUE 인자를 추가하면 **내림차순(descending order)**으로 정렬할 수 있습니다. 데이터를 순서대로 나열하여 순위를 매기거나, 값의 분포를 시각적으로 파악하기 쉽게 만들 때 사용됩니다.

39. 판매 목표를 초과 달성한 날짜 찾아내기

문제 상황: 한 달(30일)간의 일일 판매량 데이터가 있습니다. 판매 목표는 100개였습니다. 우리는 판매량이 100개를 '초과'한 날이 몇 번째 날들이었는지 궁금합니다. (간단한 예시 데이터 사용: c(90, 105, 98, 110, 101, 80))

과제:

  1. daily_units_sold 벡터를 생성하세요.
  2. which() 함수와 논리적 조건을 사용하여 판매량이 100개를 초과한 날들의 **인덱스(몇 번째 날인지)**를 찾아 exceed_days에 저장하세요.

정답 코드

daily_units_sold <- c(90, 105, 98, 110, 101, 80)
exceed_days <- which(daily_units_sold > 100)
print(exceed_days)

해설

which() 함수는 논리형 벡터를 입력받아, 값이 TRUE인 요소들의 인덱스를 반환합니다.

  1. daily_units_sold > 100은 논리형 벡터 c(FALSE, TRUE, FALSE, TRUE, TRUE, FALSE)를 생성합니다.
  2. which() 함수는 이 논리형 벡터를 보고 TRUE가 위치한 인덱스인 2, 4, 5를 숫자형 벡터로 반환합니다. 단순히 조건에 맞는 '값'을 찾는 것(daily_units_sold[daily_units_sold > 100])을 넘어, 조건에 맞는 '위치' 또는 '순서'를 찾아야 할 때 매우 유용한 함수입니다.

40. 웹사이트 일일 방문자 수 종합 분석

문제 상황: 당신은 데이터 분석가로서 한 웹사이트의 일주일간 일일 방문자 수를 분석해야 합니다. 데이터는 월요일부터 순서대로 c(1200, 1350, 1100, 1500, 2200, 2500, 1800) 입니다.

과제: visitors 벡터를 사용하여 다음의 분석을 순서대로 수행하세요.

  1. 일주일간의 평균 방문자 수를 계산하여 avg_visitors에 저장하세요.
  2. 방문자 수가 가장 많았던 날은 몇 번째 날이었는지 which.max() 함수를 사용해 찾아내세요.
  3. 평균 방문자 수(avg_visitors)보다 방문자가 적었던 날들의 방문자 수를 모두 출력하세요.

정답 코드

visitors <- c(1200, 1350, 1100, 1500, 2200, 2500, 1800)

# 1. 평균 방문자 수 계산
avg_visitors <- mean(visitors)
print(paste("평균 방문자 수:", avg_visitors))

# 2. 방문자 수가 가장 많았던 날의 인덱스 찾기
peak_day_index <- which.max(visitors)
print(paste("최고 방문일(요일 인덱스):", peak_day_index)) # 6번째 날 (토요일)

# 3. 평균보다 방문자가 적었던 날들의 데이터 추출
below_average_days <- visitors[visitors < avg_visitors]
print("평균 이하 방문자 수:")
print(below_average_days)

해설

이 문제는 지금까지 배운 여러 벡터 관련 함수와 개념을 종합적으로 활용하는 미니 분석 프로젝트입니다.

  • mean(): 데이터의 중심 경향성을 파악하기 위해 평균을 계산했습니다.
  • which.max(x): 벡터 x에서 최댓값을 가지는 요소의 인덱스를 반환합니다. (max()는 값 자체를 반환). 최고 실적을 낸 시점이나 대상을 찾을 때 유용합니다. (which.min()은 최솟값의 인덱스를 반환합니다.)
  • 논리적 인덱싱 (visitors < avg_visitors): 계산된 평균값(avg_visitors)을 기준으로 데이터를 필터링했습니다. visitors < avg_visitors는 논리형 벡터를 생성하고, 이를 인덱스로 사용하여 평균 이하의 실적을 보인 날들의 데이터만 효과적으로 추출했습니다.

이처럼 벡터의 생성, 연산, 인덱싱, 그리고 요약 통계 함수들을 조합하면 간단한 데이터에서도 의미 있는 통찰을 얻는 초기 분석을 수행할 수 있습니다.

R 프로그래밍 마스터 과정: 문제 41-60

안녕하세요! 세계 최고의 데이터 과학자이자 R 교육 전문가로서, 여러분의 R 실력을 한 단계 끌어올릴 행렬(Matrix)과 배열(Array) 관련 문제들을 준비했습니다. 데이터 분석의 기본이 되는 이 구조들을 다루며 실제 데이터 처리 능력을 길러봅시다. 흥미로운 시나리오와 함께 R의 강력함을 느껴보세요!


41. 게임 캐릭터 스탯 행렬 생성하기

문제 상황: 당신은 새로운 RPG 게임의 개발자입니다. 3명의 캐릭터(전사, 마법사, 궁수)가 있고, 각 캐릭터는 4개의 기본 스탯(HP, MP, 공격력, 방어력)을 가집니다. 이 데이터를 효율적으로 관리하기 위해 R의 행렬(Matrix)을 사용하기로 했습니다.

과제 지시 사항:

  1. 아래 스탯 데이터를 담고 있는 숫자형 벡터 stats_vector를 생성하세요.
    • 150, 50, 30, 25 (전사: HP, MP, 공격력, 방어력)
    • 80, 150, 40, 10 (마법사: HP, MP, 공격력, 방어력)
    • 100, 80, 35, 15 (궁수: HP, MP, 공격력, 방어력)
  2. stats_vector 데이터를 사용하여 3행 4열의 행렬 character_stats를 생성하세요. 데이터는 행 우선(row-wise)으로 채워져야 합니다.
  3. 생성된 행렬의 행 이름(rownames)을 각각 "Warrior", "Mage", "Archer"로, 열 이름(colnames)을 "HP", "MP", "Attack", "Defense"로 지정하세요.
  4. 최종적으로 생성된 character_stats 행렬을 출력하세요.

정답 코드

# 1. 스탯 데이터 벡터 생성
stats_vector <- c(150, 50, 30, 25, 80, 150, 40, 10, 100, 80, 35, 15)

# 2. 3x4 행렬 생성 (행 우선)
character_stats <- matrix(data = stats_vector, nrow = 3, ncol = 4, byrow = TRUE)

# 3. 행과 열 이름 지정
rownames(character_stats) <- c("Warrior", "Mage", "Archer")
colnames(character_stats) <- c("HP", "MP", "Attack", "Defense")

# 4. 최종 행렬 출력
print(character_stats)

해설

이 문제는 R에서 가장 기본적인 데이터 구조 중 하나인 행렬을 생성하고 꾸미는 방법을 다룹니다.

  • matrix() 함수: R에서 행렬을 생성하는 핵심 함수입니다.

    • data: 행렬에 채워질 데이터 벡터를 지정합니다.
    • nrow: 행(row)의 개수를 지정합니다.
    • ncol: 열(column)의 개수를 지정합니다.
    • byrow: 데이터를 채우는 방향을 결정하는 중요한 인수입니다. byrow = TRUE는 데이터를 행 순서대로 (왼쪽에서 오른쪽으로, 위에서 아래로) 채웁니다. 만약 FALSE(기본값)로 설정하면 열 순서대로 (위에서 아래로, 왼쪽에서 오른쪽으로) 채워집니다. 문제에서 '행 우선'을 요구했기 때문에 TRUE로 설정했습니다.
  • rownames()colnames(): 생성된 행렬의 행과 열에 의미 있는 이름을 부여하는 함수입니다. 이렇게 이름을 지정하면 character_stats[1, 3] 대신 character_stats["Warrior", "Attack"]처럼 직관적으로 데이터에 접근할 수 있어 코드의 가독성과 유지보수성이 크게 향상됩니다. 데이터 분석에서 이름(label)을 붙이는 것은 매우 중요한 습관입니다.


42. 분기별 판매량 데이터 합치기

문제 상황: 당신은 한 아이스크림 가게의 데이터 분석가입니다. 1분기와 2분기의 세 가지 맛(초코, 바닐라, 딸기) 아이스크림 판매량 데이터가 각각 별도의 행렬로 저장되어 있습니다. 연간 판매 추이를 분석하기 위해 이 데이터들을 하나의 행렬로 합쳐야 합니다.

과제 지시 사항:

  1. 1분기 판매량 행렬 sales_q1과 2분기 판매량 행렬 sales_q2를 아래와 같이 생성하세요. (각 행은 아이스크림 맛, 각 열은 월(Month)을 의미합니다.)
    • sales_q1 (1월, 2월, 3월): 초코(120, 130, 150), 바닐라(200, 210, 230), 딸기(180, 170, 190)
    • sales_q2 (4월, 5월, 6월): 초코(160, 180, 220), 바닐라(240, 250, 280), 딸기(210, 220, 240)
  2. cbind() 함수를 사용하여 두 행렬을 열 기준으로 합쳐 상반기(1월~6월) 판매량 행렬 sales_half_year를 만드세요.
  3. sales_half_year 행렬의 열 이름을 "Jan", "Feb", "Mar", "Apr", "May", "Jun"으로 설정하세요.
  4. 최종 sales_half_year 행렬을 출력하세요.

정답 코드

# 1. 1분기 및 2분기 판매량 행렬 생성
sales_q1 <- matrix(c(120, 130, 150, 200, 210, 230, 180, 170, 190), nrow = 3, byrow = TRUE)
rownames(sales_q1) <- c("Choco", "Vanilla", "Strawberry")

sales_q2 <- matrix(c(160, 180, 220, 240, 250, 280, 210, 220, 240), nrow = 3, byrow = TRUE)
rownames(sales_q2) <- c("Choco", "Vanilla", "Strawberry")

# 2. cbind()를 사용하여 열 기준으로 행렬 합치기
sales_half_year <- cbind(sales_q1, sales_q2)

# 3. 열 이름 설정
colnames(sales_half_year) <- c("Jan", "Feb", "Mar", "Apr", "May", "Jun")

# 4. 최종 행렬 출력
print(sales_half_year)

해설

이 문제는 여러 데이터를 하나의 구조로 통합하는 일반적인 데이터 전처리 작업을 시뮬레이션합니다.

  • cbind() (Column Bind): 여러 개의 벡터 또는 행렬을 열(column) 방향으로 묶어주는 함수입니다. cbind(A, B)를 실행하면 행렬 A의 오른쪽에 행렬 B가 붙는 형태로 새로운 행렬이 생성됩니다. cbind를 사용하려면 합치려는 행렬들의 행(row) 개수가 일치해야 합니다. 이 문제에서는 두 행렬 모두 3개의 아이스크림 맛(3개 행)을 다루므로 cbind 사용이 가능합니다.
  • rbind() (Row Bind): cbind와 유사하지만, 행(row) 방향으로 데이터를 묶어줍니다. 만약 새로운 아이스크림 맛 '민트초코'의 분기별 판매량 데이터가 추가된다면 rbind()를 사용해 기존 sales_half_year 행렬에 행으로 추가할 수 있을 것입니다. rbind를 사용하려면 열(column)의 개수가 일치해야 합니다.

데이터를 합치는 cbindrbind는 데이터 프레임(data.frame)에서도 동일하게 사용되며, 데이터 정제 및 통합 과정에서 매우 빈번하게 사용되는 필수 함수입니다.


43. 우주 탐사선 센서 데이터 인덱싱

문제 상황: 당신은 NASA의 데이터 분석가로, 화성 탐사선 'Perseverance'가 보내온 환경 센서 데이터를 분석하고 있습니다. 데이터는 5일간 매일 3번(오전, 오후, 저녁) 측정한 온도, 습도, 압력을 담은 5x3 행렬 형태로 저장되어 있습니다.

과제 지시 사항:

  1. 아래 데이터를 이용해 5행 3열의 sensor_data 행렬을 생성하세요. (행: Day, 열: Temperature, Humidity, Pressure)
    • Day 1: -20, 5, 650
    • Day 2: -25, 4, 655
    • Day 3: -22, 5, 648
    • Day 4: -30, 3, 660
    • Day 5: -28, 4, 662
  2. 3일차(Day 3)의 모든 센서 데이터(온도, 습도, 압력)를 추출하여 출력하세요.
  3. 전체 기간 동안의 압력(Pressure) 데이터만 추출하여 출력하세요.
  4. 4일차(Day 4)의 습도(Humidity) 데이터 하나만 정확히 추출하여 출력하세요.

정답 코드

# 1. 센서 데이터 행렬 생성
sensor_data <- matrix(
  c(-20, 5, 650,
    -25, 4, 655,
    -22, 5, 648,
    -30, 3, 660,
    -28, 4, 662),
  nrow = 5,
  byrow = TRUE,
  dimnames = list(
    c("Day1", "Day2", "Day3", "Day4", "Day5"),
    c("Temperature", "Humidity", "Pressure")
  )
)
cat("--- 전체 센서 데이터 ---\n")
print(sensor_data)

# 2. 3일차의 모든 데이터 추출
day3_data <- sensor_data[3, ]
cat("\n--- 3일차 모든 센서 데이터 ---\n")
print(day3_data)

# 3. 전체 기간의 압력 데이터 추출
pressure_data <- sensor_data[, 3]
cat("\n--- 전체 기간 압력 데이터 ---\n")
print(pressure_data)

# 4. 4일차의 습도 데이터 추출
humidity_day4 <- sensor_data[4, 2]
cat("\n--- 4일차 습도 데이터 ---\n")
print(humidity_day4)

해설

이 문제는 행렬의 핵심 기능인 인덱싱(indexing)과 슬라이싱(slicing)을 연습합니다. 데이터의 특정 부분을 정확하게 집어내는 능력은 모든 데이터 분석의 기본입니다.

  • 행렬 인덱싱 문법: matrix[row_index, col_index] 형식을 사용합니다.

    • sensor_data[3, ]: 3번째 행의 모든 열을 선택합니다. 쉼표 뒤의 열 인덱스를 비워두면 해당 차원의 모든 요소를 선택하라는 의미입니다.
    • sensor_data[, 3]: 모든 행의 3번째 열을 선택합니다. 쉼표 앞의 행 인덱스를 비워두었습니다.
    • sensor_data[4, 2]: 4번째 행과 2번째 열이 교차하는 지점의 단일 값(scalar)을 선택합니다.
  • dimnames 인수: matrix() 함수 내에서 dimnames 인수를 사용하면 행렬 생성과 동시에 행/열 이름을 지정할 수 있습니다. dimnames = list(row_names_vector, col_names_vector) 형식으로 사용하며, 이는 rownames()colnames()를 각각 호출하는 것보다 더 간결한 코드입니다.

인덱싱은 숫자뿐만 아니라 "Day3"와 같이 이전에 지정한 이름으로도 가능합니다 (sensor_data["Day3", ]). 이름 기반 인덱싱은 코드를 더 명확하게 만들어주므로 적극적으로 활용하는 것이 좋습니다.


44. 영화관 좌석 예매 현황 업데이트

문제 상황: 당신은 영화관 예매 시스템을 관리하고 있습니다. 5행 6열의 좌석 배치도가 있고, 모든 좌석은 현재 비어있음(0)으로 표시되어 있습니다. 이제 여러 건의 예매가 발생하여 좌석 현황을 업데이트해야 합니다.

과제 지시 사항:

  1. 모든 값이 0인 5행 6열의 행렬 seating_chart를 생성하세요.
  2. 한 고객이 2행 3열, 2행 4열, 2행 5열 좌석을 예매했습니다. 이 좌석들의 값을 1(예매됨)로 변경하세요. (벡터 인덱싱을 활용해 한 줄의 코드로 처리해 보세요.)
  3. 다른 고객이 5행의 모든 좌석(1열부터 6열까지)을 단체로 예매했습니다. 5행 전체의 값을 1로 변경하세요.
  4. 업데이트된 최종 seating_chart를 출력하세요.

정답 코드

# 1. 5x6 좌석 행렬 생성 (모두 0)
seating_chart <- matrix(0, nrow = 5, ncol = 6)
cat("--- 초기 좌석 현황 ---\n")
print(seating_chart)

# 2. 2행의 3, 4, 5열 좌석 예매 처리
seating_chart[2, c(3, 4, 5)] <- 1

# 3. 5행 전체 좌석 단체 예매 처리
seating_chart[5, ] <- 1

# 4. 최종 좌석 현황 출력
cat("\n--- 최종 예매 현황 ---\n")
print(seating_chart)

해설

이 문제는 행렬의 특정 부분을 선택하여 값을 수정하는 방법을 다룹니다. 이는 데이터에서 오류를 수정하거나, 특정 조건에 맞는 데이터를 일괄 변경하는 등 데이터 처리에서 매우 흔한 작업입니다.

  • 부분 집합에 값 할당: R에서는 인덱싱을 통해 선택된 행렬의 부분 집합(subset)에 새로운 값을 직접 할당(<-)할 수 있습니다.
    • seating_chart[2, c(3, 4, 5)] <- 1: 2행이면서 열이 3, 4, 5인 위치를 정확히 지정합니다. c(3, 4, 5)처럼 벡터를 사용하면 여러 개의 비연속적인 위치를 한 번에 선택할 수 있습니다. 선택된 3개의 위치에 스칼라 값 1을 할당하면, R은 이 값을 '재활용(recycling)'하여 3개의 위치 모두에 1을 할당합니다.
    • seating_chart[5, ] <- 1: 5행의 모든 열을 선택하고 그 위치에 1을 할당합니다. 이 역시 R의 재활용 규칙 덕분에 5행의 모든 요소가 1로 변경됩니다.

이처럼 R의 강력한 인덱싱과 값 할당 기능을 이용하면 반복문(for)을 사용하지 않고도 복잡한 데이터 조작을 간결하고 효율적으로 수행할 수 있습니다. 이를 '벡터화 연산(vectorized operation)'이라고 부르며, R 프로그래밍의 핵심 철학 중 하나입니다.


45. 농구팀 선수별 득점 분석

문제 상황: 당신은 농구팀의 데이터 분석가입니다. 5명의 선수가 4쿼터 동안 기록한 득점 데이터가 행렬로 주어져 있습니다. 팀의 전술을 짜기 위해 특정 조건에 맞는 데이터를 필터링해야 합니다.

과제 지시 사항:

  1. 아래 선수별 쿼터 득점 데이터를 담은 scores 행렬을 생성하세요. (행: 선수, 열: 쿼터)
    • Player A: 8, 10, 5, 12
    • Player B: 2, 4, 10, 3
    • Player C: 15, 12, 8, 9
    • Player D: 5, 5, 6, 5
    • Player E: 0, 2, 5, 10
  2. **논리 인덱싱(Logical Indexing)**을 사용하여, 3쿼터(3rd Quarter)에 8점 이상 득점한 선수의 모든 쿼터 득점 기록을 추출하여 출력하세요.
  3. scores 행렬 전체에서 10점 이상을 기록한 모든 득점 값을 추출하여 벡터 형태로 출력하세요.

정답 코드

# 1. 득점 데이터 행렬 생성
scores <- matrix(
  c(8, 10, 5, 12,
    2, 4, 10, 3,
    15, 12, 8, 9,
    5, 5, 6, 5,
    0, 2, 5, 10),
  nrow = 5,
  byrow = TRUE,
  dimnames = list(
    c("Player A", "Player B", "Player C", "Player D", "Player E"),
    c("Q1", "Q2", "Q3", "Q4")
  )
)
cat("--- 전체 득점 기록 ---\n")
print(scores)

# 2. 3쿼터에 8점 이상 득점한 선수의 모든 기록 추출
hot_in_q3_condition <- scores[, 3] >= 8
players_hot_in_q3 <- scores[hot_in_q3_condition, ]
cat("\n--- 3쿼터에 8점 이상 득점한 선수 기록 ---\n")
print(players_hot_in_q3)

# 3. 전체 득점 중 10점 이상인 값들만 추출
high_scores <- scores[scores >= 10]
cat("\n--- 10점 이상을 기록한 모든 득점 ---\n")
print(high_scores)

해설

이 문제는 행렬 인덱싱의 한 단계 더 나아간 형태인 논리 인덱싱을 다룹니다. 특정 조건을 만족하는 데이터를 필터링하는 가장 효율적인 방법 중 하나입니다.

  • 논리 벡터 생성: scores[, 3] >= 8 코드는 먼저 3번째 열(3쿼터 득점)을 모두 선택한 후, 각 요소가 8 이상인지 비교 연산을 수행합니다. 이 연산의 결과는 TRUE, TRUE, TRUE, FALSE, FALSE 와 같은 논리값(boolean)으로 이루어진 벡터 hot_in_q3_condition이 됩니다.

  • 행 인덱스로 논리 벡터 사용: scores[hot_in_q3_condition, ] 처럼 행 인덱스 자리에 논리 벡터를 넣으면, 벡터의 값이 TRUE인 위치에 해당하는 행들만 선택됩니다. 이 경우 1, 2, 3번째 요소가 TRUE이므로 scores 행렬의 1, 2, 3번째 행("Player A", "Player B", "Player C")이 선택됩니다.

  • 행렬 전체에 대한 논리 인덱싱: scores[scores >= 10]는 행렬 전체의 각 요소에 대해 scores >= 10 이라는 조건을 검사합니다. 이 결과로 scores와 동일한 차원의 논리 행렬이 생성되고, 이 논리 행렬에서 TRUE인 위치의 값들만 추출되어 하나의 벡터로 반환됩니다.

논리 인덱싱은 데이터프레임에서도 똑같이 사용되며, dplyr 패키지의 filter() 함수와 같은 기능의 기본 원리입니다. 이 개념을 확실히 이해하면 R을 이용한 데이터 필터링을 매우 유연하게 할 수 있습니다.


46. 스칼라 연산: 모든 상품 가격 인상하기

문제 상황: 당신은 온라인 쇼핑몰의 가격 정책 담당자입니다. 원자재 가격 상승으로 인해, 3개의 카테고리(전자제품, 의류, 식품)에 속한 4개 상품의 가격을 일괄적으로 10% 인상해야 합니다.

과제 지시 사항:

  1. 아래 가격 데이터를 담은 3x4 행렬 price_table을 생성하세요.
    • 전자제품: 1000, 1200, 800, 1500
    • 의류: 50, 80, 120, 65
    • 식품: 10, 5, 8, 12
  2. price_table의 모든 상품 가격을 10% 인상하세요. 즉, 모든 요소에 1.1을 곱하세요.
  3. 결과를 new_price_table에 저장하고 출력하세요.

정답 코드

# 1. 가격 데이터 행렬 생성
price_table <- matrix(
  c(1000, 1200, 800, 1500,
    50, 80, 120, 65,
    10, 5, 8, 12),
  nrow = 3,
  byrow = TRUE,
  dimnames = list(
    c("Electronics", "Apparel", "Food"),
    c("Item A", "Item B", "Item C", "Item D")
  )
)
cat("--- 기존 가격표 ---\n")
print(price_table)

# 2. 모든 가격 10% 인상 (스칼라 곱셈)
new_price_table <- price_table * 1.1

# 3. 새로운 가격표 출력
cat("\n--- 10% 인상 후 가격표 ---\n")
print(new_price_table)

해설

이 문제는 행렬과 단일 값(스칼라) 사이의 연산을 보여줍니다. 이는 R의 벡터화 연산(vectorized operation)의 대표적인 예시입니다.

  • 스칼라(Scalar) 연산: 행렬의 모든 요소에 동일한 연산(덧셈, 뺄셈, 곱셈, 나눗셈 등)을 적용하는 것을 의미합니다. price_table * 1.1 코드는 for 반복문을 사용하여 행렬의 각 요소를 하나씩 순회하며 1.1을 곱하는 것과 동일한 결과를 내지만, 내부적으로 최적화된 C 코드로 실행되기 때문에 훨씬 빠르고 코드가 간결합니다.

  • R의 재활용 규칙(Recycling Rule): 이 연산의 배경에는 R의 재활용 규칙이 있습니다. 길이가 다른 벡터(또는 행렬)끼리 연산할 때, R은 짧은 쪽의 요소를 반복 사용하여 긴 쪽의 길이에 맞춥니다. 이 경우, 스칼라 값 1.1price_table의 크기(3x4)에 맞게 모든 위치에 '재활용'되어 각 요소와 곱해집니다.

이러한 스칼라 연산은 데이터의 단위를 변경하거나(예: 인치 -> 센티미터), 특정 상수를 더하거나 빼는 등의 일괄적인 데이터 변환 작업에 매우 유용합니다.


47. 행렬 간 덧셈: 두 지점 재고 합산하기

문제 상황: 당신은 프랜차이즈 카페의 재고 관리자입니다. 서울 지점과 부산 지점의 원두 재고 현황이 각각 2x3 행렬로 관리되고 있습니다. 전사 총 재고량을 파악하기 위해 두 지점의 재고를 합산해야 합니다.

과제 지시 사항:

  1. 서울 지점 재고 inventory_seoul과 부산 지점 재고 inventory_busan 행렬을 아래와 같이 생성하세요. (행: 원두 종류, 열: 창고 구역)
    • inventory_seoul: 아라비카(50, 45, 60), 로부스타(30, 35, 25)
    • inventory_busan: 아라비카(40, 55, 50), 로부스타(20, 30, 40)
  2. 두 행렬을 더하여 전체 재고 현황을 나타내는 total_inventory 행렬을 생성하세요.
  3. total_inventory를 출력하세요.

정답 코드

# 1. 각 지점별 재고 행렬 생성
inventory_seoul <- matrix(
  c(50, 45, 60, 30, 35, 25),
  nrow = 2, byrow = TRUE,
  dimnames = list(c("Arabica", "Robusta"), c("Zone A", "Zone B", "Zone C"))
)

inventory_busan <- matrix(
  c(40, 55, 50, 20, 30, 40),
  nrow = 2, byrow = TRUE,
  dimnames = list(c("Arabica", "Robusta"), c("Zone A", "Zone B", "Zone C"))
)

cat("--- 서울 지점 재고 ---\n")
print(inventory_seoul)
cat("\n--- 부산 지점 재고 ---\n")
print(inventory_busan)

# 2. 행렬 간 덧셈으로 총 재고 계산
total_inventory <- inventory_seoul + inventory_busan

# 3. 총 재고 현황 출력
cat("\n--- 전국 총 재고 ---\n")
print(total_inventory)

해설

이 문제는 동일한 차원을 가진 두 행렬 간의 요소별(element-wise) 연산을 다룹니다.

  • 요소별 연산(Element-wise Operation): +, -, *, / 와 같은 기본 산술 연산자를 행렬에 사용하면, 두 행렬의 동일한 위치(동일한 행, 동일한 열)에 있는 요소들끼리 연산이 수행됩니다. 예를 들어, total_inventory[1, 1]의 값은 inventory_seoul[1, 1] + inventory_busan[1, 1] (즉, 50 + 40)으로 계산됩니다.
  • 전제 조건: 이러한 요소별 연산이 가능하려면 두 행렬의 차원(행의 수와 열의 수)이 반드시 동일해야 합니다. 만약 차원이 다르면 R은 오류를 발생시킵니다.

이러한 연산은 여러 기간의 데이터를 합산하거나, 계획 대비 실적의 차이를 계산하는 등 다양한 분석 시나리오에 적용될 수 있습니다. 중요한 점은 * 연산자가 행렬 곱셈(matrix multiplication)이 아닌, 요소별 곱셈이라는 것을 명확히 인지하는 것입니다. 진정한 행렬 곱셈은 %*% 연산자를 사용합니다.


48. 행렬의 전치: 데이터 관점 바꾸기

문제 상황: 당신은 한 학급의 성적 데이터를 분석 중입니다. 데이터가 학생을 행으로, 과목을 열로 하는 행렬로 정리되어 있습니다. 과목별 평균을 쉽게 계산하고 비교하기 위해, 과목이 행으로, 학생이 열로 오도록 데이터의 구조를 바꾸고 싶습니다.

과제 지시 사항:

  1. 3명의 학생(Amy, Brad, Chris)의 3개 과목(Math, Science, History) 성적을 담은 score_by_student 행렬을 생성하세요.
    • Amy: 90, 85, 92
    • Brad: 78, 95, 88
    • Chris: 82, 80, 95
  2. t() 함수를 사용하여 score_by_student 행렬을 전치(transpose)시켜 score_by_subject 행렬을 생성하세요.
  3. 원본 행렬과 전치된 행렬을 모두 출력하여 구조가 어떻게 바뀌었는지 확인하세요.

정답 코드

# 1. 학생 기준 성적 행렬 생성
score_by_student <- matrix(
  c(90, 85, 92,
    78, 95, 88,
    82, 80, 95),
  nrow = 3,
  byrow = TRUE,
  dimnames = list(c("Amy", "Brad", "Chris"), c("Math", "Science", "History"))
)
cat("--- 원본 행렬 (학생 x 과목) ---\n")
print(score_by_student)

# 2. t() 함수로 행렬 전치
score_by_subject <- t(score_by_student)

# 3. 전치된 행렬 출력
cat("\n--- 전치 행렬 (과목 x 학생) ---\n")
print(score_by_subject)

해설

이 문제는 선형대수의 기본 개념인 **전치 행렬(Transpose of a matrix)**을 R에서 구현하는 방법을 다룹니다.

  • 전치(Transpose): 행렬의 행과 열을 서로 맞바꾸는 연산입니다. 원본 행렬 $A$$i$번째 행, $j$번째 열의 요소 $A_{ij}$는 전치 행렬 $A^T$에서 $j$번째 행, $i$번째 열의 요소 $A^T_{ji}$가 됩니다. 즉, $A_{ij} = A^T_{ji}$ 입니다.
  • t() 함수: R에서 전치 행렬을 구하는 내장 함수입니다. 이 함수를 적용하면 행렬의 dim() 결과가 c(m, n)에서 c(n, m)으로 바뀌고, 행 이름과 열 이름도 서로 교환됩니다.

데이터 분석에서 전치는 데이터의 관점을 바꾸고 싶을 때 매우 유용합니다. 예를 들어, 시계열 데이터에서 각 행이 시간, 각 열이 관측 변수를 나타낼 때, t()를 사용하면 각 행이 변수, 각 열이 시간이 되어 변수 간의 관계를 분석하기 더 용이한 형태로 바꿀 수 있습니다.


49. 행렬 곱셈: 레시피 총 재료비 계산하기

문제 상황: 당신은 베이커리를 운영하고 있습니다. 3가지 종류의 빵(크루아상, 스콘, 바게트)을 만들 계획이며, 각 빵에 필요한 재료(밀가루, 버터, 설탕)의 양을 알고 있습니다. 또한, 각 재료의 kg당 단가도 알고 있습니다. 행렬 곱셈을 이용하여 각 빵의 개당 재료비를 계산하고자 합니다.

과제 지시 사항:

  1. '빵 x 재료' 형태의 3x3 행렬 recipe를 생성하세요. (단위: kg)
    • 크루아상: 밀가루 0.5, 버터 0.3, 설탕 0.1
    • 스콘: 밀가루 0.4, 버터 0.2, 설탕 0.2
    • 바게트: 밀가루 0.6, 버터 0.05, 설탕 0.05
  2. '재료 x 단가' 형태의 3x1 행렬 unit_cost를 생성하세요. (단위: 원/kg)
    • 밀가루: 2000
    • 버터: 8000
    • 설탕: 3000
  3. 행렬 곱셈 연산자 %*%를 사용하여 recipeunit_cost를 곱해, 각 빵의 개당 총 재료비를 계산한 production_cost를 구하세요.
  4. production_cost를 출력하세요.

정답 코드

# 1. 레시피 행렬 생성 (빵 x 재료)
recipe <- matrix(
  c(0.5, 0.3, 0.1,
    0.4, 0.2, 0.2,
    0.6, 0.05, 0.05),
  nrow = 3,
  byrow = TRUE,
  dimnames = list(c("Croissant", "Scone", "Baguette"), c("Flour", "Butter", "Sugar"))
)

# 2. 재료 단가 행렬 생성 (재료 x 단가)
unit_cost <- matrix(
  c(2000, 8000, 3000),
  ncol = 1,
  dimnames = list(c("Flour", "Butter", "Sugar"), "Cost_per_kg")
)

cat("--- 레시피 행렬 (3x3) ---\n")
print(recipe)
cat("\n--- 재료 단가 행렬 (3x1) ---\n")
print(unit_cost)

# 3. 행렬 곱셈으로 생산 비용 계산
production_cost <- recipe %*% unit_cost

# 4. 최종 생산 비용 출력
cat("\n--- 빵별 개당 재료비 (3x1) ---\n")
print(production_cost)

해설

이 문제는 요소별 곱셈(*)과 구별되는, 선형대수의 핵심 연산인 **행렬 곱셈(Matrix Multiplication)**을 다룹니다.

  • %*% 연산자: R에서 행렬 곱셈을 수행하는 전용 연산자입니다.

  • 행렬 곱셈의 조건과 원리: 두 행렬 $A$$B$를 곱하여 $C = AB$를 계산하려면, $A$의 열(column) 개수와 $B$의 행(row) 개수가 일치해야 합니다. 만약 $A$$m \times n$ 행렬이고 $B$$n \times p$ 행렬이라면, 결과 행렬 $C$$m \times p$ 행렬이 됩니다. 결과 행렬 $C$$i$번째 행, $j$번째 열의 요소 $C_{ij}$는 다음과 같이 계산됩니다. $$ C_{ij} = \sum_{k=1}^{n} A_{ik} B_{kj} $$ 이 문제에서 recipe는 3x3 행렬, unit_cost는 3x1 행렬입니다. 첫 번째 행렬의 열 개수(3)와 두 번째 행렬의 행 개수(3)가 같으므로 곱셈이 가능하며, 결과 production_cost는 3x1 행렬이 됩니다.

    예를 들어, 크루아상의 재료비는 (0.5 * 2000) + (0.3 * 8000) + (0.1 * 3000) = 1000 + 2400 + 300 = 3700 으로 계산되며, 이는 행렬 곱셈의 첫 번째 행 계산 결과와 일치합니다.

행렬 곱셈은 통계학(회귀 분석), 머신러닝(신경망), 컴퓨터 그래픽스 등 수많은 분야에서 사용되는 근본적인 연산입니다. 이 예제는 행렬 곱셈이 어떻게 실용적인 문제 해결에 사용될 수 있는지를 명확히 보여줍니다.


50. 행/열별 합계 및 평균 계산하기

문제 상황: 당신은 한 주간의 웹사이트 페이지별 방문자 수 데이터를 가지고 있습니다. 각 페이지의 주간 총 방문자 수와, 요일별 평균 방문자 수를 계산하여 웹사이트 트래픽을 요약 분석하고자 합니다.

과제 지시 사항:

  1. 4개의 페이지(Home, Products, Blog, Contact)의 7일간(Mon-Sun) 방문자 수를 담은 4x7 행렬 page_views를 생성하세요.
  2. rowSums() 함수를 사용하여 각 페이지의 주간 총 방문자 수를 계산하세요.
  3. colMeans() 함수를 사용하여 각 요일의 평균 방문자 수를 계산하세요.
  4. 계산된 총 방문자 수와 평균 방문자 수를 각각 의미 있는 이름과 함께 출력하세요.

정답 코드

# 1. 페이지뷰 데이터 행렬 생성
page_views <- matrix(
  c(120, 150, 130, 160, 200, 250, 220,  # Home
    80, 90, 85, 100, 120, 150, 130,   # Products
    100, 110, 120, 130, 110, 100, 90,    # Blog
    30, 40, 35, 45, 50, 60, 55),       # Contact
  nrow = 4,
  byrow = TRUE,
  dimnames = list(
    c("Home", "Products", "Blog", "Contact"),
    c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
  )
)
cat("--- 주간 페이지뷰 데이터 ---\n")
print(page_views)

# 2. 페이지별 주간 총 방문자 수 계산
total_views_per_page <- rowSums(page_views)
cat("\n--- 페이지별 주간 총 방문자 수 ---\n")
print(total_views_per_page)

# 3. 요일별 평균 방문자 수 계산
avg_views_per_day <- colMeans(page_views)
cat("\n--- 요일별 평균 방문자 수 ---\n")
print(avg_views_per_day)

해설

이 문제는 행렬의 행 또는 열 단위로 요약 통계량을 계산하는 매우 흔한 분석 작업을 다룹니다.

  • rowSums() / colSums(): 각각 행별 합계와 열별 합계를 계산하는 함수입니다. 결과는 각 행/열의 이름이 붙은 벡터로 반환됩니다.
  • rowMeans() / colMeans(): 각각 행별 평균과 열별 평균을 계산하는 함수입니다. rowSums() / colSums()와 사용법이 동일합니다.

이러한 함수들은 내부적으로 최적화되어 있어, for 반복문을 사용하여 직접 합계나 평균을 계산하는 것보다 훨씬 효율적입니다. 데이터 분석 시, 전체 데이터셋을 특정 기준(행 또는 열)으로 요약하여 인사이트를 얻는 첫 단계로 자주 사용됩니다. 예를 들어, total_views_per_page 결과를 보면 'Home' 페이지가 가장 방문자가 많다는 것을 즉시 알 수 있고, avg_views_per_day 결과를 보면 주말(Sat, Sun)에 방문자가 많아지는 경향을 파악할 수 있습니다.


51. apply() 함수 기초: 최고 기온 찾기

문제 상황: 당신은 기상 데이터를 분석하고 있습니다. 4개 도시의 주간 최고 기온 데이터가 행렬로 저장되어 있습니다. apply() 함수를 사용하여 각 도시의 주간 최고 기온과, 각 요일의 모든 도시 중 최고 기온을 찾아보려 합니다.

과제 지시 사항:

  1. 4개 도시(Seoul, Busan, Daegu, Incheon)의 7일간(Mon-Sun) 최고 기온 데이터를 담은 max_temps 행렬을 생성하세요.
  2. apply() 함수를 사용하여 각 도시별(행별)로 주간 최고 기온을 찾아서 출력하세요.
  3. apply() 함수를 사용하여 각 요일별(열별)로 모든 도시 중 가장 높은 기온을 찾아서 출력하세요.

정답 코드

# 1. 최고 기온 데이터 행렬 생성
max_temps <- matrix(
  c(28, 30, 31, 29, 32, 33, 30,  # Seoul
    30, 31, 32, 32, 34, 35, 33,  # Busan
    33, 34, 35, 36, 37, 36, 35,  # Daegu
    27, 28, 29, 28, 30, 31, 29),  # Incheon
  nrow = 4,
  byrow = TRUE,
  dimnames = list(
    c("Seoul", "Busan", "Daegu", "Incheon"),
    c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
  )
)
cat("--- 주간 최고 기온 데이터 ---\n")
print(max_temps)

# 2. 도시별 주간 최고 기온 (행별 적용)
hottest_by_city <- apply(X = max_temps, MARGIN = 1, FUN = max)
cat("\n--- 도시별 주간 최고 기온 ---\n")
print(hottest_by_city)

# 3. 요일별 최고 기온 (열별 적용)
hottest_by_day <- apply(X = max_temps, MARGIN = 2, FUN = max)
cat("\n--- 요일별 전국 최고 기온 ---\n")
print(hottest_by_day)

해설

이 문제는 R의 매우 강력하고 중요한 함수인 apply()의 기본 사용법을 소개합니다. apply()는 행렬이나 배열의 행 또는 열 방향으로 특정 함수를 반복적으로 적용할 때 사용됩니다.

  • apply(X, MARGIN, FUN, ...):
    • X: 함수를 적용할 행렬 또는 배열입니다.
    • MARGIN: 함수를 적용할 방향을 지정하는 핵심 인수입니다.
      • MARGIN = 1: 행(row) 방향으로 함수를 적용합니다. 즉, 각 행을 하나의 벡터로 보고 함수를 실행합니다.
      • MARGIN = 2: 열(column) 방향으로 함수를 적용합니다. 즉, 각 열을 하나의 벡터로 보고 함수를 실행합니다.
      • MARGIN = c(1, 2): 행과 열 모두에 적용합니다 (보통 각 요소에 개별적으로 함수를 적용하는 것과 유사).
    • FUN: 적용할 함수입니다. mean, max, min, sum, sd 등 벡터를 입력으로 받는 대부분의 함수를 사용할 수 있습니다.
    • ...: FUN에 전달할 추가적인 인수를 지정합니다.

apply() 계열 함수들(lapply, sapply, tapply 등)은 R에서 반복문을 대체하여 코드를 간결하고 효율적으로 만들어주는 핵심 도구입니다. rowSums/colMeans와 같은 전용 함수들이 apply()보다 빠르지만, apply()max, sd (표준편차) 등 사용자가 원하는 거의 모든 함수를 유연하게 적용할 수 있다는 큰 장점이 있습니다.


52. 행렬의 대각 요소 추출 및 수정

문제 상황: 당신은 컴퓨터 그래픽스 연구원으로, 변환 행렬(transformation matrix)을 다루고 있습니다. 특히, 크기 변환(scaling)을 담당하는 주 대각선(main diagonal)의 요소들을 확인하고, z축의 크기 변환 계수를 수정해야 합니다.

과제 지시 사항:

  1. 3x3 크기 변환 및 회전을 포함하는 transform_matrix를 아래와 같이 생성하세요.
    2.0  0.0  0.0
    0.0  1.5  0.5
    0.0 -0.5  1.5
    
  2. diag() 함수를 사용하여 이 행렬의 주 대각선 요소들(2.0, 1.5, 1.5)을 추출하여 출력하세요.
  3. z축 크기 변환 계수에 해당하는 3행 3열의 값을 2.5로 수정하세요.
  4. 다시 diag() 함수를 사용하여 수정된 행렬의 주 대각선 요소들을 추출하고, 변경 사항이 잘 적용되었는지 확인하세요.

정답 코드

# 1. 변환 행렬 생성
transform_matrix <- matrix(
  c(2.0,  0.0,  0.0,
    0.0,  1.5,  0.5,
    0.0, -0.5,  1.5),
  nrow = 3,
  byrow = TRUE
)
colnames(transform_matrix) <- rownames(transform_matrix) <- c("x", "y", "z")
cat("--- 원본 변환 행렬 ---\n")
print(transform_matrix)

# 2. 주 대각선 요소 추출
diag_elements <- diag(transform_matrix)
cat("\n--- 원본 행렬의 대각 요소 ---\n")
print(diag_elements)

# 3. 3행 3열 요소 수정
transform_matrix[3, 3] <- 2.5
cat("\n--- 수정된 변환 행렬 ---\n")
print(transform_matrix)

# 4. 수정된 행렬의 대각 요소 확인
new_diag_elements <- diag(transform_matrix)
cat("\n--- 수정된 행렬의 대각 요소 ---\n")
print(new_diag_elements)

해설

이 문제는 행렬의 특정 부분, 특히 선형대수에서 중요한 의미를 갖는 주 대각선을 다루는 방법을 연습합니다.

  • diag() 함수의 두 가지 용법:
    1. 추출: diag(matrix)와 같이 행렬을 인수로 전달하면, 해당 행렬의 주 대각선(왼쪽 위에서 오른쪽 아래로 이어지는 대각선)에 위치한 요소들을 추출하여 벡터로 반환합니다.
    2. 생성: diag(vector)와 같이 벡터를 인수로 전달하면, 해당 벡터를 주 대각선 요소로 하고 나머지 요소는 모두 0인 대각 행렬(diagonal matrix)을 생성합니다. 예를 들어 diag(c(1, 2, 3))은 3x3 단위 행렬의 2, 3번째 대각 요소를 2, 3으로 바꾼 행렬을 만듭니다. diag(3)은 3x3 단위 행렬(identity matrix)을 생성합니다.

주 대각선은 행렬의 고유값(eigenvalue), 행렬식(determinant), 대각합(trace) 등 중요한 선형대수적 특성과 깊은 관련이 있습니다. 데이터 과학에서는 공분산 행렬(covariance matrix)의 대각 요소가 각 변수의 분산을 나타내는 등 중요한 의미를 지니므로, 대각 요소를 다루는 능력은 필수적입니다.


53. 행렬의 역행렬과 단위 행렬

문제 상황: 당신은 연립방정식을 푸는 프로그램을 만들고 있습니다. 선형대수학에서 연립방정식 $A\mathbf{x} = \mathbf{b}$의 해 $\mathbf{x}$는 역행렬 $A^{-1}$을 이용하여 $\mathbf{x} = A^{-1}\mathbf{b}$로 구할 수 있습니다. 이를 R에서 구현해보고, 행렬과 그 역행렬을 곱하면 단위 행렬(Identity Matrix)이 되는지도 확인하려 합니다.

과제 지시 사항:

  1. 다음 연립방정식을 표현하는 계수 행렬 A를 생성하세요. $$ 2x + y = 5 $$ $$ x + 3y = 5 $$
  2. solve() 함수를 사용하여 행렬 A의 역행렬 A_inv를 구하세요.
  3. AA_inv를 행렬 곱셈(%*%)하여 결과를 identity_check에 저장하세요.
  4. A_invidentity_check를 출력하세요. identity_check가 단위 행렬에 가까운지 확인해보세요. (컴퓨터의 부동소수점 오차로 인해 완벽한 0이 아닐 수 있습니다.)

정답 코드

# 1. 계수 행렬 A 생성
A <- matrix(c(2, 1, 1, 3), nrow = 2, byrow = TRUE)
cat("--- 계수 행렬 A ---\n")
print(A)

# 2. A의 역행렬 계산
A_inv <- solve(A)
cat("\n--- A의 역행렬 A_inv ---\n")
print(A_inv)

# 3. A와 A_inv의 곱셈
identity_check <- A %*% A_inv
cat("\n--- A %*% A_inv 결과 (단위 행렬 확인) ---\n")
print(identity_check)

해설

이 문제는 선형대수의 핵심 개념인 역행렬과 단위 행렬을 R에서 다루는 방법을 보여줍니다.

  • 역행렬(Inverse Matrix): 정사각 행렬 $A$에 대해, $A A^{-1} = A^{-1} A = I$를 만족하는 행렬 $A^{-1}$$A$의 역행렬이라고 합니다. 여기서 $I$는 단위 행렬입니다. 모든 정사각 행렬이 역행렬을 갖는 것은 아니며, 행렬식(determinant)이 0이 아닌 경우에만 존재합니다.
  • solve() 함수: R에서 역행렬을 구하는 함수입니다. 이름이 solve인 이유는 이 함수가 주로 연립방정식을 푸는 데 사용되기 때문입니다. solve(A, b)$A\mathbf{x} = \mathbf{b}$의 해 $\mathbf{x}$를 직접 구해줍니다. solve(A)처럼 인수 하나만 주면 $A$의 역행렬을 반환합니다.
  • 단위 행렬(Identity Matrix): 주 대각선의 요소가 모두 1이고 나머지 요소는 모두 0인 정사각 행렬입니다. 행렬 곱셈에서 숫자의 1과 같은 역할을 합니다 (즉, 어떤 행렬에 곱해도 원래 행렬이 유지됨).
  • 부동소수점 오차: 출력된 identity_check를 보면, 0이 되어야 할 위치에 2.220446e-16과 같이 매우 작은 숫자가 있는 것을 볼 수 있습니다. 이는 컴퓨터가 실수를 이진수로 표현하면서 발생하는 미세한 오차(부동소수점 오차) 때문이며, 실제로는 0으로 간주해도 무방합니다.

역행렬 계산은 회귀 분석의 계수를 추정하는 등 통계 모델링의 내부 계산에서 핵심적인 역할을 수행합니다.


54. 3차원 배열 생성: 다층적 데이터 관리

문제 상황: 당신은 2개의 매장(강남, 홍대)에서 판매하는 3가지 제품(커피, 케이크, 샌드위치)의 4분기별 매출 데이터를 관리해야 합니다. 이처럼 3개의 차원(매장, 제품, 분기)을 가진 데이터는 3차원 배열(Array)을 사용하여 효과적으로 표현할 수 있습니다.

과제 지시 사항:

  1. 아래 순서대로 나열된 24개의 매출 데이터를 담은 벡터 sales_data를 생성하세요. (첫 12개는 강남점, 다음 12개는 홍대점 데이터)
    • 강남점: 커피(100, 120, 130, 150), 케이크(50, 60, 70, 80), 샌드위치(80, 90, 100, 110)
    • 홍대점: 커피(110, 110, 140, 160), 케이크(60, 65, 75, 85), 샌드위치(90, 95, 105, 115)
  2. array() 함수를 사용하여 3(제품) x 4(분기) x 2(매장) 차원을 갖는 sales_array를 생성하세요.
  3. dimnames를 설정하여 각 차원의 이름과 각 차원 요소의 이름을 지정해주세요.
    • 차원 1 (제품): "Coffee", "Cake", "Sandwich"
    • 차원 2 (분기): "Q1", "Q2", "Q3", "Q4"
    • 차원 3 (매장): "Gangnam", "Hongdae"
  4. 생성된 sales_array를 출력하세요.

정답 코드

# 1. 매출 데이터 벡터 생성
sales_data <- c(
  # 강남점 (제품 -> 분기 순)
  100, 120, 130, 150,  # 커피
  50, 60, 70, 80,      # 케이크
  80, 90, 100, 110,     # 샌드위치
  # 홍대점 (제품 -> 분기 순)
  110, 110, 140, 160,  # 커피
  60, 65, 75, 85,      # 케이크
  90, 95, 105, 115      # 샌드위치
)

# 2. 3차원 배열 생성
# dim = c(행, 열, 깊이/층)
sales_array <- array(
  data = sales_data,
  dim = c(3, 4, 2)
)

# 3. 차원 이름 설정
dimnames(sales_array) <- list(
  Product = c("Coffee", "Cake", "Sandwich"),
  Quarter = c("Q1", "Q2", "Q3", "Q4"),
  Store = c("Gangnam", "Hongdae")
)

# 4. 최종 배열 출력
print(sales_array)

해설

이 문제는 2차원 행렬을 넘어 3차원 이상의 데이터를 다루는 배열(Array)의 개념을 소개합니다.

  • 배열(Array): 동일한 데이터 타입의 요소들로 구성된 다차원 데이터 구조입니다. 행렬은 행과 열이라는 2개의 차원을 갖는 특별한 경우의 배열입니다.
  • array() 함수:
    • data: 배열에 채울 데이터를 벡터 형태로 제공합니다.
    • dim: 각 차원의 크기를 지정하는 숫자 벡터입니다. dim = c(3, 4, 2)는 3개의 행, 4개의 열, 그리고 2개의 '층(layer)' 또는 '깊이(depth)'를 갖는 3차원 배열을 의미합니다. 데이터는 첫 번째 차원(행)부터 순서대로 채워지고, 다음 열, 다음 층으로 넘어갑니다.
  • dimnames 설정: 배열의 dimnames는 리스트(list) 형태로 제공해야 합니다. 리스트의 각 요소는 해당 차원의 이름 벡터가 됩니다. list(차원1_이름벡터, 차원2_이름벡터, ...) 형식입니다.

배열은 이미지 데이터(가로 픽셀 x 세로 픽셀 x 색상 채널), 시계열 데이터 패널(국가 x 변수 x 시간), 실험 데이터(실험군 x 처리 x 반복) 등 다차원적인 구조를 가진 데이터를 자연스럽게 표현하는 데 매우 유용합니다.


55. 3차원 배열 인덱싱과 슬라이싱

문제 상황: 앞선 54번 문제에서 생성한 sales_array가 있습니다. 이제 이 배열에서 특정 데이터를 추출하여 분석 보고서를 작성해야 합니다.

과제 지시 사항:

  1. 54번 문제의 sales_array를 그대로 사용합니다. (만약 없다면 정답 코드의 생성 부분을 먼저 실행하세요.)
  2. 홍대점의 모든 제품, 모든 분기 매출 데이터를 2차원 행렬 형태로 추출하세요.
  3. 모든 매장의 1분기(Q1) 매출 데이터를 2차원 행렬 형태로 추출하세요. (제품 x 매장)
  4. 강남점의 케이크(Cake) 매출 데이터를 1년치(Q1-Q4) 벡터 형태로 추출하세요.

정답 코드

# 1. sales_array 생성 (54번 문제와 동일)
sales_data <- c(100, 120, 130, 150, 50, 60, 70, 80, 80, 90, 100, 110,
                110, 110, 140, 160, 60, 65, 75, 85, 90, 95, 105, 115)
sales_array <- array(data = sales_data, dim = c(3, 4, 2),
                     dimnames = list(Product = c("Coffee", "Cake", "Sandwich"),
                                     Quarter = c("Q1", "Q2", "Q3", "Q4"),
                                     Store = c("Gangnam", "Hongdae")))

# 2. 홍대점의 모든 데이터 추출
hongdae_sales <- sales_array[, , "Hongdae"]
cat("--- 홍대점 전체 매출 (제품 x 분기) ---\n")
print(hongdae_sales)

# 3. 모든 매장의 1분기 데이터 추출
q1_sales <- sales_array[, "Q1", ]
cat("\n--- 1분기 전체 매출 (제품 x 매장) ---\n")
print(q1_sales)

# 4. 강남점의 케이크 매출 데이터 추출
gangnam_cake_sales <- sales_array["Cake", , "Gangnam"]
cat("\n--- 강남점 케이크 분기별 매출 ---\n")
print(gangnam_cake_sales)

해설

이 문제는 3차원 배열의 인덱싱 및 슬라이싱 방법을 연습합니다. 행렬과 원리는 같지만 차원이 하나 더 늘어났을 뿐입니다.

  • 배열 인덱싱 문법: array[dim1_index, dim2_index, dim3_index] 형식을 사용합니다.
    • sales_array[, , "Hongdae"]: 첫 번째 차원(Product)과 두 번째 차원(Quarter)은 비워두어 모든 요소를 선택하고, 세 번째 차원(Store)에서는 "Hongdae"에 해당하는 층(layer)만 선택합니다. 그 결과 3x4 행렬이 반환됩니다.
    • sales_array[, "Q1", ]: 첫 번째 차원(Product)과 세 번째 차원(Store)은 모두 선택하고, 두 번째 차원(Quarter)에서는 "Q1"에 해당하는 열(slice)만 선택합니다. 그 결과 3x2 행렬이 반환됩니다.
    • sales_array["Cake", , "Gangnam"]: 첫 번째 차원에서 "Cake", 세 번째 차원에서 "Gangnam"을 선택하고, 두 번째 차원은 모두 선택합니다. 이 경우, 선택된 결과가 하나의 행 또는 열에 해당하므로 차원이 축소(drop)되어 벡터로 반환됩니다. 만약 행렬 형태를 유지하고 싶다면 sales_array["Cake", , "Gangnam", drop = FALSE]와 같이 drop = FALSE 옵션을 사용할 수 있습니다.

다차원 배열에서 원하는 데이터를 정확히 잘라내는(slicing) 능력은 복잡한 데이터를 탐색하고 분석하는 데 필수적입니다.


56. 배열에 apply 적용: 매장별 연간 총 매출 계산

문제 상황: 다시 54번의 sales_array를 사용합니다. 각 매장의 연간 총 매출액을 계산하여 어떤 매장이 더 높은 실적을 냈는지 비교하고 싶습니다.

과제 지시 사항:

  1. 54번 문제의 sales_array를 그대로 사용합니다.
  2. apply() 함수를 사용하여 매장별(세 번째 차원)로 모든 제품과 분기의 매출 합계를 계산하세요.
  3. 결과를 total_sales_by_store에 저장하고 출력하세요.

정답 코드

# 1. sales_array 생성 (54번 문제와 동일)
sales_data <- c(100, 120, 130, 150, 50, 60, 70, 80, 80, 90, 100, 110,
                110, 110, 140, 160, 60, 65, 75, 85, 90, 95, 105, 115)
sales_array <- array(data = sales_data, dim = c(3, 4, 2),
                     dimnames = list(Product = c("Coffee", "Cake", "Sandwich"),
                                     Quarter = c("Q1", "Q2", "Q3", "Q4"),
                                     Store = c("Gangnam", "Hongdae")))

# 2. apply를 사용하여 매장별 총 매출 계산
# MARGIN = 3 : 세 번째 차원(Store)을 기준으로 함수를 적용
total_sales_by_store <- apply(sales_array, MARGIN = 3, FUN = sum)

# 3. 결과 출력
cat("--- 매장별 연간 총 매출 ---\n")
print(total_sales_by_store)

해설

이 문제는 apply() 함수를 2차원 행렬이 아닌 3차원 배열에 적용하는 방법을 보여줍니다.

  • MARGIN 인수의 확장: apply() 함수의 MARGIN 인수는 다차원 배열에서도 동일하게 작동합니다. MARGIN = 3은 세 번째 차원, 즉 '매장' 차원을 기준으로 함수를 적용하라는 의미입니다.
  • 작동 원리: apply는 세 번째 차원의 각 요소("Gangnam", "Hongdae")에 대해 반복 작업을 수행합니다.
    1. 먼저 "Gangnam"에 해당하는 3x4 행렬을 가져옵니다.
    2. 이 행렬의 모든 요소를 sum() 함수에 전달하여 합계를 구합니다 (강남점의 연간 총 매출).
    3. 다음으로 "Hongdae"에 해당하는 3x4 행렬을 가져옵니다.
    4. 마찬가지로 sum() 함수를 적용하여 합계를 구합니다 (홍대점의 연간 총 매출).
    5. 이 결과들을 모아 이름이 붙은 벡터로 반환합니다.

만약 MARGIN = 1을 사용했다면 제품별 총 매출(강남/홍대 합산)이 계산될 것이고, MARGIN = 2를 사용했다면 분기별 총 매출이 계산될 것입니다. 이처럼 apply와 다차원 배열을 함께 사용하면 복잡한 데이터 집계를 매우 유연하고 간결하게 처리할 수 있습니다.


57. outer() 함수: 구구단표 만들기

문제 상황: 당신은 어린이들을 위한 R 코딩 교육 자료를 만들고 있습니다. outer() 함수를 사용하여 9x9 구구단표를 한 번의 연산으로 생성하는 멋진 예제를 보여주고 싶습니다.

과제 지시 사항:

  1. 1부터 9까지의 숫자를 담은 벡터 x를 생성하세요.
  2. outer() 함수와 곱셈(*) 연산을 사용하여 xx의 외적(outer product)을 계산하세요. 이 결과가 바로 구구단표가 됩니다.
  3. 결과 행렬의 행 이름과 열 이름을 1부터 9까지의 숫자로 설정하여 가독성을 높이세요.
  4. 생성된 구구단표 gugudan_table을 출력하세요.

정답 코드

# 1. 1부터 9까지의 벡터 생성
x <- 1:9

# 2. outer() 함수로 구구단표 생성
gugudan_table <- outer(x, x, FUN = "*")

# 3. 행/열 이름 설정
rownames(gugudan_table) <- x
colnames(gugudan_table) <- x

# 4. 구구단표 출력
print(gugudan_table)

해설

이 문제는 두 벡터의 모든 요소 조합에 대해 특정 연산을 수행하는 outer() 함수를 소개합니다.

  • outer(X, Y, FUN = "*", ...):
    • X, Y: 연산을 수행할 두 벡터입니다.
    • FUN: X의 각 요소와 Y의 각 요소에 적용할 함수입니다. 기본값은 곱셈(*)입니다.
  • 작동 원리: outer(A, B, FUN)은 결과로 행렬을 반환합니다. 이 행렬의 $i$번째 행, $j$번째 열의 값은 FUN(A[i], B[j])로 계산됩니다.
    • 이 문제에서는 XY가 모두 1:9 벡터입니다.
    • 결과 행렬의 [2, 3] 위치의 값은 X[2] * Y[3], 즉 2 * 3 = 6이 됩니다.
    • 결과 행렬의 [7, 8] 위치의 값은 X[7] * Y[8], 즉 7 * 8 = 56이 됩니다. 이러한 방식으로 모든 조합에 대한 곱셈을 수행하여 9x9 구구단표를 완성합니다.

outer() 함수는 단순히 구구단표를 만드는 것 외에도, 그리드(grid) 형태의 좌표를 생성하거나, 두 매개변수 집합의 모든 조합에 대해 특정 함수 값을 계산하는 등 수학적, 통계적 시뮬레이션에서 유용하게 사용됩니다.


58. 행렬 연산 종합: 학생 성적 표준화하기 (Z-score)

문제 상황: 당신은 교사로서 여러 과목의 학생 성적을 분석하고 있습니다. 과목마다 평균과 표준편차가 달라 직접적인 점수 비교가 어렵습니다. 각 학생의 점수를 해당 과목의 Z-점수(Z-score)로 변환하여, 상대적인 성취도를 공정하게 비교하고자 합니다.

과제 지시 사항:

  1. 5명의 학생, 3개 과목의 성적을 담은 scores 행렬을 생성하세요.
  2. colMeans()apply()를 사용하여 각 과목(열)의 평균(subject_means)과 표준편차(subject_sds)를 계산하세요.
  3. scores 행렬의 각 열에서 해당 열의 평균을 빼주세요. (R의 벡터화 연산을 활용)
  4. 위 결과 행렬의 각 열을 해당 열의 표준편차로 나누어 Z-점수 행렬 z_scores를 완성하세요.
  5. z_scores를 출력하여 결과를 확인하세요.

Z-점수 공식: $$ Z = \frac{x - \mu}{\sigma} $$ 여기서 $x$는 개별 점수, $\mu$는 해당 과목의 평균, $\sigma$는 해당 과목의 표준편차입니다.

정답 코드

# 1. 성적 데이터 행렬 생성
scores <- matrix(
  c(80, 75, 90,
    95, 88, 82,
    70, 85, 78,
    88, 92, 95,
    65, 70, 68),
  nrow = 5,
  byrow = TRUE,
  dimnames = list(
    paste0("Student", 1:5),
    c("Korean", "Math", "English")
  )
)
cat("--- 원본 성적 데이터 ---\n")
print(scores)

# 2. 과목별 평균 및 표준편차 계산
subject_means <- colMeans(scores)
subject_sds <- apply(scores, 2, sd)

cat("\n--- 과목별 평균 ---\n")
print(subject_means)
cat("\n--- 과목별 표준편차 ---\n")
print(subject_sds)

# 3. 각 점수에서 과목별 평균 빼기
# sweep() 함수를 사용하거나 직접 연산 가능
# 여기서는 더 직관적인 직접 연산을 보여줍니다.
scores_centered <- t(t(scores) - subject_means)

# 4. 과목별 표준편차로 나누기
z_scores <- t(t(scores_centered) / subject_sds)

# 최종 Z-점수 출력
cat("\n--- Z-점수 변환 결과 ---\n")
print(z_scores)

# 참고: scale() 함수를 사용하면 한 번에 가능합니다.
# cat("\n--- scale() 함수 사용 결과 ---\n")
# print(scale(scores))

해설

이 문제는 행렬 연산을 종합적으로 사용하여 통계학에서 매우 중요한 데이터 표준화(standardization) 과정을 구현합니다.

  • t(t(scores) - subject_means)의 원리: R에서 행렬과 벡터의 연산은 기본적으로 열 단위로 일어납니다. 즉, scores - vector를 하면 vector가 각 에 대해 빼집니다. 하지만 우리는 각 의 학생 점수에서 의 평균을 빼야 합니다. 이럴 때 sweep() 함수를 쓰거나, 전치(t())를 두 번 하는 트릭을 사용할 수 있습니다.

    1. t(scores): 행렬을 전치하여 과목이 행, 학생이 열이 되도록 만듭니다. 이제 각 행이 하나의 과목을 나타냅니다.
    2. t(scores) - subject_means: 이제 subject_means 벡터(길이가 3)가 각 행(과목)에 대해 빼기 연산을 수행합니다. 이는 R의 재활용 규칙에 따라 올바르게 작동합니다.
    3. t(...): 결과를 다시 전치하여 원래의 '학생 x 과목' 형태로 되돌립니다. 이러한 트릭은 행 단위의 연산을 벡터화된 방식으로 수행할 때 매우 유용합니다. 나누기 연산도 동일한 원리로 수행됩니다.
  • scale() 함수: 사실 R에는 이 Z-점수 변환을 한 번에 수행해주는 내장 함수 scale()이 있습니다. scale(x, center = TRUE, scale = TRUE)center 인수로 평균을 뺄지(기본값 TRUE), scale 인수로 표준편차로 나눌지(기본값 TRUE)를 결정합니다. 실제 분석에서는 scale() 함수를 사용하는 것이 훨씬 간결하고 효율적입니다. 이 문제는 scale() 함수의 내부 작동 원리를 행렬 연산으로 직접 구현해보는 데 의의가 있습니다.

Z-점수는 평균이 0, 표준편차가 1이 되도록 데이터를 변환하며, 서로 다른 단위나 범위를 가진 변수들을 동일한 척도상에서 비교할 수 있게 해주는 필수적인 전처리 기법입니다.


59. 행렬 필터링 및 값 교체: 이상치(Outlier) 처리하기

문제 상황: 당신은 공장의 센서 데이터를 분석하고 있습니다. 가끔 센서의 오류로 인해 비정상적으로 높거나 낮은 값(이상치)이 기록됩니다. 분석의 정확도를 높이기 위해, 특정 범위를 벗어나는 값들을 결측치(NA)로 대체하는 전처리 작업을 수행해야 합니다.

과제 지시 사항:

  1. 5x4 크기의 센서 데이터 행렬 sensor_readings를 생성하세요.
  2. 이 행렬에서 10보다 작거나 90보다 큰 값을 가진 모든 요소를 찾아내세요. (논리 인덱싱 활용)
  3. 찾아낸 이상치들을 NA (결측치)로 교체하세요.
  4. 수정 전과 후의 행렬을 모두 출력하여 비교하세요.

정답 코드

# 1. 센서 데이터 행렬 생성
set.seed(42) # 결과 재현을 위한 시드 설정
sensor_readings <- matrix(round(runif(20, 0, 100)), nrow = 5)
cat("--- 원본 센서 데이터 ---\n")
print(sensor_readings)

# 2. 이상치 조건 설정 및 인덱싱
outlier_condition <- sensor_readings < 10 | sensor_readings > 90

# 3. 이상치를 NA로 교체
sensor_readings_cleaned <- sensor_readings
sensor_readings_cleaned[outlier_condition] <- NA

# 4. 결과 비교 출력
cat("\n--- 이상치를 NA로 교체한 후 데이터 ---\n")
print(sensor_readings_cleaned)

해설

이 문제는 데이터 정제(data cleaning) 과정에서 흔히 발생하는 이상치 처리 방법을 다룹니다.

  • 논리 연산자 | (OR): sensor_readings < 10은 10 미만인 위치에 TRUE를 갖는 논리 행렬을 반환하고, sensor_readings > 90은 90 초과인 위치에 TRUE를 갖는 논리 행렬을 반환합니다. | 연산자는 두 조건 중 하나라도 TRUE이면 TRUE를 반환합니다. 따라서 outlier_condition은 10 미만이거나 90 초과인 모든 위치에 TRUE를 갖는 최종 논리 행렬이 됩니다.
  • 논리 행렬을 이용한 값 할당: sensor_readings_cleaned[outlier_condition] <- NA 코드는 outlier_condition 행렬에서 TRUE인 위치에 해당하는 sensor_readings_cleaned의 요소들을 모두 NA로 교체합니다. 이는 문제 45번에서 배운 논리 인덱싱을 값 할당에 응용한 것입니다.

set.seed() 함수는 난수 생성을 재현 가능하게 만들어, 코드를 실행할 때마다 동일한 난수(여기서는 runif로 생성된 값)가 나오도록 보장합니다. runif(20, 0, 100)은 0과 100 사이의 균등분포에서 20개의 난수를 생성합니다.

이상치를 무작정 제거하기보다는 NA로 대체하면, 나중에 na.rm = TRUE 옵션을 사용하거나 특정 값으로 대체(imputation)하는 등 더 유연한 분석이 가능해집니다.


60. kronecker() 함수: 블록 행렬 생성하기

문제 상황: 당신은 실험 설계나 시뮬레이션 연구를 위해 특정 패턴을 가진 큰 행렬, 즉 블록 행렬(block matrix)을 생성해야 합니다. 예를 들어, 2x2 기본 패턴 행렬을 반복하여 4x4 행렬을 만들고 싶습니다. 크로네커 곱(Kronecker product)은 이러한 작업을 우아하게 해결해 줍니다.

과제 지시 사항:

  1. 기본 패턴이 될 2x2 행렬 A와, 각 패턴에 곱해질 계수를 담은 2x2 행렬 B를 생성하세요.
    • A: [[1, 2], [3, 4]]
    • B: [[1, 10], [100, 1000]]
  2. kronecker() 함수를 사용하여 AB의 크로네커 곱을 계산하세요.
  3. 결과 행렬 C를 출력하고, 그 구조가 어떻게 형성되었는지 관찰하세요.

크로네커 곱의 정의: $m \times n$ 행렬 $A$$p \times q$ 행렬 $B$의 크로네커 곱 $A \otimes B$는 다음과 같이 정의되는 $mp \times nq$ 크기의 블록 행렬입니다. $$ A \otimes B = \begin{pmatrix} a_{11}B & a_{12}B & \cdots & a_{1n}B \ a_{21}B & a_{22}B & \cdots & a_{2n}B \ \vdots & \vdots & \ddots & \vdots \ a_{m1}B & a_{m2}B & \cdots & a_{mn}B \end{pmatrix} $$

정답 코드

# 1. 두 개의 2x2 행렬 생성
A <- matrix(1:4, nrow = 2, byrow = TRUE)
B <- matrix(c(1, 10, 100, 1000), nrow = 2, byrow = TRUE)

cat("--- 행렬 A ---\n")
print(A)
cat("\n--- 행렬 B ---\n")
print(B)

# 2. 크로네커 곱 계산
C <- kronecker(A, B)

# 3. 결과 행렬 출력
cat("\n--- 크로네커 곱 결과 (A ⊗ B) ---\n")
print(C)

해설

이 문제는 고급 행렬 연산 중 하나인 크로네커 곱을 다루며, 패턴이 있는 큰 행렬을 효율적으로 생성하는 방법을 보여줍니다.

  • kronecker() 함수: R에서 크로네커 곱을 계산하는 내장 함수입니다. kronecker(X, Y)$X \otimes Y$를 계산합니다.
  • 결과 분석: 출력된 행렬 C를 자세히 살펴보면, 그 구조가 정의와 정확히 일치함을 알 수 있습니다.
    • C의 왼쪽 위 2x2 블록은 A[1,1] * B (즉, 1 * B) 입니다.
    • C의 오른쪽 위 2x2 블록은 A[1,2] * B (즉, 2 * B) 입니다.
    • C의 왼쪽 아래 2x2 블록은 A[2,1] * B (즉, 3 * B) 입니다.
    • C의 오른쪽 아래 2x2 블록은 A[2,2] * B (즉, 4 * B) 입니다.

크로네커 곱은 통계학의 다변량 분석, 시계열 분석의 VAR 모형, 양자 컴퓨팅 등 다양한 전문 분야에서 사용되는 중요한 수학적 도구입니다. 이 함수를 알아두면 복잡한 구조의 행렬을 생성해야 할 때 매우 강력한 무기가 될 수 있습니다.

R 프로그래밍 문제 (초급 4단계: 61~80번)

주제: 리스트(List)와 팩터(Factor) 조작


61. 게임 캐릭터 인벤토리 만들기

문제 상황: 당신은 RPG 게임 개발자입니다. 플레이어의 인벤토리를 R로 관리하려고 합니다. 인벤토리에는 캐릭터의 이름(문자열), 레벨(숫자), 보유 아이템 목록(문자형 벡터), 그리고 스킬 사용 가능 여부(논리형) 정보가 포함되어야 합니다. 이 모든 정보를 하나의 객체에 담고 싶습니다.

과제: 다음 정보를 포함하는 inventory라는 이름의 리스트(list)를 생성하세요.

  1. name: "Luna"
  2. level: 15
  3. items: "health_potion", "mana_potion", "sword"
  4. is_active: TRUE

정답 코드

inventory <- list(
  name = "Luna",
  level = 15,
  items = c("health_potion", "mana_potion", "sword"),
  is_active = TRUE
)

# 생성된 리스트 확인
print(inventory)
str(inventory)

해설

리스트는 R에서 가장 유연한 데이터 구조입니다. 벡터, 행렬, 데이터 프레임, 심지어 다른 리스트까지 서로 다른 데이터 유형의 객체들을 하나의 객체 안에 담을 수 있기 때문입니다.

  • list() 함수를 사용하여 리스트를 생성합니다.
  • 각 구성 요소(element)는 이름 = 값 형태로 지정할 수 있습니다. 이렇게 이름을 지정하면 나중에 데이터를 불러올 때 매우 편리합니다 (inventory$name 과 같이).
  • c() 함수는 여러 개의 아이템을 하나의 벡터로 묶는 데 사용되었습니다. items 요소는 3개의 문자열을 가진 문자형 벡터입니다.
  • str() 함수는 객체의 구조(structure)를 보여주는 매우 유용한 함수입니다. 리스트의 각 요소가 어떤 데이터 타입인지 한눈에 파악할 수 있습니다.

62. 신입사원 프로필 정보 추출하기

문제 상황: 인사팀에서 신입사원의 정보를 리스트로 관리하고 있습니다. 이 리스트에는 이름, 부서, 입사 연도, 그리고 교육 이수 과목 벡터가 포함되어 있습니다. 특정 신입사원의 부서와 첫 번째 교육 이수 과목을 추출해야 합니다.

과제: 아래 new_employee 리스트가 주어졌을 때, 다음 두 가지 정보를 추출하여 출력하세요.

  1. 부서(department) 정보
  2. 교육 이수 과목(courses) 중 첫 번째 과목
new_employee <- list(
  name = "김민준",
  department = "Data Science",
  entry_year = 2023,
  courses = c("R Programming", "SQL Basics", "Statistics 101")
)

정답 코드

new_employee <- list(
  name = "김민준",
  department = "Data Science",
  entry_year = 2023,
  courses = c("R Programming", "SQL Basics", "Statistics 101")
)

# 1. 부서 정보 추출
dept <- new_employee$department
print(paste("부서:", dept))

# 2. 첫 번째 교육 이수 과목 추출
first_course <- new_employee$courses[1]
# 또는 new_employee[["courses"]][1]
print(paste("첫 번째 과목:", first_course))

해설

리스트의 요소에 접근하는 방법은 여러 가지가 있으며, 상황에 따라 적절한 방법을 사용하는 것이 중요합니다.

  1. $ 연산자: 리스트이름$요소이름 형식으로 사용합니다. 가장 직관적이고 코드를 읽기 쉽게 만들어 줍니다. 이름이 지정된 요소에 접근할 때 주로 사용됩니다.
  2. [[ ]] 이중 대괄호: 리스트이름[["요소이름"]] 또는 리스트이름[[인덱스]] 형식으로 사용합니다. 리스트에서 단 하나의 요소를 그 자체의 데이터 타입으로 추출합니다. 예를 들어 new_employee[["courses"]]courses 벡터 자체를 반환합니다.
  3. [ ] 단일 대괄호: 리스트이름["요소이름"] 또는 리스트이름[인덱스] 형식으로 사용합니다. 항상 리스트 형태로 결과를 반환합니다. 즉, 원본 리스트의 일부를 잘라낸 '서브 리스트(sub-list)'를 만듭니다.

이 문제에서는 new_employee$courses를 통해 courses 벡터를 먼저 추출한 후, 벡터의 첫 번째 요소를 얻기 위해 [1]을 사용했습니다. 이는 new_employee[["courses"]][1]과 동일한 결과를 냅니다.


63. 프로젝트 정보 업데이트하기

문제 상황: 진행 중인 프로젝트의 상태를 기록한 리스트가 있습니다. 프로젝트 기간이 연장되었고, 새로운 팀원이 투입되었습니다. 이 정보를 리스트에 반영해야 합니다.

과제: 주어진 project_status 리스트를 다음 조건에 맞게 수정하세요.

  1. duration_months를 6에서 9로 변경하세요.
  2. team_members 벡터에 "David"를 추가하세요.
  3. 프로젝트의 마감일(deadline)을 "2024-12-31"로 하는 새로운 요소를 추가하세요.
project_status <- list(
  project_name = "Alpha",
  is_active = TRUE,
  duration_months = 6,
  team_members = c("Alice", "Bob", "Charlie")
)

정답 코드

project_status <- list(
  project_name = "Alpha",
  is_active = TRUE,
  duration_months = 6,
  team_members = c("Alice", "Bob", "Charlie")
)

# 1. 기간 변경
project_status$duration_months <- 9

# 2. 팀원 추가
project_status$team_members <- c(project_status$team_members, "David")

# 3. 마감일 요소 추가
project_status$deadline <- "2024-12-31"

# 변경된 리스트 확인
print(project_status)

해설

리스트는 '가변적(mutable)'인 객체로, 생성된 후에도 내용을 쉽게 추가, 수정, 삭제할 수 있습니다.

  • 요소 수정: 기존 요소에 접근하여 새로운 값을 할당(<-)하면 됩니다. project_status$duration_months <- 9duration_months의 값을 9로 덮어씁니다.
  • 벡터 요소 추가: 리스트 내의 벡터에 새 값을 추가하려면, 해당 벡터를 추출한 뒤 c() 함수를 사용해 기존 벡터와 새 값을 결합하고, 다시 원래 위치에 할당해야 합니다.
  • 새로운 요소 추가: 존재하지 않는 이름으로 값에 할당하면 리스트에 새로운 요소가 추가됩니다. project_status$deadline <- "..." 코드는 deadline이라는 새로운 요소를 리스트의 끝에 추가합니다.

64. 중첩 리스트에서 데이터 찾기

문제 상황: 회사의 조직 구조가 팀과 팀원으로 구성된 중첩 리스트(nested list)로 표현되어 있습니다. 'Sales' 부서의 'Q1_Team'에 속한 두 번째 팀원의 이름을 찾아야 합니다.

과제: 아래 org_chart 리스트에서 'Sales' 부서의 'Q1_Team'에 속한 두 번째 팀원("Grace")의 이름을 추출하여 출력하세요.

org_chart <- list(
  Marketing = list(
    Content_Team = c("Frank", "Ivy"),
    Ads_Team = c("Jack", "Karen")
  ),
  Sales = list(
    Q1_Team = c("Peter", "Grace"),
    Q2_Team = c("Heidi", "Judy")
  )
)

정답 코드

org_chart <- list(
  Marketing = list(
    Content_Team = c("Frank", "Ivy"),
    Ads_Team = c("Jack", "Karen")
  ),
  Sales = list(
    Q1_Team = c("Peter", "Grace"),
    Q2_Team = c("Heidi", "Judy")
  )
)

# 'Sales' 부서 -> 'Q1_Team' -> 두 번째 팀원
target_member <- org_chart$Sales$Q1_Team[2]
# 또는 org_chart[["Sales"]][["Q1_Team"]][2]

print(target_member)

해설

중첩 리스트는 리스트 안에 또 다른 리스트가 있는 구조입니다. 데이터에 접근하려면 계층 구조를 따라 차례대로 들어가야 합니다.

  • org_chart$Sales: 먼저 최상위 리스트에서 Sales 요소를 선택합니다. 이 결과는 Q1_TeamQ2_Team을 요소로 갖는 또 다른 리스트입니다.
  • org_chart$Sales$Q1_Team: 위에서 얻은 리스트에서 다시 $ 연산자를 사용해 Q1_Team 요소를 선택합니다. 이 결과는 c("Peter", "Grace")라는 문자형 벡터입니다.
  • org_chart$Sales$Q1_Team[2]: 최종적으로 얻은 벡터에서 [2]를 사용해 두 번째 원소인 "Grace"를 추출합니다.

[[ ]]를 사용해서도 동일한 결과를 얻을 수 있습니다. org_chart[["Sales"]][["Q1_Team"]][2]는 더 프로그래밍적인 접근 방식이며, 요소 이름을 변수로 다룰 때 유용합니다.


65. 리스트 요소의 평균값 일괄 계산하기

문제 상황: 여러 학생들의 과목별 시험 점수가 리스트에 저장되어 있습니다. 각 학생(리스트의 각 요소)의 평균 점수를 한 번에 계산하고 싶습니다.

과제: student_scores 리스트의 각 학생별 평균 점수를 계산하세요. lapply() 함수를 사용해 보세요.

student_scores <- list(
  student_A = c(85, 92, 88, 95),
  student_B = c(78, 81, 80),
  student_C = c(95, 99, 97, 98, 100)
)

정답 코드

student_scores <- list(
  student_A = c(85, 92, 88, 95),
  student_B = c(78, 81, 80),
  student_C = c(95, 99, 97, 98, 100)
)

# lapply를 사용하여 각 요소(점수 벡터)에 mean 함수를 적용
average_scores <- lapply(student_scores, mean)

# 결과 확인
print(average_scores)

해설

lapply()는 'list apply'의 약자로, 리스트의 각 요소에 특정 함수를 반복적으로 적용하고 그 결과를 리스트 형태로 반환하는 매우 강력한 함수입니다. for 반복문을 사용하는 것보다 훨씬 간결하고 효율적입니다.

  • lapply(X, FUN):
    • X: 함수를 적용할 리스트 (이 경우 student_scores).
    • FUN: 각 요소에 적용할 함수 (이 경우 mean).

lapply()student_scores 리스트를 순회하면서 각 요소(student_A의 점수 벡터, student_B의 점수 벡터 등)를 mean() 함수의 입력으로 전달합니다. mean() 함수의 계산 결과들을 모아 원래 리스트와 동일한 구조의 새 리스트로 만들어 반환합니다.

수학적으로 학생 A의 평균 점수는 다음과 같이 계산됩니다. $$ \bar{x}_A = \frac{85 + 92 + 88 + 95}{4} = 90 $$ lapply는 이 계산을 모든 학생에 대해 자동으로 수행해 줍니다.


66. 리스트 결과를 벡터로 변환하기

문제 상황: 앞선 65번 문제에서 lapply()를 사용해 학생들의 평균 점수를 리스트 형태로 얻었습니다. 이 결과를 분석 및 시각화하기 편하도록 숫자형 벡터로 변환하고 싶습니다.

과제: student_scores 리스트에 mean 함수를 적용하되, 결과가 리스트가 아닌 벡터 형태로 나오도록 하세요. sapply() 함수를 사용해 보세요.

student_scores <- list(
  student_A = c(85, 92, 88, 95),
  student_B = c(78, 81, 80),
  student_C = c(95, 99, 97, 98, 100)
)

정답 코드

student_scores <- list(
  student_A = c(85, 92, 88, 95),
  student_B = c(78, 81, 80),
  student_C = c(95, 99, 97, 98, 100)
)

# sapply를 사용하여 결과를 벡터로 단순화(simplify)
average_scores_vec <- sapply(student_scores, mean)

# 결과 확인
print(average_scores_vec)
class(average_scores_vec)

해설

sapply()는 'simplify apply'의 약자입니다. lapply()와 거의 동일하게 작동하지만, 마지막에 결과를 가능한 한 단순한 데이터 구조로 변환하려는 시도를 합니다.

  • 만약 모든 결과가 길이가 1인 벡터(스칼라)라면, sapply()는 이들을 모아 하나의 벡터로 반환합니다. (이 문제의 경우)
  • 만약 모든 결과가 길이가 같은 벡터라면, sapply()는 이들을 모아 행렬로 반환합니다.
  • 결과를 단순화할 수 없는 복잡한 구조라면, lapply()와 동일하게 리스트를 반환합니다.

이 문제에서는 mean() 함수의 결과가 모두 단일 숫자 값이므로, sapply()는 이 숫자들을 묶어 숫자형 벡터를 생성합니다. 데이터 분석 과정에서 lapply()의 결과를 다루기 쉬운 벡터나 행렬로 변환하고자 할 때 매우 유용합니다.


67. 쇼핑 목록 합치기

문제 상황: 어제 작성한 쇼핑 목록과 오늘 추가로 작성한 쇼핑 목록이 각각 별개의 리스트로 있습니다. 두 목록을 하나로 합쳐서 최종 쇼핑 목록을 만들어야 합니다.

과제: yesterday_listtoday_list를 합쳐 full_shopping_list라는 하나의 리스트를 생성하세요.

yesterday_list <- list(fruits = "apple", dairy = "milk")
today_list <- list(vegetable = "carrot", snacks = c("chip", "soda"))

정답 코드

yesterday_list <- list(fruits = "apple", dairy = "milk")
today_list <- list(vegetable = "carrot", snacks = c("chip", "soda"))

# c() 함수를 사용하여 리스트를 결합
full_shopping_list <- c(yesterday_list, today_list)

# 결과 확인
print(full_shopping_list)
str(full_shopping_list)

해설

c() 함수는 벡터를 결합하는 데 주로 사용되지만, 리스트를 결합하는 데에도 사용할 수 있습니다. c() 함수에 여러 리스트를 인자로 전달하면, 각 리스트의 요소들을 순서대로 포함하는 새로운 리스트를 생성합니다.

이 예제에서는 yesterday_list의 요소들(fruits, dairy)이 먼저 오고, 그 뒤에 today_list의 요소들(vegetable, snacks)이 추가되어 총 4개의 요소를 가진 full_shopping_list가 만들어집니다.


68. 설문조사 응답 범주화하기

문제 상황: 고객 만족도 설문조사에서 "매우 만족", "만족", "보통", "불만족"과 같은 응답을 문자열 벡터로 수집했습니다. 이 데이터를 통계 분석에 사용하기 위해 순서가 있는 범주형 데이터, 즉 팩터(factor)로 변환해야 합니다.

과제: 주어진 satisfaction_responses 문자열 벡터를 팩터로 변환하여 satisfaction_factor라는 변수에 저장하세요.

satisfaction_responses <- c("만족", "매우 만족", "보통", "만족", "불만족", "매우 만족")

정답 코드

satisfaction_responses <- c("만족", "매우 만족", "보통", "만족", "불만족", "매우 만족")

# factor() 함수를 사용하여 팩터로 변환
satisfaction_factor <- factor(satisfaction_responses)

# 결과 확인
print(satisfaction_factor)
class(satisfaction_factor)
levels(satisfaction_factor)

해설

팩터(Factor)는 명목형(nominal) 또는 순서형(ordinal) 변수와 같은 범주형 데이터를 저장하는 데 사용되는 R의 특별한 데이터 타입입니다. 내부적으로는 정수 벡터와 각 정수에 해당하는 문자열 라벨(level)의 매핑으로 저장되어 메모리를 효율적으로 사용하고, 통계 모델링(예: 회귀분석, ANOVA)에서 범주형 변수를 올바르게 처리하도록 돕습니다.

  • factor(): 벡터를 팩터로 변환하는 기본 함수입니다.
  • levels(): 팩터가 가질 수 있는 고유한 값(범주)들을 보여줍니다. 기본적으로 알파벳 순서로 정렬됩니다. 출력된 Levels를 보면 "만족", "매우 만족", "보통", "불만족" 순서가 아닌, "만족", "매우 만족", "불만족", "보통" 처럼 가나다 순으로 정렬된 것을 볼 수 있습니다.

69. 티셔츠 사이즈 순서 정하기

문제 상황: 온라인 쇼핑몰에서 티셔츠 사이즈 데이터를 수집했습니다. 사이즈는 "S", "M", "L", "XL"로, 명확한 순서가 있습니다. 이 순서 정보를 포함하는 팩터를 만들어야 합니다.

과제: tshirt_sizes 벡터를 "S" < "M" < "L" < "XL" 순서를 가지는 정렬된(ordered) 팩터로 만드세요.

tshirt_sizes <- c("M", "L", "S", "XL", "L", "M")

정답 코드

tshirt_sizes <- c("M", "L", "S", "XL", "L", "M")

# levels 인자를 사용해 순서를 직접 지정하고, ordered=TRUE로 설정
ordered_size_factor <- factor(
  tshirt_sizes,
  levels = c("S", "M", "L", "XL"),
  ordered = TRUE
)

# 결과 확인
print(ordered_size_factor)

# 순서 확인 (M > S)
print(ordered_size_factor[1] > ordered_size_factor[3]) # TRUE

해설

단순히 범주를 나누는 것을 넘어 범주 간에 순서가 있을 때, '정렬된 팩터(ordered factor)'를 사용합니다.

  • factor() 함수의 levels 인자: 팩터의 레벨 순서를 직접 지정할 수 있습니다. 여기에 원하는 순서대로 문자열 벡터를 전달하면 됩니다. c("S", "M", "L", "XL")는 "S"가 가장 낮은 레벨임을 명시합니다.
  • ordered = TRUE 인자: 이 팩터가 순서가 있음을 R에 알려줍니다.

이렇게 생성된 정렬된 팩터는 크기 비교가 가능합니다. 코드의 마지막 줄 ordered_size_factor[1] > ordered_size_factor[3]는 첫 번째 요소인 "M"과 세 번째 요소인 "S"를 비교합니다. 우리가 "M" > "S" 순서를 지정했기 때문에 이 비교는 TRUE를 반환합니다.


70. 혈액형 데이터 요약하기

문제 상황: 한 그룹의 사람들의 혈액형 데이터가 있습니다. 각 혈액형(A, B, AB, O)이 몇 명씩 있는지 간단하게 요약하고 싶습니다.

과제: blood_types 벡터를 팩터로 변환한 뒤, summary() 함수를 사용하여 각 혈액형의 빈도를 계산하세요.

blood_types <- c("A", "B", "O", "AB", "A", "O", "O", "B", "A")

정답 코드

blood_types <- c("A", "B", "O", "AB", "A", "O", "O", "B", "A")

# 팩터로 변환
blood_factor <- factor(blood_types)

# summary() 함수로 빈도 요약
summary(blood_factor)

해설

summary() 함수는 R에서 객체의 종류에 따라 다른 요약 정보를 보여주는 매우 유용한 일반(generic) 함수입니다.

  • 숫자형 벡터에 사용하면: 최소값, 1사분위수, 중앙값, 평균, 3사분위수, 최대값을 보여줍니다.
  • 팩터에 사용하면: 각 레벨(범주)에 해당하는 데이터의 개수(빈도, frequency)를 세어 보여줍니다.

이는 table() 함수를 사용한 것과 동일한 결과를 보여주며, 범주형 데이터의 분포를 빠르게 파악하는 데 매우 효과적입니다.


71. 팩터 레벨 이름 변경하기

문제 상황: 설문조사 데이터에서 성별이 'm'과 'f'로 코딩되어 있습니다. 보고서를 작성할 때 더 명확하게 보이기 위해 'Male'과 'Female'로 변경하고 싶습니다.

과제: gender_codes 팩터의 레벨을 'm'은 'Male'로, 'f'는 'Female'로 변경하세요.

gender_codes <- factor(c("m", "f", "f", "m", "m"))

정답 코드

gender_codes <- factor(c("m", "f", "f", "m", "m"))

# levels() 함수를 사용하여 레벨 이름 변경
levels(gender_codes) <- c("Female", "Male") # 알파벳 순서: f, m 순서에 맞춰서 지정

# 결과 확인
print(gender_codes)

해설

팩터의 레벨 이름을 변경하는 가장 일반적인 방법은 levels() 함수를 사용하는 것입니다.

  1. 먼저 levels(gender_codes)를 실행해 보면 현재 레벨과 그 순서("f", "m")를 확인할 수 있습니다.
  2. levels(gender_codes) <- c(...)와 같이 levels() 함수에 새로운 이름의 벡터를 할당하면, 기존 레벨이 순서대로 새로운 이름으로 바뀝니다.
    • 첫 번째 레벨인 "f"가 "Female"로
    • 두 번째 레벨인 "m"이 "Male"로 변경됩니다.

중요: 할당하는 새 레벨 벡터의 순서가 기존 레벨의 순서와 일치해야 합니다. 순서가 헷갈릴 경우, revalue()recode() 같은 plyr 또는 dplyr 패키지의 함수를 사용하는 것이 더 안전할 수 있습니다.


72. 리스트와 팩터의 결합: 실험 데이터 분석

문제 상황: 세 가지 다른 비료(A, B, C)가 식물 성장에 미치는 영향을 실험한 데이터가 있습니다. 결과는 "성장", "유지", "감소" 중 하나입니다. 이 데이터를 리스트로 정리하여 각 비료 그룹별 결과의 빈도를 분석하고 싶습니다.

과제:

  1. 각 비료 그룹의 결과를 팩터로 만드세요. "감소" < "유지" < "성장" 순서를 가지는 정렬된 팩터여야 합니다.
  2. 이 세 팩터를 experiment_results라는 이름의 리스트에 fertilizer_A, fertilizer_B, fertilizer_C라는 이름으로 저장하세요.
  3. lapply()summary()를 사용해 각 비료 그룹의 결과 빈도를 한 번에 출력하세요.
group_A <- c("성장", "성장", "유지")
group_B <- c("유지", "감소", "유지", "성장")
group_C <- c("감소", "감소")

정답 코드

group_A <- c("성장", "성장", "유지")
group_B <- c("유지", "감소", "유지", "성장")
group_C <- c("감소", "감소")

# 공통 레벨과 순서 정의
result_levels <- c("감소", "유지", "성장")

# 1. 각 그룹을 정렬된 팩터로 변환
factor_A <- factor(group_A, levels = result_levels, ordered = TRUE)
factor_B <- factor(group_B, levels = result_levels, ordered = TRUE)
factor_C <- factor(group_C, levels = result_levels, ordered = TRUE)

# 2. 팩터들을 리스트에 저장
experiment_results <- list(
  fertilizer_A = factor_A,
  fertilizer_B = factor_B,
  fertilizer_C = factor_C
)

# 3. lapply와 summary로 각 그룹 결과 빈도 분석
lapply(experiment_results, summary)

해설

이 문제는 리스트와 팩터를 함께 사용하여 구조화된 데이터를 분석하는 전형적인 예시입니다.

  • 공통 레벨 지정: 여러 팩터를 비교 분석할 때는 levels를 동일하게 지정하는 것이 중요합니다. result_levels 변수를 만들어 모든 팩터 생성 시 동일한 레벨과 순서를 사용하도록 했습니다. 이렇게 하면 group_C에 "성장"이나 "유지"가 없음에도 불구하고, 요약 결과에는 해당 레벨이 0으로 표시되어 모든 그룹을 동일한 기준으로 비교할 수 있습니다.
  • 리스트로 데이터 구조화: 관련된 데이터(각 그룹의 결과 팩터)를 하나의 리스트(experiment_results)로 묶으면 데이터를 관리하고 함수를 일괄 적용하기 편리해집니다.
  • 일괄 분석: lapply(experiment_results, summary) 코드는 리스트의 각 요소(각 비료 그룹 팩터)에 대해 summary() 함수를 적용하여, 각 그룹의 결과 빈도를 깔끔하게 계산해 줍니다. 이는 데이터 분석의 효율성을 크게 높여주는 R의 강력한 기능입니다.

73. 새로운 레벨 추가하기

문제 상황: 운영 중인 서비스의 사용자 등급을 팩터로 관리하고 있습니다. 기존 등급은 "Bronze", "Silver", "Gold"였는데, 이번에 "Platinum" 등급이 새로 추가되었습니다. 기존 팩터에 이 새로운 등급을 추가하고, 새로운 사용자를 "Platinum" 등급에 배정해야 합니다.

과제:

  1. 주어진 user_grades 팩터에 "Platinum"이라는 새로운 레벨을 추가하세요.
  2. 새로운 사용자("new_user")에게 "Platinum" 등급을 할당하여 updated_grades 팩터를 만드세요.
user_grades <- factor(c("Gold", "Silver", "Silver", "Bronze"))
new_user_grade <- "Platinum"

정답 코드

user_grades <- factor(c("Gold", "Silver", "Silver", "Bronze"))
new_user_grade <- "Platinum"

# 1. 새로운 레벨 추가
levels(user_grades) <- c(levels(user_grades), "Platinum")

# 2. 새로운 사용자를 포함한 업데이트된 팩터 생성
# 먼저 기존 팩터를 문자열 벡터로 변환 후 새 사용자 추가, 그리고 다시 팩터로 변환
updated_grades <- factor(
  c(as.character(user_grades), new_user_grade),
  levels = levels(user_grades) # 레벨 순서 유지를 위해 명시
)


# 결과 확인
print(updated_grades)
levels(updated_grades)

해설

존재하지 않는 레벨을 팩터에 직접 할당하려고 하면 에러가 발생합니다. 따라서 값을 추가하기 전에 먼저 레벨을 정의해주어야 합니다.

  • 레벨 추가: levels(user_grades) <- c(levels(user_grades), "Platinum") 코드는 기존 레벨 목록(levels(user_grades))에 "Platinum"을 추가(c(...))하여 새로운 레벨 목록으로 갱신합니다. 이 시점에서 user_grades 팩터는 "Platinum"이라는 값을 가질 준비가 된 것입니다.
  • 값 추가 및 팩터 재생성:
    1. as.character(user_grades): 기존 팩터를 순수한 문자열 벡터로 되돌립니다.
    2. c(..., new_user_grade): 이 벡터에 새로운 사용자의 등급을 추가합니다.
    3. factor(..., levels = levels(user_grades)): 새롭게 만들어진 문자열 벡터를 다시 팩터로 만듭니다. 이때 levels 인자를 명시적으로 지정하여, 우리가 원하는 레벨 순서("Bronze", "Silver", "Gold", "Platinum")를 유지하도록 합니다. 만약 levels를 지정하지 않으면 다시 알파벳 순으로 정렬될 수 있습니다.

74. 리스트에서 특정 조건의 데이터 필터링하기

문제 상황: 여러 영화의 정보가 담긴 리스트가 있습니다. 각 영화는 제목, 장르, 평점을 요소로 갖는 하위 리스트입니다. 이 중에서 평점이 9.0 이상인 영화의 제목만 추출하고 싶습니다.

과제: movie_list에서 평점(rating)이 9.0 이상인 모든 영화의 제목(title)을 추출하여 문자열 벡터로 만드세요.

movie_list <- list(
  list(title = "Inception", genre = "Sci-Fi", rating = 9.2),
  list(title = "The Godfather", genre = "Crime", rating = 9.5),
  list(title = "Shrek", genre = "Animation", rating = 7.9),
  list(title = "Parasite", genre = "Thriller", rating = 9.1)
)

정답 코드

movie_list <- list(
  list(title = "Inception", genre = "Sci-Fi", rating = 9.2),
  list(title = "The Godfather", genre = "Crime", rating = 9.5),
  list(title = "Shrek", genre = "Animation", rating = 7.9),
  list(title = "Parasite", genre = "Thriller", rating = 9.1)
)

# sapply를 사용하여 각 영화의 평점을 추출
ratings <- sapply(movie_list, function(movie) movie$rating)

# 평점이 9.0 이상인 영화의 인덱스를 찾음
high_rating_indices <- which(ratings >= 9.0)

# 해당 인덱스를 가진 영화들의 제목을 추출
high_rating_titles <- sapply(movie_list[high_rating_indices], function(movie) movie$title)

# 결과 확인
print(high_rating_titles)

해설

이 문제는 리스트를 다루는 데 있어 매우 실용적인 기술을 요구합니다. 여러 단계로 나누어 생각할 수 있습니다.

  1. 정보 추출: 먼저 각 영화의 평점 정보만 뽑아내야 합니다. sapply(movie_list, function(movie) movie$rating) 코드는 movie_list의 각 요소(영화 리스트)에 대해 익명 함수 function(movie) movie$rating을 적용합니다. 이 함수는 각 영화 리스트에서 rating 값을 추출하는 역할을 합니다. sapply 덕분에 결과는 숫자형 벡터 ratings가 됩니다.
  2. 조건 필터링: which(ratings >= 9.0)ratings 벡터에서 값이 9.0 이상인 요소들의 **인덱스(위치)**를 찾아냅니다. 결과는 c(1, 2, 4)가 됩니다.
  3. 최종 데이터 선택: movie_list[high_rating_indices]는 위에서 찾은 인덱스를 사용해 원본 리스트에서 조건에 맞는 영화들만으로 구성된 서브 리스트를 만듭니다.
  4. 제목 추출: 마지막으로, 이 서브 리스트에 다시 sapply를 적용하여 각 영화의 title만 추출해 최종적인 문자열 벡터를 완성합니다.

이러한 '추출 -> 필터링 -> 선택' 패턴은 R에서 데이터를 다룰 때 매우 흔하게 사용됩니다.


75. 팩터의 숫자 변환 함정 피하기

문제 상황: 온라인 설문조사에서 '1'~'5'점으로 평가된 점수가 있습니다. 이 데이터가 R로 불러와지면서 의도치 않게 팩터로 변환되었습니다. 이 팩터를 다시 숫자형으로 변환하여 평균을 계산해야 합니다.

과제: rating_factor를 올바른 방법으로 숫자형 벡터로 변환한 뒤, 평균을 계산하세요. as.numeric()을 바로 사용하는 경우와 올바른 방법을 비교해 보세요.

rating_factor <- factor(c("3", "5", "2", "4", "3", "5"))

정답 코드

rating_factor <- factor(c("3", "5", "2", "4", "3", "5"))

# 잘못된 방법: 팩터의 내부 정수 코드를 반환
wrong_numeric <- as.numeric(rating_factor)
print("잘못된 변환 결과:")
print(wrong_numeric)
print(paste("잘못 계산된 평균:", mean(wrong_numeric)))

# 올바른 방법: 팩터 -> 문자열 -> 숫자 순서로 변환
correct_numeric <- as.numeric(as.character(rating_factor))
print("올바른 변환 결과:")
print(correct_numeric)
print(paste("올바르게 계산된 평균:", mean(correct_numeric)))

해설

이것은 R을 처음 배울 때 가장 흔하게 겪는 함정 중 하나입니다.

  • 팩터의 내부 구조: 팩터는 내부적으로 정수(1, 2, 3...)로 데이터를 저장하고, 각 정수가 어떤 문자열 레벨("2", "3", "4", "5")에 해당하는지를 매핑 테이블로 가지고 있습니다. levels(rating_factor)를 해보면 c("2", "3", "4", "5") 순서일 것입니다. 따라서 내부적으로 "2"는 1, "3"은 2, "4"는 3, "5"는 4로 저장됩니다.
  • 잘못된 방법 as.numeric(factor): as.numeric()를 팩터에 직접 적용하면, 눈에 보이는 라벨("3", "5" 등)이 아닌, 내부적으로 저장된 정수 코드(2, 4 등)를 반환합니다. 따라서 c(2, 4, 1, 3, 2, 4)라는 원치 않는 결과가 나옵니다.
  • 올바른 방법 as.numeric(as.character(factor)): 이 함정을 피하기 위해서는 두 단계를 거쳐야 합니다.
    1. as.character(rating_factor): 팩터를 먼저 문자열 벡터 c("3", "5", "2", "4", "3", "5")로 변환합니다. 이 과정에서 내부 정수 코드가 아닌, 눈에 보이는 라벨 값들이 문자열로 바뀝니다.
    2. as.numeric(...): 이제 이 문자열 벡터를 as.numeric()으로 변환하면, 각 문자열이 올바르게 숫자로 변환됩니다.

이 두 단계 변환법은 팩터를 숫자로 바꿀 때 반드시 기억해야 할 중요한 테크닉입니다.


76. 리스트를 데이터 프레임으로 변환하기

문제 상황: 각각의 변수(이름, 나이, 도시)가 벡터로 저장된 리스트가 있습니다. 이는 데이터 분석에 널리 사용되는 데이터 프레임 형식과 유사합니다. 이 리스트를 실제 데이터 프레임으로 변환하여 더 쉽게 다루고 싶습니다.

과제: person_data_list의 모든 벡터들이 동일한 길이를 가진다고 가정하고, 이 리스트를 person_df라는 데이터 프레임으로 변환하세요.

person_data_list <- list(
  name = c("Alice", "Bob", "Charlie"),
  age = c(25, 30, 28),
  city = c("New York", "London", "Paris")
)

정답 코드

person_data_list <- list(
  name = c("Alice", "Bob", "Charlie"),
  age = c(25, 30, 28),
  city = c("New York", "London", "Paris")
)

# as.data.frame() 함수를 사용하여 변환
person_df <- as.data.frame(person_data_list)

# 결과 확인
print(person_df)
class(person_df)
str(person_df)

해설

리스트의 각 요소가 같은 길이의 벡터일 때, 이 리스트는 데이터 프레임으로 변환하기에 이상적인 구조입니다.

  • as.data.frame(): 이 함수는 리스트를 데이터 프레임으로 변환하는 가장 직접적인 방법입니다. 함수는 리스트의 각 요소를 데이터 프레임의 열(column)로 취급합니다. 리스트 요소의 이름(name, age, city)은 자동으로 열의 이름이 됩니다.
  • 변환 조건: 이 변환이 성공적으로 이루어지려면 리스트 내의 모든 벡터(또는 요소)가 동일한 길이를 가져야 합니다. 길이가 다르면 R은 재활용 규칙(recycling rule)을 적용하거나 오류를 발생시킬 수 있으므로 주의해야 합니다.

str(person_df)를 실행해보면, namecity가 R 4.0.0 버전 이후 기본값인 character 타입으로 유지되는 것을 볼 수 있습니다. (이전 버전에서는 factor로 자동 변환되는 경우가 많았음). 필요하다면 data.frame() 함수 사용 시 stringsAsFactors = TRUE/FALSE 옵션으로 조절할 수 있습니다.


77. unlist로 모든 값 추출하기

문제 상황: 여러 지역의 일일 판매량이 중첩된 리스트 구조로 저장되어 있습니다. 전체 지역의 총 판매량을 계산하기 위해 리스트 구조를 풀고 모든 숫자 값을 하나의 벡터로 만들어야 합니다.

과제: sales_data 리스트를 unlist() 함수를 사용하여 모든 숫자 값을 포함하는 단일 숫자형 벡터로 변환하고, 그 합계(총 판매량)를 계산하세요.

sales_data <- list(
  seoul = c(120, 150, 135),
  busan = c(80, 95),
  incheon = list(
    airport = 200,
    downtown = 75
  )
)

정답 코드

sales_data <- list(
  seoul = c(120, 150, 135),
  busan = c(80, 95),
  incheon = list(
    airport = 200,
    downtown = 75
  )
)

# unlist()를 사용하여 리스트를 재귀적으로 풀어 하나의 벡터로 만듦
all_sales_vector <- unlist(sales_data)

# 결과 확인
print(all_sales_vector)

# 총 판매량 계산
total_sales <- sum(all_sales_vector)
print(paste("총 판매량:", total_sales))

해설

unlist() 함수는 리스트의 계층 구조를 재귀적으로(recursively) 탐색하며 모든 원자(atomic) 요소를 추출하여 하나의 벡터로 만드는 강력한 도구입니다.

  • unlist(sales_data): 이 함수는 sales_data 리스트 내부를 탐색합니다. seoul 벡터의 120, 150, 135를 꺼내고, busan 벡터의 80, 95를 꺼냅니다. 그리고 중첩된 리스트인 incheon 안으로 더 들어가 airport200downtown75까지 꺼내옵니다.
  • 결과 벡터: 최종적으로 이 모든 숫자 값들을 순서대로 모아 하나의 벡터로 만듭니다. 결과 벡터의 이름은 seoul1, seoul2, busan1... 처럼 원래 데이터의 구조를 나타내는 이름으로 자동 생성됩니다.
  • sum(): 이렇게 평탄화된 벡터에 sum()과 같은 벡터 연산을 쉽게 적용하여 전체 합계나 평균 등을 계산할 수 있습니다.

78. 팩터 레벨 순서 변경하기

문제 상황: 어떤 제품에 대한 평가가 "Good", "Bad", "Excellent"로 기록된 팩터가 있습니다. 시각화나 분석을 할 때 "Bad" -> "Good" -> "Excellent" 순서로 나타내는 것이 더 자연스럽습니다.

과제: ratings 팩터의 레벨 순서를 "Bad", "Good", "Excellent" 순으로 재정렬하여 reordered_ratings라는 새로운 팩터를 만드세요.

ratings <- factor(c("Good", "Bad", "Excellent", "Good"))

정답 코드

ratings <- factor(c("Good", "Bad", "Excellent", "Good"))

# 현재 레벨 순서 확인 (알파벳 순)
print("기존 레벨 순서:")
print(levels(ratings))

# factor() 함수와 levels 인자를 사용하여 레벨 재정렬
reordered_ratings <- factor(ratings, levels = c("Bad", "Good", "Excellent"))

# 재정렬된 팩터와 레벨 순서 확인
print("재정렬된 팩터:")
print(reordered_ratings)
print("새로운 레벨 순서:")
print(levels(reordered_ratings))

해설

기존에 생성된 팩터의 레벨 순서를 바꾸는 가장 확실한 방법은 factor() 함수를 다시 사용하는 것입니다.

  • factor(ratings, levels = c("Bad", "Good", "Excellent")):
    • 첫 번째 인자 ratings: 순서를 바꿀 원본 팩터(또는 벡터)입니다.
    • levels 인자: 원하는 레벨의 순서를 명시적으로 지정합니다. R은 이 순서를 기준으로 팩터를 새로 구성합니다.

이 방법을 사용하면 데이터 값은 그대로 유지하면서 팩터의 내부적인 순서와 표현 방식만 변경할 수 있습니다. 이렇게 재정렬된 팩터는 ggplot2와 같은 시각화 도구에서 막대그래프 등을 그릴 때 x축의 순서를 원하는 대로 제어하는 데 매우 유용합니다.


79. split 함수로 데이터 분할하기

문제 상황: iris 데이터셋에는 여러 종(Species)의 붓꽃 데이터가 섞여 있습니다. 각 종(Species)별로 Sepal.Length(꽃받침 길이) 데이터를 분리하여 별도의 그룹으로 만들고 싶습니다.

과제: iris 데이터셋의 Sepal.Length 열을 Species 열을 기준으로 그룹화하여 리스트로 만드세요. split() 함수를 사용하면 이 작업을 간단하게 할 수 있습니다.


정답 코드

# iris 데이터셋은 R에 내장되어 있습니다.

# Sepal.Length를 Species를 기준으로 분할
sepal_length_by_species <- split(iris$Sepal.Length, iris$Species)

# 결과 확인 (리스트 형태)
str(sepal_length_by_species)
print(sepal_length_by_species)

# 각 종별 평균 꽃받침 길이 계산 (응용)
lapply(sepal_length_by_species, mean)

해설

split() 함수는 데이터 분석에서 데이터를 그룹별로 나누는 데 매우 핵심적인 역할을 합니다.

  • split(x, f):
    • x: 분할하려는 데이터 벡터 또는 데이터 프레임 (이 경우 iris$Sepal.Length).
    • f: 그룹화의 기준이 되는 팩터 또는 벡터 (이 경우 iris$Species). f의 각 레벨(또는 고유값)에 따라 x의 데이터가 나뉩니다.

split() 함수는 iris$Species 팩터의 레벨("setosa", "versicolor", "virginica")을 확인합니다. 그리고 iris$Sepal.Length 벡터를 처음부터 끝까지 순회하면서, 각 값이 어떤 Species에 해당하는지를 보고, 해당 Species의 이름을 가진 리스트 요소에 값을 담습니다.

결과적으로 setosa의 모든 Sepal.Length 값들을 담은 벡터, versicolor의 모든 Sepal.Length 값들을 담은 벡터, virginica의 모든 Sepal.Length 값들을 담은 벡터를 요소로 갖는 리스트가 생성됩니다. 이렇게 분할된 데이터는 lapplysapply를 사용하여 그룹별 통계량을 계산하는 데 매우 편리하게 사용됩니다.


80. 커피숍 주문 데이터 분석하기

문제 상황: 당신은 작은 커피숍의 데이터 분석가입니다. 오늘의 주문 기록이 리스트 형태로 있습니다. 각 주문은 음료 이름과 사이즈를 포함합니다. 가장 많이 주문된 음료 사이즈가 무엇인지 파악하고 싶습니다.

과제:

  1. orders 리스트에서 모든 주문의 사이즈(size) 정보만 추출하여 하나의 벡터로 만드세요. (sapply 사용)
  2. 추출된 사이즈 벡터를 "Small" < "Medium" < "Large" 순서를 가지는 정렬된 팩터로 변환하세요.
  3. summary() 함수를 사용하여 사이즈별 주문량을 계산하고, 가장 많이 주문된 사이즈가 무엇인지 확인하세요.
orders <- list(
  list(item = "Americano", size = "Medium"),
  list(item = "Latte", size = "Small"),
  list(item = "Americano", size = "Large"),
  list(item = "Espresso", size = "Small"),
  list(item = "Latte", size = "Medium"),
  list(item = "Americano", size = "Medium")
)

정답 코드

orders <- list(
  list(item = "Americano", size = "Medium"),
  list(item = "Latte", size = "Small"),
  list(item = "Americano", size = "Large"),
  list(item = "Espresso", size = "Small"),
  list(item = "Latte", size = "Medium"),
  list(item = "Americano", size = "Medium")
)

# 1. 모든 사이즈 정보 추출
order_sizes <- sapply(orders, function(order) order$size)
print("추출된 사이즈 벡터:")
print(order_sizes)

# 2. 정렬된 팩터로 변환
size_factor <- factor(
  order_sizes,
  levels = c("Small", "Medium", "Large"),
  ordered = TRUE
)
print("생성된 팩터:")
print(size_factor)

# 3. 사이즈별 주문량 요약
size_summary <- summary(size_factor)
print("사이즈별 주문량:")
print(size_summary)

# 가장 많이 주문된 사이즈는 "Medium" (3회) 입니다.

해설

이 문제는 리스트 데이터 처리, 팩터 생성, 그리고 데이터 요약이라는 여러 핵심 개념을 아우르는 실용적인 미니 분석 프로젝트입니다.

  1. 데이터 추출 (sapply): sapply(orders, function(order) order$size)는 리스트 orders의 각 요소(주문 리스트)를 순회하며, 각 주문에서 $size 값을 추출합니다. sapply를 사용했기 때문에 결과는 깔끔한 문자열 벡터 c("Medium", "Small", "Large", "Small", "Medium", "Medium")이 됩니다.
  2. 데이터 유형 변환 (factor): 커피 사이즈는 명확한 순서("Small" < "Medium" < "Large")를 가집니다. factor() 함수에 levelsordered = TRUE를 지정하여 이 순서 정보를 데이터에 부여합니다. 이는 나중에 데이터를 시각화할 때 x축을 논리적인 순서로 정렬하는 데 도움이 됩니다.
  3. 데이터 요약 (summary): 팩터에 summary() 함수를 적용하면 각 레벨의 빈도를 간단하게 계산할 수 있습니다. 출력 결과를 통해 "Medium" 사이즈가 3번으로 가장 많이 주문되었음을 한눈에 파악할 수 있습니다.

이러한 과정을 통해 복잡한 원시 데이터(리스트)를 의미 있는 정보(사이즈별 주문량)로 변환할 수 있습니다.

네, 알겠습니다. 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 사용자의 학습 여정에 큰 도움이 될 흥미롭고 실용적인 문제들을 생성해 드리겠습니다.

주제: 데이터프레임(Data Frame) 생성, 병합, 하위 집합 추출 (Base R) 범위: 초급 5단계 (81번 ~ 100번)


81. 마법학교 학생 명부 만들기

상황: 당신은 마법학교의 신입생 담당 조교가 되었습니다. 올해 입학한 신입생들의 이름, 기숙사, 그리고 입학 마법 시험 점수를 기록한 데이터프레임을 만들어야 합니다.

과제: 다음 정보를 담고 있는 students 라는 이름의 데이터프레임을 생성하세요.

  • 이름 (name): 'Harry', 'Hermione', 'Ron', 'Draco'
  • 기숙사 (house): 'Gryffindor', 'Gryffindor', 'Gryffindor', 'Slytherin'
  • 시험 점수 (score): 85, 99, 75, 88

정답 코드

students <- data.frame(
  name = c('Harry', 'Hermione', 'Ron', 'Draco'),
  house = c('Gryffindor', 'Gryffindor', 'Gryffindor', 'Slytherin'),
  score = c(85, 99, 75, 88)
)

print(students)

해설

data.frame() 함수는 R에서 가장 기본적인 데이터 구조 중 하나인 데이터프레임을 생성하는 데 사용됩니다. 데이터프레임은 행과 열로 이루어진 2차원 테이블 형태이며, 각 열은 서로 다른 데이터 타입(문자, 숫자, 논리 등)을 가질 수 있습니다.

  • data.frame(column_name1 = vector1, column_name2 = vector2, ...) 형식으로 사용합니다.
  • 각 벡터는 데이터프레임의 열(column)이 되며, 벡터의 이름이 열의 이름이 됩니다.
  • c() 함수는 여러 개의 원소를 결합하여 하나의 벡터(vector)를 만드는 데 사용됩니다.
  • 중요: 데이터프레임을 구성하는 모든 벡터는 길이가 같아야 합니다. 길이가 다르면 오류가 발생합니다. 이 예제에서는 모든 벡터의 길이가 4로 동일합니다.

82. 게임 캐릭터의 능력치(스탯) 확인하기

상황: 당신은 게임 개발자로, '판타지 월드' 게임의 캐릭터 정보를 데이터프레임으로 관리하고 있습니다. 특정 캐릭터의 한 가지 능력치만 빠르게 확인해야 할 때가 많습니다.

과제: 아래와 같이 주어진 characters 데이터프레임에서, 모든 캐릭터의 공격력(attack) 정보만 추출하여 출력하세요. `$`` 기호를 사용하세요.

# 주어진 데이터프레임
characters <- data.frame(
  name = c('Aragorn', 'Legolas', 'Gimli'),
  class = c('Ranger', 'Archer', 'Warrior'),
  attack = c(90, 95, 85),
  defense = c(80, 70, 98)
)

정답 코드

# 주어진 데이터프레임
characters <- data.frame(
  name = c('Aragorn', 'Legolas', 'Gimli'),
  class = c('Ranger', 'Archer', 'Warrior'),
  attack = c(90, 95, 85),
  defense = c(80, 70, 98)
)

# 공격력(attack) 열 추출
attack_stats <- characters$attack

print(attack_stats)

해설

데이터프레임에서 특정 열을 추출하는 가장 직관적이고 흔한 방법은 $ 기호를 사용하는 것입니다.

  • 데이터프레임이름$열이름 형식으로 사용합니다.
  • 이 방법은 코드가 간결하고 읽기 쉬워 대화형 분석(interactive analysis)에서 매우 유용합니다.
  • 결과값은 벡터(vector) 형태로 반환됩니다. 이 예제에서는 [1] 90 95 85 와 같은 숫자형 벡터가 출력됩니다.
  • $ 기호는 자동 완성(auto-completion) 기능을 지원하는 RStudio와 같은 개발 환경에서 특히 편리합니다.

83. 우주 탐사선 3호기의 비행 기록 조회

상황: 당신은 우주 관제 센터의 데이터 분석가입니다. 여러 탐사선의 비행 기록이 담긴 데이터프레임에서, 특정 순서의 기록(행)을 조회해야 합니다.

과제: 아래 flight_logs 데이터프레임에서 **3번째 행(row)**에 해당하는 모든 데이터를 추출하여 출력하세요.

# 주어진 데이터프레임
flight_logs <- data.frame(
  probe_id = c('Voyager1', 'Pioneer10', 'NewHorizons', 'Juno'),
  launch_year = c(1977, 1972, 2006, 2011),
  destination = c('Interstellar', 'Jupiter', 'Pluto', 'Jupiter'),
  is_active = c(TRUE, FALSE, TRUE, TRUE)
)

정답 코드

# 주어진 데이터프레임
flight_logs <- data.frame(
  probe_id = c('Voyager1', 'Pioneer10', 'NewHorizons', 'Juno'),
  launch_year = c(1977, 1972, 2006, 2011),
  destination = c('Interstellar', 'Jupiter', 'Pluto', 'Jupiter'),
  is_active = c(TRUE, FALSE, TRUE, TRUE)
)

# 3번째 행 추출
third_log <- flight_logs[3, ]

print(third_log)

해설

데이터프레임에서 특정 행이나 열, 혹은 특정 셀을 추출할 때는 대괄호 []를 사용합니다. 이를 '인덱싱(indexing)' 또는 '서브세팅(subsetting)'이라고 합니다.

  • 데이터프레임[행_인덱스, 열_인덱스] 형식이 기본입니다.
  • 3번째 행의 모든 열을 선택하기 위해 flight_logs[3, ]와 같이 작성합니다.
  • 쉼표(,) 뒤의 열 인덱스를 비워두면 '모든 열'을 의미하게 됩니다.
  • 결과값은 데이터프레임 형태로 반환됩니다. (이 경우 1개의 행과 4개의 열을 가진 데이터프레임)

84. 커피숍 메뉴판에서 특정 메뉴의 가격 찾기

상황: 당신은 단골 커피숍의 메뉴판을 데이터프레임으로 정리했습니다. 친구가 '카페라떼'의 가격이 얼마인지 물어봤습니다. 데이터프레임에서 정확한 값을 찾아줘야 합니다.

과제: 아래 cafe_menu 데이터프레임에서 2번째 행, 3번째 열에 위치한 값(카페라떼의 가격)을 추출하여 출력하세요.

# 주어진 데이터프레임
cafe_menu <- data.frame(
  category = c('Coffee', 'Coffee', 'Tea', 'Juice'),
  name = c('Americano', 'Caffe Latte', 'Green Tea', 'Orange Juice'),
  price = c(4100, 4600, 4500, 5500)
)

정답 코드

# 주어진 데이터프레임
cafe_menu <- data.frame(
  category = c('Coffee', 'Coffee', 'Tea', 'Juice'),
  name = c('Americano', 'Caffe Latte', 'Green Tea', 'Orange Juice'),
  price = c(4100, 4600, 4500, 5500)
)

# 2행 3열의 값 추출
latte_price <- cafe_menu[2, 3]

print(latte_price)

해설

데이터프레임[행_인덱스, 열_인덱스] 형식을 사용하여 특정 위치의 단일 값을 정확하게 추출할 수 있습니다.

  • cafe_menu[2, 3]cafe_menu 데이터프레임의 2번째 행과 3번째 열이 교차하는 지점의 값을 의미합니다.
  • 이 예제에서 2번째 행은 'Caffe Latte'의 정보이고, 3번째 열은 'price' 정보이므로, 결과적으로 'Caffe Latte'의 가격인 4600이 추출됩니다.
  • 이렇게 단일 값을 추출하면 결과는 벡터 형태로 반환됩니다.

85. 연구실 동물의 일부 정보만 선택하기

상황: 당신은 생물학 연구실의 연구원입니다. 실험 동물들의 다양한 정보(ID, 종, 무게, 나이)가 기록된 데이터프레임이 있습니다. 보고서 작성을 위해 이 동물들의 ID와 무게 정보만 따로 뽑아서 새로운 데이터프레임을 만들어야 합니다.

과제: 아래 lab_animals 데이터프레임에서 id 열과 weight_kg 열만 선택하여 animal_weights라는 새로운 데이터프레임을 생성하세요.

# 주어진 데이터프레임
lab_animals <- data.frame(
  id = c('R-01', 'R-02', 'M-01', 'M-02'),
  species = c('Rat', 'Rat', 'Mouse', 'Mouse'),
  weight_kg = c(0.45, 0.48, 0.02, 0.025),
  age_weeks = c(8, 9, 6, 6)
)

정답 코드

# 주어진 데이터프레임
lab_animals <- data.frame(
  id = c('R-01', 'R-02', 'M-01', 'M-02'),
  species = c('Rat', 'Rat', 'Mouse', 'Mouse'),
  weight_kg = c(0.45, 0.48, 0.02, 0.025),
  age_weeks = c(8, 9, 6, 6)
)

# 'id'와 'weight_kg' 열 선택
animal_weights <- lab_animals[, c('id', 'weight_kg')]

print(animal_weights)

해설

여러 개의 열을 선택할 때는 열 인덱스 부분에 선택하고 싶은 열 이름들을 문자형 벡터로 전달합니다.

  • 데이터프레임[, c('열이름1', '열이름2', ...)] 형식으로 사용합니다.
  • 쉼표(,) 앞의 행 인덱스를 비워두면 '모든 행'을 의미합니다.
  • c('id', 'weight_kg')는 'id'와 'weight_kg'라는 문자열을 담은 벡터를 생성하고, 이 벡터를 열 인덱스로 사용하여 해당 열들을 선택합니다.
  • 이 방법은 열의 순서나 개수에 상관없이 이름으로 정확하게 열을 선택할 수 있어 매우 유용합니다. 숫자 인덱스(예: lab_animals[, c(1, 3)])를 사용할 수도 있지만, 열의 순서가 바뀔 경우 코드를 수정해야 하므로 이름으로 선택하는 것이 더 안정적입니다.

86. 고득점 학생 필터링하기

상황: 마법학교의 덤블도어 교장 선생님이 마법 시험에서 90점 이상을 받은 우수한 학생들의 명단을 요청했습니다. 당신은 students 데이터프레임에서 해당 학생들을 찾아야 합니다.

과제: 81번 문제에서 만들었던 students 데이터프레임에서, score가 90점 이상인 학생들의 모든 정보를 추출하여 출력하세요.

# 주어진 데이터프레임
students <- data.frame(
  name = c('Harry', 'Hermione', 'Ron', 'Draco'),
  house = c('Gryffindor', 'Gryffindor', 'Gryffindor', 'Slytherin'),
  score = c(85, 99, 75, 88)
)

정답 코드

# 주어진 데이터프레임
students <- data.frame(
  name = c('Harry', 'Hermione', 'Ron', 'Draco'),
  house = c('Gryffindor', 'Gryffindor', 'Gryffindor', 'Slytherin'),
  score = c(85, 99, 75, 88)
)

# score가 90점 이상인 학생 필터링
high_scorers <- students[students$score >= 90, ]

print(high_scorers)

해설

데이터 분석에서 가장 핵심적인 작업 중 하나는 특정 조건을 만족하는 데이터를 필터링하는 것입니다. R에서는 **논리적 인덱싱(logical indexing)**을 사용하여 이 작업을 수행합니다.

  1. 조건 생성: students$score >= 90students 데이터프레임의 score 열의 각 원소에 대해 90 이상인지 아닌지를 검사합니다. 이 연산의 결과는 FALSE, TRUE, FALSE, FALSE 와 같은 논리형(logical) 벡터가 됩니다.
  2. 행 인덱스에 적용: 이 논리형 벡터를 행 인덱스 [] 안에 넣습니다: students[c(FALSE, TRUE, FALSE, FALSE), ].
  3. 필터링: R은 벡터에서 TRUE에 해당하는 위치의 행들만 선택합니다. 이 경우, 두 번째 행('Hermione')만 TRUE이므로 해당 행이 추출됩니다.

이처럼 조건식을 행 인덱스 자리에 직접 넣어주면 매우 간결하고 효율적으로 데이터를 필터링할 수 있습니다.


87. 특정 기숙사의 우수 학생 찾기

상황: 덤블도어 교장 선생님이 이번에는 "그리핀도르(Gryffindor) 기숙사 학생이면서, 시험 점수가 80점 이상인 학생"의 명단을 특별히 요청했습니다. 두 가지 조건을 동시에 만족해야 합니다.

과제: students 데이터프레임에서 house가 'Gryffindor'이고 그리고(AND) score가 80점 이상인 학생의 모든 정보를 추출하세요.

# 주어진 데이터프레임
students <- data.frame(
  name = c('Harry', 'Hermione', 'Ron', 'Draco'),
  house = c('Gryffindor', 'Gryffindor', 'Gryffindor', 'Slytherin'),
  score = c(85, 99, 75, 88)
)

정답 코드

# 주어진 데이터프레임
students <- data.frame(
  name = c('Harry', 'Hermione', 'Ron', 'Draco'),
  house = c('Gryffindor', 'Gryffindor', 'Gryffindor', 'Slytherin'),
  score = c(85, 99, 75, 88)
)

# 그리핀도르이면서 80점 이상인 학생 필터링
gryffindor_high_scorers <- students[students$house == 'Gryffindor' & students$score >= 80, ]

print(gryffindor_high_scorers)

해설

여러 조건을 동시에 만족하는 데이터를 필터링할 때는 논리 연산자 & (AND)를 사용합니다.

  • students$house == 'Gryffindor' : house 열이 'Gryffindor'와 같은지 비교하여 논리형 벡터 (TRUE, TRUE, TRUE, FALSE)를 생성합니다.
  • students$score >= 80 : score 열이 80 이상인지 비교하여 논리형 벡터 (TRUE, TRUE, FALSE, TRUE)를 생성합니다.
  • & 연산자는 두 논리형 벡터의 각 원소를 비교하여, **둘 다 TRUE일 경우에만 TRUE**를 반환합니다.
    • TRUE & TRUE -> TRUE (Harry)
    • TRUE & TRUE -> TRUE (Hermione)
    • TRUE & FALSE -> FALSE (Ron)
    • FALSE & TRUE -> FALSE (Draco)
  • 최종적으로 c(TRUE, TRUE, FALSE, FALSE) 벡터가 행 인덱스로 사용되어 'Harry'와 'Hermione'의 정보가 추출됩니다.
  • 주의: R에서 &&는 벡터의 첫 번째 원소만 비교하므로, 데이터프레임 필터링과 같이 원소별(element-wise) 비교가 필요할 때는 반드시 &를 사용해야 합니다.

88. 특정 직업군 필터링하기

상황: '판타지 월드' 게임의 밸런스 조정을 위해, 직업이 '전사(Warrior)'이거나 또는(OR) '궁수(Archer)'인 캐릭터들의 데이터를 분석하려고 합니다.

과제: 82번 문제의 characters 데이터프레임에서 class가 'Warrior'이거나 또는 'Archer'인 캐릭터들의 모든 정보를 추출하세요.

# 주어진 데이터프레임
characters <- data.frame(
  name = c('Aragorn', 'Legolas', 'Gimli'),
  class = c('Ranger', 'Archer', 'Warrior'),
  attack = c(90, 95, 85),
  defense = c(80, 70, 98)
)

정답 코드

# 주어진 데이터프레임
characters <- data.frame(
  name = c('Aragorn', 'Legolas', 'Gimli'),
  class = c('Ranger', 'Archer', 'Warrior'),
  attack = c(90, 95, 85),
  defense = c(80, 70, 98)
)

# 'Warrior' 또는 'Archer' 직업 필터링
warrior_or_archer <- characters[characters$class == 'Warrior' | characters$class == 'Archer', ]

print(warrior_or_archer)

해설

여러 조건 중 하나라도 만족하는 데이터를 필터링할 때는 논리 연산자 | (OR)를 사용합니다.

  • characters$class == 'Warrior' : FALSE, FALSE, TRUE
  • characters$class == 'Archer' : FALSE, TRUE, FALSE
  • | 연산자는 두 논리형 벡터의 각 원소를 비교하여, **둘 중 하나라도 TRUE이면 TRUE**를 반환합니다.
    • FALSE | FALSE -> FALSE (Aragorn)
    • FALSE | TRUE -> TRUE (Legolas)
    • TRUE | FALSE -> TRUE (Gimli)
  • 최종적으로 c(FALSE, TRUE, TRUE) 벡터가 행 인덱스로 사용되어 'Legolas'와 'Gimli'의 정보가 추출됩니다.
  • 주의: &와 마찬가지로, 원소별 비교에는 ||가 아닌 |를 사용해야 합니다.

89. 관심 기숙사 학생들만 모아보기

상황: 당신은 '그리핀도르'와 '슬리데린' 기숙사 간의 라이벌 관계를 분석하는 기사를 쓰려고 합니다. 두 기숙사 소속 학생들의 데이터만 필요합니다.

과제: students 데이터프레임에서 house가 'Gryffindor' 또는 'Slytherin'에 속하는 모든 학생 정보를 추출하세요. 이번에는 %in% 연산자를 사용해 보세요.

# 주어진 데이터프레임
students <- data.frame(
  name = c('Harry', 'Hermione', 'Ron', 'Draco'),
  house = c('Gryffindor', 'Gryffindor', 'Gryffindor', 'Slytherin'),
  score = c(85, 99, 75, 88)
)

정답 코드

# 주어진 데이터프레임
students <- data.frame(
  name = c('Harry', 'Hermione', 'Ron', 'Draco'),
  house = c('Gryffindor', 'Gryffindor', 'Gryffindor', 'Slytherin'),
  score = c(85, 99, 75, 88)
)

# 'Gryffindor' 또는 'Slytherin'에 속하는 학생 필터링
target_houses <- c('Gryffindor', 'Slytherin')
selected_students <- students[students$house %in% target_houses, ]

print(selected_students)

해설

%in% 연산자는 특정 벡터의 각 원소가 다른 벡터에 포함되어 있는지 여부를 확인하는 매우 유용한 도구입니다.

  • A %in% B는 벡터 A의 각 원소가 벡터 B 안에 존재하는지 확인하여 논리형 벡터를 반환합니다.
  • students$house %in% c('Gryffindor', 'Slytherin') 코드는 students$house의 각 원소('Gryffindor', 'Gryffindor', 'Gryffindor', 'Slytherin')가 c('Gryffindor', 'Slytherin') 벡터에 포함되는지 검사합니다.
  • 그 결과 TRUE, TRUE, TRUE, TRUE 라는 논리형 벡터가 생성되고, 이를 이용해 모든 학생의 정보가 추출됩니다.
  • | 연산자를 여러 번 사용하는 것(students$house == 'Gryffindor' | students$house == 'Slytherin' | ...)보다 %in%을 사용하는 것이 코드가 훨씬 간결하고 가독성이 높아집니다. 특히 비교할 대상이 많을 때 강력한 힘을 발휘합니다.

90. 특정 조건을 만족하는 행의 번호 찾기

상황: 우주 관제 센터에서 목성(Jupiter)으로 향하는 탐사선이 몇 번째 기록에 있는지 그 **위치(행 번호)**를 정확히 알아내야 합니다. 데이터 자체가 아니라, 데이터의 위치가 궁금한 상황입니다.

과제: flight_logs 데이터프레임에서 destination이 'Jupiter'인 행들의 인덱스 번호를 찾아서 출력하세요. which() 함수를 사용하세요.

# 주어진 데이터프레임
flight_logs <- data.frame(
  probe_id = c('Voyager1', 'Pioneer10', 'NewHorizons', 'Juno'),
  launch_year = c(1977, 1972, 2006, 2011),
  destination = c('Interstellar', 'Jupiter', 'Pluto', 'Jupiter'),
  is_active = c(TRUE, FALSE, TRUE, TRUE)
)

정답 코드

# 주어진 데이터프레임
flight_logs <- data.frame(
  probe_id = c('Voyager1', 'Pioneer10', 'NewHorizons', 'Juno'),
  launch_year = c(1977, 1972, 2006, 2011),
  destination = c('Interstellar', 'Jupiter', 'Pluto', 'Jupiter'),
  is_active = c(TRUE, FALSE, TRUE, TRUE)
)

# 'destination'이 'Jupiter'인 행의 인덱스 찾기
jupiter_indices <- which(flight_logs$destination == 'Jupiter')

print(jupiter_indices)

해설

which() 함수는 논리형 벡터를 입력받아 TRUE 값을 가지는 원소의 인덱스(위치)를 반환합니다.

  1. flight_logs$destination == 'Jupiter'는 논리형 벡터 c(FALSE, TRUE, FALSE, TRUE)를 생성합니다.
  2. which(c(FALSE, TRUE, FALSE, TRUE))TRUE가 위치한 2번째와 4번째 자리를 찾아 숫자형 벡터 c(2, 4)를 반환합니다.

이 기능은 단순히 데이터를 필터링하는 것을 넘어, 특정 조건을 만족하는 데이터가 어디에 있는지 그 위치 정보가 필요할 때 유용하게 사용됩니다. 예를 들어, 찾은 인덱스를 이용해 다른 데이터프레임의 동일한 위치에 있는 값을 수정하거나 참조하는 등의 복잡한 작업을 수행할 수 있습니다.


91. 고객 정보와 주문 정보 합치기

상황: 당신은 온라인 쇼핑몰의 데이터 분석가입니다. 고객 정보가 담긴 customers 데이터프레임과, 주문 정보가 담긴 orders 데이터프레임이 있습니다. 어떤 고객이 무엇을 주문했는지 한눈에 보기 위해 두 데이터를 합쳐야 합니다.

과제: customersorders 데이터프레임을 customer_id를 기준으로 병합(merge)하여 customer_orders라는 새로운 데이터프레임을 만드세요.

# 주어진 데이터프레임
customers <- data.frame(
  customer_id = c(101, 102, 103, 104),
  name = c('Alice', 'Bob', 'Charlie', 'David'),
  city = c('Seoul', 'Busan', 'Seoul', 'Incheon')
)

orders <- data.frame(
  order_id = c(1, 2, 3, 4, 5),
  customer_id = c(102, 103, 101, 103, 105),
  product = c('Book', 'Pen', 'Laptop', 'Mouse', 'Keyboard')
)

정답 코드

# 주어진 데이터프레임
customers <- data.frame(
  customer_id = c(101, 102, 103, 104),
  name = c('Alice', 'Bob', 'Charlie', 'David'),
  city = c('Seoul', 'Busan', 'Seoul', 'Incheon')
)

orders <- data.frame(
  order_id = c(1, 2, 3, 4, 5),
  customer_id = c(102, 103, 101, 103, 105),
  product = c('Book', 'Pen', 'Laptop', 'Mouse', 'Keyboard')
)

# customer_id를 기준으로 두 데이터프레임 병합
customer_orders <- merge(customers, orders, by = 'customer_id')

print(customer_orders)

해설

merge() 함수는 두 데이터프레임을 공통된 열(key)을 기준으로 합치는 역할을 합니다. SQL의 JOIN과 유사한 개념입니다.

  • merge(x, y, by = "공통열이름") 형식으로 사용합니다.
  • by 인자에 기준이 되는 열의 이름을 지정합니다. 두 데이터프레임에 공통으로 존재하는 열의 이름이 같다면, R이 자동으로 해당 열을 기준으로 병합하기도 하지만, 명시적으로 by를 지정하는 것이 더 안전하고 명확한 코드입니다.
  • 기본적으로 merge()내부 조인(Inner Join) 방식으로 동작합니다. 즉, by로 지정된 열의 값이 두 데이터프레임 모두에 존재하는 행들만 결과에 포함됩니다.
  • customerscustomer_id는 101, 102, 103, 104가 있고, orders에는 101, 102, 103, 105가 있습니다. 공통된 ID는 101, 102, 103이므로, 이 고객들의 정보만 병합되어 결과에 나타납니다.
  • 주문 기록이 없는 고객(David, 104)과 고객 정보가 없는 주문(customer_id 105)은 결과에서 제외됩니다.

92. 모든 학생과 그들의 동아리 활동 (Left Join)

상황: 마법학교에서 모든 학생의 명단(students)과, 동아리에 가입한 학생들의 정보(clubs)를 가지고 있습니다. 모든 학생을 기준으로, 어떤 동아리에 가입했는지, 혹은 가입하지 않았는지를 포함한 전체 명단을 만들고 싶습니다.

과제: students 데이터프레임을 기준으로 clubs 데이터프레임을 병합하세요. 동아리에 가입하지 않은 학생 정보도 결과에 반드시 포함되어야 합니다. (Left Join)

# 주어진 데이터프레임
students <- data.frame(
  student_id = c(1, 2, 3, 4),
  name = c('Harry', 'Hermione', 'Ron', 'Neville')
)

clubs <- data.frame(
  club_id = c(10, 20),
  club_name = c('Dueling Club', 'Herbology Club'),
  student_id = c(1, 4)
)

정답 코드

# 주어진 데이터프레임
students <- data.frame(
  student_id = c(1, 2, 3, 4),
  name = c('Harry', 'Hermione', 'Ron', 'Neville')
)

clubs <- data.frame(
  club_id = c(10, 20),
  club_name = c('Dueling Club', 'Herbology Club'),
  student_id = c(1, 4)
)

# students를 기준으로 left join 수행
student_club_info <- merge(students, clubs, by = 'student_id', all.x = TRUE)

print(student_club_info)

해설

**왼쪽 외부 조인(Left Outer Join)**은 첫 번째(왼쪽) 데이터프레임의 모든 행을 유지하면서, 두 번째(오른쪽) 데이터프레임에서 일치하는 정보를 가져와 붙이는 방식입니다.

  • merge() 함수에서 all.x = TRUE 옵션을 사용하면 Left Join을 수행할 수 있습니다. (x는 첫 번째 인자로 들어온 데이터프레임을 의미)
  • students 데이터프레임의 모든 학생(Harry, Hermione, Ron, Neville) 정보는 결과에 그대로 남습니다.
  • clubs 데이터프레임에서 student_id가 일치하는 학생(Harry, Neville)에게는 동아리 정보가 추가됩니다.
  • student_id가 일치하지 않는 학생(Hermione, Ron)의 동아리 관련 열(club_id, club_name)은 결측값인 NA (Not Available)로 채워집니다.
  • 이 방법은 "A를 기준으로 B 정보를 붙이고 싶을 때" 매우 유용합니다.

93. 판매된 모든 상품과 공급처 정보 (Right Join)

상황: 당신은 마트의 재고 관리 담당자입니다. 현재 마트에서 판매하는 모든 상품 목록(products)과, 오늘 판매된 상품 목록(sales)이 있습니다. 오늘 판매된 모든 상품을 기준으로, 해당 상품의 카테고리와 공급처 정보를 확인하고 싶습니다.

과제: sales 데이터프레임을 기준으로 products 데이터프레임을 병합하세요. products 목록에는 없지만 sales에 기록된 상품이 있더라도, 판매 기록은 모두 결과에 포함되어야 합니다. (Right Join)

# 주어진 데이터프레임
products <- data.frame(
  product_id = c('P01', 'P02', 'P03'),
  product_name = c('Milk', 'Bread', 'Cheese'),
  supplier = c('Farm A', 'Bakery B', 'Factory C')
)

sales <- data.frame(
  sale_id = 1:4,
  product_id = c('P02', 'P01', 'P02', 'P04'),
  quantity = c(2, 1, 1, 3)
)

정답 코드

# 주어진 데이터프레임
products <- data.frame(
  product_id = c('P01', 'P02', 'P03'),
  product_name = c('Milk', 'Bread', 'Cheese'),
  supplier = c('Farm A', 'Bakery B', 'Factory C')
)

sales <- data.frame(
  sale_id = 1:4,
  product_id = c('P02', 'P01', 'P02', 'P04'),
  quantity = c(2, 1, 1, 3)
)

# sales를 기준으로 right join 수행
sales_with_product_info <- merge(products, sales, by = 'product_id', all.y = TRUE)

print(sales_with_product_info)

해설

**오른쪽 외부 조인(Right Outer Join)**은 두 번째(오른쪽) 데이터프레임의 모든 행을 유지하면서, 첫 번째(왼쪽) 데이터프레임에서 일치하는 정보를 가져와 붙이는 방식입니다.

  • merge() 함수에서 all.y = TRUE 옵션을 사용하면 Right Join을 수행할 수 있습니다. (y는 두 번째 인자로 들어온 데이터프레임을 의미)
  • sales 데이터프레임의 모든 판매 기록은 결과에 그대로 남습니다.
  • products 데이터프레임에서 product_id가 일치하는 판매 기록('P01', 'P02')에는 상품 정보(product_name, supplier)가 추가됩니다.
  • products 목록에 존재하지 않는 상품('P04')의 판매 기록에 대한 상품 정보 열은 NA로 채워집니다.
  • 반면, 판매되지 않은 상품('P03', Cheese)은 결과에서 제외됩니다.
  • merge(x, y, all.y = TRUE)merge(y, x, all.x = TRUE)와 결과의 행은 같지만 열의 순서가 다를 수 있습니다. 기준이 되는 데이터프레임을 무엇으로 할지에 따라 선택하면 됩니다.

94. 모든 직원과 모든 부서의 전체 목록 (Full Outer Join)

상황: 당신은 회사의 인사팀 직원입니다. 회사 전체 직원 정보(employees)와 전체 부서 목록(departments)을 가지고 있습니다. 아직 부서에 배정되지 않은 신입사원이나, 직원이 한 명도 없는 신생 부서까지 모두 포함된 통합 명단을 만들고 싶습니다.

과제: employeesdepartments 데이터프레임을 dept_id를 기준으로 병합하되, 어느 한쪽에만 정보가 있는 경우에도 모두 결과에 포함되도록 하세요. (Full Outer Join)

# 주어진 데이터프레임
employees <- data.frame(
  emp_id = c(1, 2, 3),
  emp_name = c('Peter', 'Olivia', 'Walter'),
  dept_id = c('D1', 'D2', 'D4') # D4는 신입사원 임시 부서
)

departments <- data.frame(
  dept_id = c('D1', 'D2', 'D3'),
  dept_name = c('Sales', 'Marketing', 'R&D') # D3는 신생 부서
)

정답 코드

# 주어진 데이터프레임
employees <- data.frame(
  emp_id = c(1, 2, 3),
  emp_name = c('Peter', 'Olivia', 'Walter'),
  dept_id = c('D1', 'D2', 'D4')
)

departments <- data.frame(
  dept_id = c('D1', 'D2', 'D3'),
  dept_name = c('Sales', 'Marketing', 'R&D')
)

# Full outer join 수행
full_list <- merge(employees, departments, by = 'dept_id', all = TRUE)

print(full_list)

해설

**완전 외부 조인(Full Outer Join)**은 양쪽 데이터프레임의 모든 행을 유지하고, 일치하는 키를 기준으로 데이터를 합치는 방식입니다.

  • merge() 함수에서 all = TRUE 옵션을 사용하면 Full Outer Join을 수행할 수 있습니다. (all.x = TRUEall.y = TRUE를 동시에 사용한 것과 같습니다.)
  • 결과 분석:
    • D1, D2: 양쪽에 모두 존재하므로 직원 정보와 부서 정보가 모두 채워집니다.
    • D4: employees에는 있지만 departments에는 없으므로, dept_nameNA가 됩니다. (부서 미정 직원)
    • D3: departments에는 있지만 employees에는 없으므로, emp_idemp_nameNA가 됩니다. (직원 없는 부서)
  • 이 방법은 두 데이터셋의 모든 정보를 손실 없이 확인하고 싶을 때 사용하며, 데이터 정합성을 검토하는 데에도 유용합니다.

95. ID 이름이 다른 데이터 병합하기

상황: 당신은 두 개의 다른 시스템에서 추출한 데이터를 분석해야 합니다. 하나는 user_info (사용자 정보)이고 다른 하나는 game_logs (게임 접속 기록)입니다. 문제는 user_info에서는 사용자 ID를 user_id라고 부르고, game_logs에서는 player_id라고 부른다는 점입니다.

과제: user_infogame_logs 데이터프레임을 사용자 ID 기준으로 병합하세요. 두 데이터프레임에서 기준이 되는 열의 이름이 다르다는 점에 유의하세요.

# 주어진 데이터프레임
user_info <- data.frame(
  user_id = c(101, 102, 103),
  name = c('Sonic', 'Tails', 'Knuckles')
)

game_logs <- data.frame(
  log_time = c('2023-10-27 10:00', '2023-10-27 10:02', '2023-10-27 10:05'),
  player_id = c(102, 101, 102),
  action = c('login', 'login', 'move')
)

정답 코드

# 주어진 데이터프레임
user_info <- data.frame(
  user_id = c(101, 102, 103),
  name = c('Sonic', 'Tails', 'Knuckles')
)

game_logs <- data.frame(
  log_time = c('2023-10-27 10:00', '2023-10-27 10:02', '2023-10-27 10:05'),
  player_id = c(102, 101, 102),
  action = c('login', 'login', 'move')
)

# 서로 다른 이름의 열을 기준으로 병합
merged_data <- merge(user_info, game_logs, by.x = 'user_id', by.y = 'player_id')

print(merged_data)

해설

merge() 함수는 by.xby.y 인자를 통해 서로 다른 이름의 열을 기준으로 두 데이터프레임을 병합하는 기능을 제공합니다.

  • by.x = "열이름_x": 첫 번째 데이터프레임(x)에서 사용할 기준 열의 이름을 지정합니다.
  • by.y = "열이름_y": 두 번째 데이터프레임(y)에서 사용할 기준 열의 이름을 지정합니다.
  • 이 예제에서는 user_infouser_idgame_logsplayer_id가 같은 의미를 가지므로, 각각 by.xby.y에 지정해줍니다.
  • 결과 데이터프레임에는 by.x로 지정한 user_id 열이 공통 ID 열로 포함됩니다.
  • 이 기능은 표준화되지 않은 여러 데이터 소스를 통합해야 하는 현실적인 데이터 분석 상황에서 매우 중요합니다.

96. RPG 캐릭터 능력치 분석: 전사 클래스 최강자 찾기

상황: 당신은 RPG 게임 기획자입니다. 캐릭터들의 밸런스를 확인하기 위해, 직업이 'Warrior'인 캐릭터들 중에서 공격력(attack)이 90 이상인 캐릭터를 찾고, 그들의 이름과 공격력, 방어력(defense)만 따로 보고 싶습니다.

과제: 아래 rpg_chars 데이터프레임에 대해 다음 두 단계를 순서대로 수행하세요.

  1. class가 'Warrior'이고 attack이 90 이상인 캐릭터들의 데이터를 필터링하세요.
  2. 필터링된 결과에서 name, attack, defense 열만 선택하여 출력하세요.
# 주어진 데이터프레임
rpg_chars <- data.frame(
  name = c('Barbarian', 'Sorceress', 'Paladin', 'Amazon', 'Crusader'),
  class = c('Warrior', 'Mage', 'Warrior', 'Archer', 'Warrior'),
  level = c(99, 95, 98, 96, 92),
  attack = c(95, 70, 88, 85, 92),
  defense = c(80, 50, 95, 60, 98)
)

정답 코드

# 주어진 데이터프레임
rpg_chars <- data.frame(
  name = c('Barbarian', 'Sorceress', 'Paladin', 'Amazon', 'Crusader'),
  class = c('Warrior', 'Mage', 'Warrior', 'Archer', 'Warrior'),
  level = c(99, 95, 98, 96, 92),
  attack = c(95, 70, 88, 85, 92),
  defense = c(80, 50, 95, 60, 98)
)

# 1단계: 조건에 맞는 행 필터링
strong_warriors <- rpg_chars[rpg_chars$class == 'Warrior' & rpg_chars$attack >= 90, ]

# 2단계: 필요한 열만 선택
final_report <- strong_warriors[, c('name', 'attack', 'defense')]

print(final_report)

해설

이 문제는 데이터 분석의 일반적인 흐름인 **"행 필터링 -> 열 선택"**을 보여주는 좋은 예시입니다.

  1. 행 필터링: 먼저 [행 조건, ]을 사용하여 원하는 행을 추출합니다. rpg_chars$class == 'Warrior'rpg_chars$attack >= 90 두 조건을 & 연산자로 묶어 'Warrior'이면서 공격력이 90 이상인 행들을 선택합니다. 이 결과가 strong_warriors에 저장됩니다.
  2. 열 선택: 그 다음, 필터링된 데이터프레임 strong_warriors에 대해 [, 열 조건]을 사용하여 원하는 열을 선택합니다. c('name', 'attack', 'defense') 벡터를 열 인덱스에 넣어 해당 열들만 추출합니다.

이처럼 여러 단계에 걸쳐 데이터프레임을 조작함으로써, 원본 데이터에서 내가 원하는 형태의 통찰력을 얻을 수 있는 부분집합을 만들어낼 수 있습니다.


97. 카페 매출 분석: 베이커리류 총 매출 계산하기

상황: 당신은 카페 사장님입니다. 메뉴 정보(menu)와 일일 판매 기록(sales) 데이터를 가지고 있습니다. 'Bakery' 카테고리에 속하는 메뉴들의 오늘 총 매출액을 계산하고 싶습니다.

과제: 다음 단계를 순서대로 수행하여 'Bakery' 카테고리의 총 매출액을 계산하세요.

  1. menusales 데이터프레임을 item_id를 기준으로 병합하여 daily_sales를 만드세요.
  2. daily_sales에서 category가 'Bakery'인 데이터만 필터링하세요.
  3. 필터링된 데이터에서 pricequantity를 곱하여 각 판매 건의 매출액을 계산하고, 이들의 총합을 구하세요.
# 주어진 데이터프레임
menu <- data.frame(
  item_id = c('C01', 'B01', 'T01', 'B02'),
  item_name = c('Americano', 'Croissant', 'Earl Grey', 'Scone'),
  category = c('Coffee', 'Bakery', 'Tea', 'Bakery'),
  price = c(4000, 3500, 4500, 3000)
)

sales <- data.frame(
  order_num = 101:105,
  item_id = c('C01', 'B01', 'B01', 'T01', 'B02'),
  quantity = c(2, 1, 3, 1, 2)
)

정답 코드

# 주어진 데이터프레임
menu <- data.frame(
  item_id = c('C01', 'B01', 'T01', 'B02'),
  item_name = c('Americano', 'Croissant', 'Earl Grey', 'Scone'),
  category = c('Coffee', 'Bakery', 'Tea', 'Bakery'),
  price = c(4000, 3500, 4500, 3000)
)

sales <- data.frame(
  order_num = 101:105,
  item_id = c('C01', 'B01', 'B01', 'T01', 'B02'),
  quantity = c(2, 1, 3, 1, 2)
)

# 1단계: 데이터 병합
daily_sales <- merge(menu, sales, by = 'item_id')

# 2단계: 'Bakery' 카테고리 필터링
bakery_sales <- daily_sales[daily_sales$category == 'Bakery', ]

# 3단계: 총 매출 계산
# 각 행의 매출액 계산
revenue_per_sale <- bakery_sales$price * bakery_sales$quantity
# 총합 계산
total_bakery_revenue <- sum(revenue_per_sale)

print(total_bakery_revenue)

해설

이 문제는 실제 데이터 분석 프로젝트의 축소판과 같습니다.

  1. 데이터 통합 (merge): 분리된 정보를 하나로 합치는 것은 분석의 첫걸음입니다. sales에는 가격 정보가 없고 menu에는 판매 수량 정보가 없으므로, merge를 통해 두 정보를 합쳐야만 매출을 계산할 수 있습니다.
  2. 관심 데이터 필터링: 전체 데이터에서 우리가 관심 있는 'Bakery' 데이터만 논리적 인덱싱으로 추출합니다.
  3. 특성 생성 및 집계 (sum): 기존 변수(price, quantity)를 이용해 새로운 변수인 매출액($매출액 = 가격 \times 수량$)을 계산합니다. R에서는 벡터 간의 곱셈이 원소별로 수행되므로 bakery_sales$price * bakery_sales$quantity는 각 판매 건의 매출액 벡터를 생성합니다. 마지막으로 sum() 함수를 사용해 이 벡터의 모든 값을 더하여 총 매출을 구합니다.

98. 동아리에 가입하지 않은 학생 찾기

상황: 마법학교의 학생 상담 선생님인 당신은, 아직 동아리에 가입하지 않은 학생들을 파악하여 상담을 통해 학교 생활 적응을 돕고자 합니다.

과제: 92번 문제에서 studentsclubs 데이터프레임을 Left Join하여 만들었던 student_club_info를 다시 생성하고, 이 데이터프레임에서 동아리 정보가 없는(즉, club_nameNA인) 학생들의 이름만 추출하여 출력하세요. is.na() 함수를 사용하세요.

# 주어진 데이터프레임
students <- data.frame(
  student_id = c(1, 2, 3, 4),
  name = c('Harry', 'Hermione', 'Ron', 'Neville')
)

clubs <- data.frame(
  club_id = c(10, 20),
  club_name = c('Dueling Club', 'Herbology Club'),
  student_id = c(1, 4)
)

정답 코드

# 주어진 데이터프레임
students <- data.frame(
  student_id = c(1, 2, 3, 4),
  name = c('Harry', 'Hermione', 'Ron', 'Neville')
)

clubs <- data.frame(
  club_id = c(10, 20),
  club_name = c('Dueling Club', 'Herbology Club'),
  student_id = c(1, 4)
)

# Left Join 수행
student_club_info <- merge(students, clubs, by = 'student_id', all.x = TRUE)

# club_name이 NA인 행 필터링
no_club_students_data <- student_club_info[is.na(student_club_info$club_name), ]

# 해당 학생들의 이름만 추출
no_club_student_names <- no_club_students_data$name

print(no_club_student_names)

해설

NA(결측값)를 다루는 것은 데이터 분석에서 매우 중요한 기술입니다.

  • is.na() 함수는 벡터의 각 원소가 NA인지 아닌지를 검사하여 TRUE/FALSE의 논리형 벡터를 반환합니다. student_club_info$club_nameNA를 포함하고 있으므로, is.na()를 통해 NA인 위치를 찾아낼 수 있습니다.
  • student_club_info$club_name == NA 와 같이 비교하면 원하는 결과를 얻을 수 없습니다. NA는 '값이 없음'을 의미하는 특별한 값이므로, == 연산자로 비교할 수 없고 반드시 is.na() 함수를 사용해야 합니다.
  • is.na(student_club_info$club_name)가 반환한 논리형 벡터를 행 인덱스로 사용하여 NA를 가진 행 전체를 필터링합니다.
  • 마지막으로 필터링된 데이터프레임에서 $를 이용해 name 열만 추출하여 최종 결과를 얻습니다.

99. 일일 판매 기록에 매출액 열 추가하기

상황: 카페 사장님은 일일 판매 기록(daily_sales) 데이터프레임을 볼 때마다 가격과 수량을 암산하여 매출을 계산하는 것이 번거롭습니다. 각 판매 건의 매출액을 보여주는 revenue 열을 아예 데이터프레임에 추가해달라고 요청했습니다.

과제: 97번 문제에서 생성했던 daily_sales 데이터프레임에, pricequantity를 곱한 값을 담는 revenue라는 새로운 열을 추가하세요. 그 후, revenue가 10000원 이상인 판매 기록만 출력하세요.

# 97번 문제의 데이터와 병합 결과
menu <- data.frame(
  item_id = c('C01', 'B01', 'T01', 'B02'),
  price = c(4000, 3500, 4500, 3000)
)
sales <- data.frame(
  item_id = c('C01', 'B01', 'B01', 'T01', 'B02'),
  quantity = c(2, 1, 3, 1, 2)
)
daily_sales <- merge(menu, sales, by = 'item_id')

정답 코드

# 97번 문제의 데이터와 병합 결과
menu <- data.frame(
  item_id = c('C01', 'B01', 'T01', 'B02'),
  price = c(4000, 3500, 4500, 3000)
)
sales <- data.frame(
  item_id = c('C01', 'B01', 'B01', 'T01', 'B02'),
  quantity = c(2, 1, 3, 1, 2)
)
daily_sales <- merge(menu, sales, by = 'item_id')

# 'revenue' 열 추가
daily_sales$revenue <- daily_sales$price * daily_sales$quantity

# revenue가 10000 이상인 기록 필터링
high_revenue_sales <- daily_sales[daily_sales$revenue >= 10000, ]

print(high_revenue_sales)

해설

데이터프레임에 새로운 열을 추가하는 것은 매우 간단합니다.

  • 데이터프레임이름$새로운열이름 <- 값 형식을 사용합니다.
  • daily_sales$revenue <- daily_sales$price * daily_sales$quantity 코드는 price 열과 quantity 열의 각 원소를 곱한 결과를 revenue라는 새로운 열에 할당합니다. R이 벡터화 연산을 지원하기 때문에 반복문 없이도 모든 행에 대해 한 번에 계산이 적용됩니다.
  • 이렇게 새로 생성된 열은 기존 열과 마찬가지로 필터링 조건에 바로 사용할 수 있습니다. daily_sales[daily_sales$revenue >= 10000, ] 코드는 방금 만든 revenue 열을 기준으로 10000원 이상의 매출이 발생한 판매 건을 필터링합니다.
  • 이처럼 기존 데이터를 가공하여 새로운 정보를 담은 열(파생 변수)을 만드는 것은 데이터 분석의 핵심적인 과정입니다.

100. 종합 실습: 우주선 승무원과 임무 배정 보고서 작성

상황: 당신은 은하 연방 함대의 인사 기록 담당관입니다. 승무원 정보(crew)와 임무 정보(missions) 데이터베이스를 관리하고 있습니다. "Project Genesis" 임무에 배정된 승무원 중, 비행 기술(skill_level)이 8 이상인 베테랑 조종사들의 이름과 직책, 그리고 그들이 맡은 임무의 목적지를 포함하는 최종 보고서를 작성해야 합니다.

과제: 다음 단계를 모두 수행하여 최종 보고서(데이터프레임)를 만드세요.

  1. crew 데이터프레임과 missions 데이터프레임을 병합하세요. (승무원 ID 열 이름이 crew_idassigned_crew_id로 다름에 유의)
  2. 병합된 데이터에서 mission_name이 "Project Genesis"인 기록만 필터링하세요.
  3. 그 결과에서 skill_level이 8 이상인 승무원만 다시 필터링하세요.
  4. 최종적으로 필터링된 데이터에서 name, position, destination 열만 선택하여 출력하세요.
# 주어진 데이터프레임
crew <- data.frame(
  crew_id = c('C-001', 'C-002', 'C-003', 'C-004', 'C-005'),
  name = c('Kirk', 'Spock', 'McCoy', 'Sulu', 'Uhura'),
  position = c('Captain', 'Science Officer', 'Doctor', 'Pilot', 'Communications'),
  skill_level = c(9, 10, 7, 8, 8)
)

missions <- data.frame(
  mission_id = c('M-01', 'M-02', 'M-03'),
  mission_name = c('Explore Xylos', 'Project Genesis', 'Project Genesis'),
  destination = c('Planet Xylos', 'Mutara Nebula', 'Mutara Nebula'),
  assigned_crew_id = c('C-001', 'C-002', 'C-004')
)

정답 코드

# 주어진 데이터프레임
crew <- data.frame(
  crew_id = c('C-001', 'C-002', 'C-003', 'C-004', 'C-005'),
  name = c('Kirk', 'Spock', 'McCoy', 'Sulu', 'Uhura'),
  position = c('Captain', 'Science Officer', 'Doctor', 'Pilot', 'Communications'),
  skill_level = c(9, 10, 7, 8, 8)
)

missions <- data.frame(
  mission_id = c('M-01', 'M-02', 'M-03'),
  mission_name = c('Explore Xylos', 'Project Genesis', 'Project Genesis'),
  destination = c('Planet Xylos', 'Mutara Nebula', 'Mutara Nebula'),
  assigned_crew_id = c('C-001', 'C-002', 'C-004')
)

# 1단계: 데이터 병합 (서로 다른 키 이름 사용)
assignment_details <- merge(crew, missions, by.x = 'crew_id', by.y = 'assigned_crew_id')

# 2단계: 특정 임무 필터링
genesis_crew <- assignment_details[assignment_details$mission_name == 'Project Genesis', ]

# 3단계: 특정 스킬 레벨 필터링
veteran_genesis_crew <- genesis_crew[genesis_crew$skill_level >= 8, ]

# 4단계: 최종 보고서용 열 선택
final_report <- veteran_genesis_crew[, c('name', 'position', 'destination')]

print(final_report)

해설

이 문제는 지금까지 배운 데이터프레임 생성, 병합, 하위 집합 추출의 모든 기술을 종합적으로 활용합니다.

  1. 병합 (merge with by.x, by.y): crew_idassigned_crew_id라는 서로 다른 이름의 키를 기준으로 두 데이터를 성공적으로 합칩니다.
  2. 다단계 필터링: 첫 번째 필터링(mission_name == 'Project Genesis')으로 분석 대상을 좁히고, 그 결과에 대해 두 번째 필터링(skill_level >= 8)을 적용하여 조건을 더욱 구체화합니다. 이렇게 연쇄적으로 필터링하는 것은 복잡한 조건을 가진 데이터를 추출하는 표준적인 방법입니다.
  3. 최종 선택: 모든 조건에 맞는 행이 추출된 후, 보고서에 필요한 name, position, destination 열만 선택하여 최종 결과를 만듭니다.

이러한 일련의 과정을 통해, 복잡하게 얽힌 원본 데이터로부터 명확한 목적을 가진 유의미한 정보를 추출해낼 수 있으며, 이것이 바로 데이터 분석의 본질입니다.

네, 알겠습니다. 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 사용자의 학습 여정에 큰 도움이 될 흥미롭고 실용적인 문제들을 생성해 드리겠습니다.

주어진 주제(제어문, 반복문, apply 계열 함수)와 난이도(초급 6단계), 문제 번호(101~120)에 맞춰 상세한 해설과 함께 20개의 문제를 제공합니다.


101. 게임 캐릭터 레벨에 따른 장비 착용 가능 여부 판별

상황 설명: 당신은 새로운 RPG 게임 "The Chronicles of R"의 개발자입니다. 게임 속 전설의 무기 '드래곤 슬레이어'는 레벨 20 이상인 캐릭터만 착용할 수 있습니다. 플레이어의 현재 레벨에 따라 이 무기를 착용할 수 있는지 알려주는 시스템을 만들어야 합니다.

과제:

  1. character_level이라는 변수를 만들고 임의의 레벨(예: 22)을 할당하세요.
  2. if-else 문을 사용하여 character_level이 20 이상이면 "드래곤 슬레이어 착용 가능! 전설의 시작입니다."를 출력하세요.
  3. 만약 20 미만이라면 "레벨이 부족합니다. 필요한 레벨: 20, 현재 레벨: [캐릭터의 현재 레벨]" 형식으로 메시지를 출력하세요.

정답 코드

character_level <- 22

if (character_level >= 20) {
  print("드래곤 슬레이어 착용 가능! 전설의 시작입니다.")
} else {
  # paste0 함수를 사용해 문자열과 변수를 깔끔하게 합칩니다.
  print(paste0("레벨이 부족합니다. 필요한 레벨: 20, 현재 레벨: ", character_level))
}

# 레벨이 부족한 경우 테스트
character_level <- 15
if (character_level >= 20) {
  print("드래곤 슬레이어 착용 가능! 전설의 시작입니다.")
} else {
  print(paste0("레벨이 부족합니다. 필요한 레벨: 20, 현재 레벨: ", character_level))
}

해설

이 문제는 R에서 가장 기본적인 조건문인 if-else 구문의 사용법을 다룹니다.

  • if (condition) { ... }: condition 부분이 논리적으로 TRUE일 경우 중괄호 {} 안의 코드가 실행됩니다. 여기서 조건은 character_level >= 20으로, 캐릭터 레벨이 20 이상인지 확인합니다.
  • else { ... }: ifconditionFALSE일 경우 else 뒤의 중괄호 {} 안의 코드가 실행됩니다.
  • >=: '크거나 같다'를 의미하는 비교 연산자입니다.
  • paste0(): 여러 문자열이나 변수를 공백 없이 하나로 합쳐주는 유용한 함수입니다. 동적인 메시지를 생성할 때 매우 편리합니다. paste()는 기본적으로 공백을 넣어 합칩니다.

102. 카페 메뉴 주문 시스템: 할인 적용 여부 결정

상황 설명: 당신은 동네 인기 카페 'Rpresso'의 POS(판매 시점 정보 관리) 시스템을 개발하고 있습니다. 고객이 총 10,000원 이상 주문하면 10% 할인을 적용하고, 5,000원 이상 10,000원 미만이면 5% 할인을, 5,000원 미만이면 할인을 적용하지 않는 규칙이 있습니다.

과제:

  1. order_amount 변수에 주문 금액(예: 12000)을 할당하세요.
  2. if, else if, else를 사용하여 다음 조건에 따라 최종 결제 금액을 계산하고 출력하세요.
    • 10,000원 이상: "10% 할인 적용! 최종 결제 금액: [계산된 금액]원"
    • 5,000원 이상: "5% 할인 적용! 최종 결제 금액: [계산된 금액]원"
    • 5,000원 미만: "할인 미적용. 결제 금액: [원래 금액]원"

정답 코드

order_amount <- 12000
final_price <- 0

if (order_amount >= 10000) {
  final_price <- order_amount * 0.9 # 10% 할인
  print(paste0("10% 할인 적용! 최종 결제 금액: ", final_price, ""))
} else if (order_amount >= 5000) {
  final_price <- order_amount * 0.95 # 5% 할인
  print(paste0("5% 할인 적용! 최종 결제 금액: ", final_price, ""))
} else {
  final_price <- order_amount # 할인 없음
  print(paste0("할인 미적용. 결제 금액: ", final_price, ""))
}

해설

이 문제는 여러 조건을 순차적으로 확인할 때 사용하는 if-else if-else 구조를 학습합니다.

  • if (condition1): 첫 번째 조건을 확인합니다. TRUE이면 해당 블록을 실행하고 전체 if-else if-else 구조를 빠져나옵니다.
  • else if (condition2): 첫 번째 조건(condition1)이 FALSE일 경우에만 두 번째 조건(condition2)을 확인합니다.
  • else: 위의 모든 ifelse if 조건이 FALSE일 경우 실행됩니다.
  • 조건의 순서가 매우 중요합니다. 만약 order_amount >= 5000 조건을 먼저 확인했다면, 12000원 같은 금액도 이 조건에 TRUE가 되어 5% 할인만 적용되고 끝나버립니다. 따라서 가장 범위가 좁거나 까다로운 조건부터 검사하는 것이 일반적입니다.

103. 기상 관측소의 온도 데이터 분류

상황 설명: 당신은 국립기상과학원의 데이터 분석가입니다. 센서로부터 수집된 온도 데이터(temperature)를 받아서 "폭염", "더위", "보통", "쌀쌀", "추위" 5단계로 분류하는 자동화 스크립트를 작성해야 합니다.

과제:

  1. temperature 변수에 섭씨 온도를 할당하세요.
  2. if-else if-else 체인을 사용하여 아래 기준에 따라 온도를 분류하고 결과를 출력하세요.
    • 33도 이상: "폭염"
    • 28도 이상 33도 미만: "더위"
    • 15도 이상 28도 미만: "보통"
    • 5도 이상 15도 미만: "쌀쌀"
    • 5도 미만: "추위"
  3. 논리 연산자 &&를 적절히 활용하세요.

정답 코드

temperature <- 29.5
category <- ""

if (temperature >= 33) {
  category <- "폭염"
} else if (temperature >= 28 && temperature < 33) {
  category <- "더위"
} else if (temperature >= 15 && temperature < 28) {
  category <- "보통"
} else if (temperature >= 5 && temperature < 15) {
  category <- "쌀쌀"
} else {
  category <- "추위"
}

print(paste("현재 온도", temperature, "도는 '", category, "' 상태입니다.", sep=""))

해설

이 문제는 여러 조건을 조합해야 할 때 사용하는 논리 연산자의 중요성을 보여줍니다.

  • && (AND 연산자): A && B는 A와 B 조건이 모두 TRUE일 때만 전체가 TRUE가 됩니다. 예를 들어 temperature >= 28 && temperature < 33는 온도가 28도 이상'이면서 동시에' 33도 미만인지를 확인합니다.
  • 사실 else if 구조에서는 이전 조건이 FALSE임이 보장되므로 코드를 더 간결하게 작성할 수도 있습니다. 예를 들어, else if (temperature >= 28) 라고만 써도 이 코드가 실행되는 시점에는 temperature >= 33이 이미 FALSE임이 확실하므로 temperature < 33 조건은 묵시적으로 만족됩니다. 하지만 명시적으로 작성하는 것이 코드의 가독성을 높여줄 때도 있습니다.

개선된 코드:

temperature <- 29.5
if (temperature >= 33) {
  category <- "폭염"
} else if (temperature >= 28) { # 이미 33 미만임이 보장됨
  category <- "더위"
} else if (temperature >= 15) { # 이미 28 미만임이 보장됨
  category <- "보통"
} else if (temperature >= 5) {  # 이미 15 미만임이 보장됨
  category <- "쌀쌀"
} else {
  category <- "추위"
}
print(paste("현재 온도", temperature, "도는 '", category, "' 상태입니다.", sep=""))

104. 학생 성적 벡터를 이용한 합격/불합격 일괄 처리

상황 설명: 한 학급의 R 프로그래밍 기말고사 성적이 벡터로 주어졌습니다. 60점 이상이면 'Pass', 미만이면 'Fail'로 처리하여 결과를 새로운 벡터에 저장해야 합니다. for 문을 사용하지 않고 이 작업을 한 번에 처리하고 싶습니다.

과제:

  1. 학생들의 성적을 담은 숫자 벡터 scores를 만드세요. (예: c(85, 55, 92, 48, 70, 60))
  2. ifelse() 함수를 사용하여 scores 벡터의 각 점수가 60점 이상이면 "Pass", 아니면 "Fail"을 갖는 새로운 벡터 results를 생성하세요.
  3. scoresresults를 함께 출력하여 결과를 확인하세요.

정답 코드

scores <- c(85, 55, 92, 48, 70, 60)

# ifelse(조건, TRUE일 때 값, FALSE일 때 값)
results <- ifelse(scores >= 60, "Pass", "Fail")

# 결과 확인
print("학생 점수:")
print(scores)
print("합격 여부:")
print(results)

# 데이터 프레임으로 묶어서 보면 더 깔끔합니다.
final_results <- data.frame(Score = scores, Result = results)
print(final_results)

해설

이 문제는 if-else와 비슷하지만 벡터화(vectorized) 연산에 특화된 ifelse() 함수의 강력함을 보여줍니다.

  • ifelse(test, yes, no): 이 함수는 세 개의 인자를 받습니다.
    1. test: 논리값을 반환하는 조건 벡터 (예: scores >= 60c(TRUE, FALSE, TRUE, FALSE, TRUE, TRUE)를 반환)
    2. yes: test 벡터의 해당 위치가 TRUE일 때 사용할 값
    3. no: test 벡터의 해당 위치가 FALSE일 때 사용할 값
  • ifelse()는 내부적으로 반복문을 실행하지만, R의 C언어 기반으로 구현되어 있어 사용자가 직접 for 루프를 작성하는 것보다 훨씬 빠르고 간결합니다. 데이터 전체에 동일한 조건부 로직을 적용할 때 매우 유용합니다.

105. 1부터 10까지의 숫자 출력하기

상황 설명: 프로그래밍의 가장 기본적인 연습 중 하나는 반복문을 사용하여 특정 범위의 숫자를 순서대로 출력하는 것입니다. R의 for 루프를 사용하여 이 고전적인 문제를 해결해 봅시다.

과제:

  1. for 반복문을 사용하여 변수 i가 1부터 10까지 변하도록 하세요.
  2. 루프의 각 반복에서 "현재 숫자는 [i] 입니다." 라는 문장을 출력하세요.

정답 코드

# 1부터 10까지의 시퀀스를 순회하는 for 루프
for (i in 1:10) {
  print(paste("현재 숫자는", i, "입니다."))
}

해설

이 문제는 R에서 for 반복문의 가장 기본적인 구조를 소개합니다.

  • for (variable in sequence) { ... }: for 루프의 기본 구문입니다.
    • variable (여기서는 i): 루프의 각 반복에서 sequence의 한 요소를 받아 저장하는 임시 변수입니다.
    • sequence (여기서는 1:10): 반복할 대상이 되는 값들의 집합입니다. 1:10은 1, 2, 3, ..., 10까지의 정수 벡터를 생성합니다.
  • 루프는 sequence의 첫 번째 요소(1)를 i에 할당하고 중괄호 안의 코드를 실행한 뒤, 두 번째 요소(2)를 i에 할당하고 코드를 실행하는 식으로 sequence의 마지막 요소까지 반복합니다.

106. 과일 바구니 내용물 하나씩 꺼내보기

상황 설명: 과일 가게에서 과일 바구니를 샀습니다. 바구니 안에는 여러 종류의 과일이 문자형 벡터로 담겨 있습니다. 바구니 안의 과일을 하나씩 꺼내며 확인하는 스크립트를 작성해 보세요.

과제:

  1. fruit_basket이라는 문자형 벡터를 만드세요. (예: c("사과", "바나나", "딸기", "오렌지"))
  2. for 루프를 사용하여 fruit_basket의 각 과일을 순서대로 출력하세요. 출력 형식은 "바구니에서 [과일 이름]을(를) 꺼냈습니다." 입니다.

정답 코드

fruit_basket <- c("사과", "바나나", "딸기", "오렌지", "포도")

for (fruit in fruit_basket) {
  print(paste0("바구니에서 ", fruit, "을(를) 꺼냈습니다."))
}

해설

이 문제는 숫자 시퀀스뿐만 아니라 문자형 벡터와 같은 다른 유형의 벡터를 순회하는 방법을 보여줍니다. for 루프의 원리는 동일합니다.

  • for (fruit in fruit_basket): fruit_basket 벡터의 첫 번째 요소인 "사과"가 fruit 변수에 할당되고 루프 본문이 실행됩니다. 다음 반복에서는 "바나나"가 fruit에 할당되는 식으로 벡터의 모든 요소를 순회할 때까지 계속됩니다.
  • 이처럼 for 루프는 벡터의 각 요소를 직접 다룰 때 매우 직관적이고 유용합니다.

107. 1부터 100까지의 짝수의 합 구하기

상황 설명: 당신은 수학 문제를 R 코드로 푸는 연습을 하고 있습니다. 1부터 100까지의 모든 짝수의 합을 구하는 프로그램을 작성해야 합니다.

과제:

  1. 합계를 저장할 변수 total_sum을 0으로 초기화하세요.
  2. for 루프를 사용하여 1부터 100까지 숫자를 확인하세요.
  3. 루프 안에서 if 문을 사용하여 현재 숫자가 짝수인지 판별하세요. (힌트: 나머지 연산자 %% 사용)
  4. 짝수일 경우에만 total_sum에 그 숫자를 더하세요.
  5. 루프가 끝난 후, 최종 total_sum 값을 출력하세요.

정답 코드

total_sum <- 0

for (i in 1:100) {
  # i를 2로 나눈 나머지가 0이면 짝수
  if (i %% 2 == 0) {
    total_sum <- total_sum + i
  }
}

print(paste("1부터 100까지 짝수의 합은:", total_sum))

# R 다운 더 효율적인 방법 (참고용)
# 1. 짝수 시퀀스 생성 후 sum() 함수 사용
# sum(seq(2, 100, by = 2))
# 2. 벡터화 연산 사용
# sum((1:100)[(1:100) %% 2 == 0])

해설

이 문제는 반복문과 조건문을 결합하는 가장 흔하고 중요한 패턴을 연습합니다.

  • total_sum <- 0: 누적 합계를 계산하기 전에는 반드시 결과를 저장할 변수를 0으로 초기화해야 합니다. 그렇지 않으면 예상치 못한 값으로 계산이 시작될 수 있습니다.
  • i %% 2 == 0: 나머지(modulo) 연산자 %%는 나눗셈의 나머지를 반환합니다. 어떤 숫자를 2로 나누었을 때 나머지가 0이면 그 숫자는 짝수입니다. 이 조건은 for 루프 내에서 필터 역할을 합니다.
  • total_sum <- total_sum + i: 이 코드는 "기존 total_sum 값에 현재 i 값을 더해서 새로운 total_sum 값으로 갱신하라"는 의미입니다. 누적 계산의 핵심적인 표현입니다.
  • 해설에 포함된 'R 다운 방법'은 R이 벡터화 연산에 강하다는 것을 보여줍니다. 루프를 사용하는 것보다 훨씬 빠르고 간결하게 동일한 결과를 얻을 수 있지만, 알고리즘의 기본 논리를 이해하기 위해 루프를 먼저 배우는 것이 중요합니다.

108. 우주선 발사 카운트다운

상황 설명: 당신은 스페이스R(SpaceR)사의 로켓 발사 통제 시스템을 개발 중입니다. 발사 10초 전부터 카운트다운을 하고, 0이 되면 "발사!"를 외치는 프로그램을 while 루프로 만들어야 합니다.

과제:

  1. 카운트다운 시작 숫자를 저장할 countdown 변수를 10으로 설정하세요.
  2. while 루프를 사용하여 countdown이 0보다 클 동안 반복하도록 하세요.
  3. 루프 안에서는 현재 countdown 숫자를 출력하고, countdown 값을 1씩 감소시키세요.
  4. 루프가 끝난 후 "발사!"를 출력하세요.

정답 코드

countdown <- 10

while (countdown > 0) {
  print(paste(countdown, "..."))
  Sys.sleep(1) # 1초간 실행을 멈춤 (실감 나는 효과!)
  countdown <- countdown - 1
}

print("발사!")

해설

이 문제는 for 루프와는 다른 종류의 반복문인 while 루프를 소개합니다.

  • while (condition) { ... }: while 루프는 conditionTRUE인 동안 중괄호 안의 코드를 계속해서 반복 실행합니다.
  • for 루프는 정해진 횟수(시퀀스의 길이)만큼 반복하는 반면, while 루프는 특정 조건이 만족되는 동안 계속 반복합니다.
  • 무한 루프 주의: while 루프를 사용할 때 가장 중요한 것은 루프 내에서 언젠가는 조건이 FALSE가 되도록 상태를 변경하는 코드가 반드시 있어야 한다는 점입니다. 이 문제에서는 countdown <- countdown - 1이 그 역할을 합니다. 이 코드가 없다면 countdown은 영원히 10으로 남아 countdown > 0 조건이 항상 TRUE가 되므로 프로그램이 멈추지 않는 무한 루프에 빠지게 됩니다.
  • Sys.sleep(1): 프로그램의 실행을 지정된 시간(초)만큼 일시 정지시키는 함수로, 카운트다운 효과를 실감 나게 만들기 위해 사용했습니다.

109. 예금 목표 금액 달성 시뮬레이터

상황 설명: 당신은 1,000만원을 모으는 것을 목표로 저축을 시작했습니다. 현재 500만원이 있고, 매년 5%의 복리 이자를 받으며, 추가로 매년 50만원씩 저축합니다. 목표 금액을 달성하는 데 몇 년이 걸리는지 계산해 보세요.

과제:

  1. balance (현재 잔액), target (목표 금액), interest_rate (이율), annual_saving (연간 추가 저축액), years (경과 년수) 변수를 초기화하세요.
  2. while 루프를 사용하여 balancetarget보다 작은 동안 반복하세요.
  3. 루프 안에서 다음을 수행하세요.
    • 1년치 이자를 계산하여 balance에 더하세요. (balance = balance * (1 + interest_rate))
    • 연간 추가 저축액을 balance에 더하세요.
    • years를 1 증가시키세요.
  4. 루프가 끝나면 "목표 금액 [target]원 달성! 총 [years]년 걸렸습니다." 메시지를 출력하세요.

정답 코드

balance <- 5000000
target <- 10000000
interest_rate <- 0.05 # 5%
annual_saving <- 500000
years <- 0

while (balance < target) {
  # 이자 계산
  balance <- balance * (1 + interest_rate)
  # 추가 저축
  balance <- balance + annual_saving
  # 연수 증가
  years <- years + 1
  
  # 매년 경과 출력 (선택 사항)
  print(paste(years, "년 후 잔액:", round(balance), ""))
}

print(paste0("목표 금액 ", format(target, big.mark=","), "원 달성! 총 ", years, "년 걸렸습니다."))

해설

이 문제는 while 루프가 '언제 끝날지 정확히 모르는' 반복 작업에 얼마나 유용한지 보여주는 훌륭한 예시입니다.

  • 우리는 목표 금액을 달성하는 데 정확히 몇 년이 걸릴지 미리 알 수 없습니다. 따라서 "잔액이 목표보다 작은 동안"이라는 조건을 설정하고, 조건이 만족될 때까지 시뮬레이션을 반복하는 while 루프가 매우 적합합니다.
  • 루프의 각 반복은 '1년'의 시간을 시뮬레이션합니다. 이자 계산, 추가 저축, 시간 경과라는 세 가지 상태 변화가 루프 내에서 순차적으로 일어납니다.
  • round(): 숫자를 반올림하는 함수입니다. 소수점 이하가 지저분하게 나오는 것을 방지합니다.
  • format(target, big.mark=","): 숫자에 천 단위 구분 기호(,)를 추가하여 가독성을 높여주는 함수입니다.

110. 구구단 7단 출력하기

상황 설명: 어린 조카에게 R 프로그래밍으로 구구단을 만들어 보여주기로 했습니다. for 반복문을 사용하여 구구단 7단을 보기 좋게 출력해 보세요.

과제:

  1. for 루프를 사용하여 1부터 9까지 반복하는 변수 i를 만드세요.
  2. 루프 안에서 "7 x [i] = [7 * i]" 형식의 문자열을 만들어 출력하세요.

정답 코드

# 구구단 7단
dan <- 7

for (i in 1:9) {
  result <- dan * i
  print(paste(dan, "x", i, "=", result))
}

해설

이 문제는 for 루프의 기본적인 활용을 복습하고, 루프 변수를 사용하여 계산하고 그 결과를 출력하는 간단한 예제입니다.

  • dan <- 7: 구구단의 단수를 변수로 지정하면 나중에 3단, 5단 등으로 쉽게 코드를 변경할 수 있어 유지보수에 좋습니다.
  • result <- dan * i: 루프의 각 단계에서 계산 결과를 result 변수에 명시적으로 저장하면 코드를 읽기 쉬워집니다.
  • paste(): 여러 요소(숫자, 문자열)를 합쳐서 하나의 출력 문자열로 만드는 데 사용되었습니다. paste는 기본적으로 각 요소를 공백으로 구분합니다.

111. 중첩 루프를 이용한 전체 구구단 출력

상황 설명: 구구단 7단 만들기에 성공했습니다! 이제 한 걸음 더 나아가, for 루프를 두 개 중첩하여 2단부터 9단까지의 전체 구구단을 출력하는 프로그램을 만들어 보세요.

과제:

  1. 바깥쪽 for 루프는 2부터 9까지 변하는 dan (단수)을 제어합니다.
  2. 안쪽 for 루프는 1부터 9까지 변하는 i (곱하는 수)를 제어합니다.
  3. 안쪽 루프에서 "[dan] x [i] = [dan * i]" 형식으로 구구단을 출력하세요.
  4. 각 단이 끝날 때마다 구분을 위해 빈 줄이나 구분선을 출력하세요.

정답 코드

# 2단부터 9단까지 반복
for (dan in 2:9) {
  print(paste("---", dan, "단 ---"))
  
  # 각 단에 대해 1부터 9까지 곱함
  for (i in 1:9) {
    result <- dan * i
    print(paste(dan, "x", i, "=", result))
  }
  
  # 한 단이 끝나면 빈 줄을 출력하여 가독성 향상
  print("") 
}

해설

이 문제는 중첩 루프(Nested Loop)의 개념을 다룹니다. 하나의 루프 안에 다른 루프가 포함된 구조입니다.

  • 동작 원리: 바깥쪽 루프가 한 번 실행될 때마다, 안쪽 루프는 처음부터 끝까지 모두 실행됩니다.
    1. dan이 2가 됩니다.
    2. 안쪽 루프가 i를 1부터 9까지 변화시키며 2단을 모두 출력합니다.
    3. 안쪽 루프가 끝나면 바깥쪽 루프의 다음 반복으로 넘어갑니다.
    4. dan이 3이 됩니다.
    5. 안쪽 루프가 다시 i를 1부터 9까지 변화시키며 3단을 모두 출력합니다.
    6. 이 과정이 dan이 9가 될 때까지 반복됩니다.
  • 중첩 루프는 행렬(matrix) 처리, 조합 탐색 등 2차원 이상의 데이터를 다룰 때 매우 유용합니다.

112. 성적 데이터에서 특정 점수 이상인 학생 수 세기

상황 설명: 교사인 당신은 학생들의 시험 성적 벡터를 가지고 있습니다. 이 중에서 80점 이상을 받은 학생이 총 몇 명인지 세어야 합니다.

과제:

  1. scores 벡터에 학생들의 성적을 할당하세요. (예: c(78, 95, 88, 62, 81, 85, 74))
  2. 80점 이상인 학생 수를 저장할 high_achievers_count 변수를 0으로 초기화하세요.
  3. for 루프를 사용하여 scores 벡터의 각 점수를 확인하세요.
  4. if 문을 사용하여 점수가 80점 이상이면 high_achievers_count를 1 증가시키세요.
  5. 루프가 끝난 후, 결과를 "80점 이상인 학생은 총 [결과]명 입니다." 형식으로 출력하세요.

정답 코드

scores <- c(78, 95, 88, 62, 81, 85, 74)
high_achievers_count <- 0

for (score in scores) {
  if (score >= 80) {
    high_achievers_count <- high_achievers_count + 1
  }
}

print(paste("80점 이상인 학생은 총", high_achievers_count, "명 입니다."))

# R 다운 더 효율적인 방법 (참고용)
# sum(scores >= 80)
# TRUE는 1, FALSE는 0으로 취급되는 R의 특성을 이용한 방법입니다.

해설

이 문제는 루프를 돌면서 특정 조건을 만족하는 요소의 개수를 세는(counting) 전형적인 예제입니다.

  • high_achievers_count <- high_achievers_count + 1: 이 코드는 카운터 변수를 1씩 증가시키는 표준적인 방법입니다. 조건을 만족하는 요소를 발견할 때마다 카운트를 하나씩 올립니다.
  • R 다운 방법 해설: scores >= 80c(FALSE, TRUE, TRUE, FALSE, TRUE, TRUE, FALSE)와 같은 논리형 벡터를 반환합니다. R에서는 sum() 함수에 논리형 벡터를 넣으면 TRUE를 1로, FALSE를 0으로 간주하여 합계를 계산합니다. 따라서 TRUE의 개수, 즉 80점 이상인 학생 수를 매우 효율적으로 계산할 수 있습니다.

113. next를 이용해 홀수만 출력하기

상황 설명: for 루프를 사용하여 1부터 10까지의 숫자 중 홀수만 출력하는 프로그램을 작성하려고 합니다. 이번에는 if 문과 next 키워드를 사용하여 짝수일 경우 현재 반복을 건너뛰는 방식으로 구현해 보세요.

과제:

  1. for 루프를 사용하여 1부터 10까지 반복하세요.
  2. 루프 안에서 현재 숫자가 짝수인지 확인하세요.
  3. 만약 짝수라면, next를 사용하여 다음 반복으로 즉시 넘어가세요.
  4. 짝수가 아니라면 (즉, 홀수라면) 해당 숫자를 출력하세요.

정답 코드

for (i in 1:10) {
  # 만약 i가 짝수이면,
  if (i %% 2 == 0) {
    next # 현재 반복의 나머지 부분을 건너뛰고 다음 반복으로 넘어감
  }
  
  # 이 코드는 i가 홀수일 때만 실행됨
  print(i)
}

해설

이 문제는 루프의 흐름을 제어하는 next 키워드를 소개합니다.

  • next: next가 호출되면, 현재 실행 중인 루프 반복의 나머지 부분을 모두 건너뛰고 즉시 다음 반복을 시작합니다.
  • 동작 원리:
    1. i가 1일 때: 1 %% 2 == 0FALSE이므로 if 문을 건너뛰고 print(1)이 실행됩니다.
    2. i가 2일 때: 2 %% 2 == 0TRUE이므로 if 문 안의 next가 실행됩니다. print(2)는 실행되지 않고 바로 다음 반복으로 넘어가 i는 3이 됩니다.
    3. 이 과정이 10까지 반복됩니다.
  • next는 특정 조건을 만족하는 경우를 제외하고 로직을 처리하고 싶을 때 코드를 더 깔끔하게 만들어 줍니다. 복잡한 if-else 구조를 피할 수 있습니다.

114. break를 이용해 특정 문자 찾기

상황 설명: 알파벳이 무작위로 섞여 있는 문자 벡터가 있습니다. 이 벡터를 처음부터 순서대로 확인하다가 알파벳 'R'을 발견하는 즉시 검색을 중단하고 싶습니다.

과제:

  1. letters 벡터에 알파벳을 무작위로 섞어서 할당하세요. (힌트: sample(LETTERS))
  2. for 루프를 사용하여 letters 벡터를 순회하세요.
  3. 루프 안에서 현재 알파벳이 'R'인지 확인하세요.
  4. 만약 'R'을 찾았다면, "드디어 'R'을 찾았습니다!" 메시지를 출력하고 break를 사용하여 루프를 즉시 탈출하세요.
  5. 'R'이 아니라면, 현재 확인 중인 알파벳을 출력하세요.

정답 코드

# 재현성을 위해 시드 설정
set.seed(42) 
letters_scrambled <- sample(LETTERS) # LETTERS는 A-Z 벡터

print("검색 시작!")
print(letters_scrambled)

for (char in letters_scrambled) {
  if (char == "R") {
    print("드디어 'R'을 찾았습니다!")
    break # 루프를 완전히 중단하고 탈출
  }
  
  # 'R'을 찾기 전까지는 계속 현재 문자를 출력
  print(paste("현재 문자:", char))
}
print("검색 종료!")

해설

이 문제는 루프를 완전히 중단시키는 break 키워드를 다룹니다.

  • break: break가 호출되면, next와 달리 다음 반복으로 넘어가는 것이 아니라 즉시 해당 루프를 완전히 종료하고 루프 다음의 코드로 실행 흐름이 이동합니다.
  • next vs break:
    • next: "이번 판은 무효! 다음 판으로 가자." (현재 반복만 건너뜀)
    • break: "게임 끝! 모두 집으로 가자." (루프 전체를 종료)
  • break는 원하는 결과를 찾았거나, 더 이상 반복이 무의미한 특정 조건을 만족했을 때 불필요한 연산을 중단하여 프로그램의 효율성을 높이는 데 사용됩니다.

115. 학생별 과목 점수 행렬 데이터 처리하기

상황 설명: 당신은 4명의 학생(A, B, C, D)에 대한 3개 과목(국어, 영어, 수학)의 성적을 담은 행렬(matrix) 데이터를 가지고 있습니다. 각 학생의 평균 점수와 각 과목의 평균 점수를 계산해야 합니다.

과제:

  1. 4x3 크기의 성적 행렬 score_matrix를 만드세요. 행 이름은 학생 이름, 열 이름은 과목 이름으로 지정하세요.
  2. apply() 함수를 사용하여 다음을 계산하세요.
    • 각 학생의 평균 점수 (행 기준 연산)
    • 각 과목의 평균 점수 (열 기준 연산)
  3. 계산된 결과를 각각 출력하세요.

정답 코드

# 1. 성적 행렬 생성
scores <- c(85, 92, 78,
            90, 88, 95,
            72, 80, 75,
            95, 98, 92)
score_matrix <- matrix(scores, nrow = 4, byrow = TRUE)

# 행과 열 이름 지정
rownames(score_matrix) <- c("학생A", "학생B", "학생C", "학생D")
colnames(score_matrix) <- c("국어", "영어", "수학")

print("--- 전체 성적표 ---")
print(score_matrix)

# 2. apply() 함수 사용
# MARGIN = 1 : 행(row) 단위로 함수 적용
student_means <- apply(score_matrix, 1, mean) 
print("--- 학생별 평균 점수 ---")
print(student_means)

# MARGIN = 2 : 열(column) 단위로 함수 적용
subject_means <- apply(score_matrix, 2, mean)
print("--- 과목별 평균 점수 ---")
print(subject_means)

해설

이 문제는 apply() 계열 함수의 첫 주자인 apply()를 소개합니다. apply()는 행렬이나 배열의 행 또는 열 방향으로 특정 함수를 일괄 적용할 때 사용됩니다.

  • apply(X, MARGIN, FUN):
    • X: 함수를 적용할 행렬 또는 배열 (여기서는 score_matrix)
    • MARGIN: 함수를 적용할 방향을 지정하는 숫자.
      • 1: 행(row) 방향. 각 행에 대해 함수를 한 번씩 실행합니다.
      • 2: 열(column) 방향. 각 열에 대해 함수를 한 번씩 실행합니다.
    • FUN: 적용할 함수 (여기서는 mean)
  • 수학적 표현: 학생 A의 평균 점수는 $ \bar{x}{A} = \frac{1}{3}\sum{j=1}^{3}x_{Aj} $ 로 계산되며, apply(score_matrix, 1, mean)은 모든 학생 $i$에 대해 이 계산을 수행합니다. 마찬가지로 국어 과목의 평균 점수는 $ \bar{x}{국어} = \frac{1}{4}\sum{i=1}^{4}x_{i,국어} $ 이며, apply(score_matrix, 2, mean)이 이 계산을 수행합니다.
  • apply()를 사용하면 for 루프를 여러 번 작성해야 하는 코드를 단 한 줄로 깔끔하고 효율적으로 처리할 수 있습니다.

116. 파일 이름 리스트에서 확장자만 추출하기

상황 설명: 어떤 폴더에 있는 파일들의 이름이 리스트(list) 형태로 저장되어 있습니다. 각 파일 이름에서 확장자(예: .txt, .csv, .R)만 추출하여 새로운 벡터로 만들고 싶습니다.

과제:

  1. file_list라는 리스트를 만드세요. (예: list("report.docx", "data_01.csv", "my_script.R", "notes.txt"))
  2. 문자열을 특정 구분자(.)로 나누는 strsplit() 함수와, 리스트의 각 요소에 함수를 적용하는 lapply() 또는 sapply()를 사용하세요.
  3. 각 파일 이름에서 확장자만 추출하여 extensions라는 벡터에 저장하세요.
    • 힌트: strsplit()은 리스트를 반환합니다. strsplit(x, "\\.")[[1]] 와 같이 접근할 수 있으며, 마지막 요소를 선택해야 합니다.

정답 코드

file_list <- list("report.docx", "data_01.csv", "my_script.R", "notes.txt", "archive.zip")

# lapply 사용 예시
# strsplit의 결과가 리스트이므로, 그 리스트의 마지막 요소를 가져오는 함수를 정의
get_extension <- function(filename) {
  # '.'을 기준으로 문자열을 나눔. '\\.'는 정규표현식에서 '.' 자체를 의미
  parts <- strsplit(filename, "\\.")[[1]]
  # 마지막 부분을 반환
  return(parts[length(parts)])
}

extensions_list <- lapply(file_list, get_extension)
print("lapply 결과 (리스트):")
print(extensions_list)

# sapply 사용 예시 (더 간결한 결과를 원할 때)
# sapply는 결과를 가능한 한 벡터나 행렬로 단순화시켜 줌
extensions_vector <- sapply(file_list, get_extension)
print("sapply 결과 (벡터):")
print(extensions_vector)

해설

이 문제는 리스트의 각 요소에 함수를 적용하는 lapply()sapply()를 다룹니다.

  • lapply(X, FUN): 리스트 X의 각 요소(element)에 FUN 함수를 적용하고, 그 결과를 리스트 형태로 반환합니다. 'list apply'의 약자입니다.
  • sapply(X, FUN): lapply와 동일하게 작동하지만, 결과를 가능한 한 벡터나 행렬 형태로 **단순화(simplify)**하려고 시도합니다. 'simplify apply'의 약자입니다. 반환된 결과가 모두 길이가 1인 벡터라면, sapply는 이들을 하나의 벡터로 묶어줍니다. 이 문제처럼 결과가 단순한 문자열 벡터일 경우 sapply가 더 편리합니다.
  • strsplit(filename, "\\."): filename. 기준으로 자릅니다. .은 정규표현식에서 '임의의 한 글자'를 의미하는 특수 문자이므로, . 문자 자체를 의미하도록 \\를 앞에 붙여 이스케이프(escape) 처리해야 합니다.
  • [[1]]: strsplit은 항상 리스트를 반환하므로, 첫 번째 요소에 접근하기 위해 [[1]]을 사용합니다.
  • 이처럼 lapplysapply는 리스트 데이터를 다룰 때 for 루프를 대체하는 매우 강력하고 R 다운(R-ish) 방법입니다.

117. 데이터 프레임의 특정 열에 함수 일괄 적용하기

상황 설명: 당신은 우주 탐사선 'R-deavour'호가 각 행성에서 측정한 중력, 온도(섭씨), 대기압 데이터를 가지고 있습니다. 온도 데이터는 모두 섭씨(Celsius)로 기록되어 있는데, 분석을 위해 화씨(Fahrenheit)로 변환해야 합니다.

과제:

  1. 행성 이름, 중력, 온도(섭씨), 대기압을 포함하는 planets_df 데이터 프레임을 만드세요.
  2. 섭씨를 화씨로 변환하는 공식 $ F = C \times \frac{9}{5} + 32 $ 를 사용하여 celsius_to_fahrenheit 함수를 정의하세요.
  3. lapply() 또는 sapply()를 사용하여 planets_dftemperature_c 열에 celsius_to_fahrenheit 함수를 적용하세요.
  4. 변환된 화씨 온도 결과를 planets_dftemperature_f라는 새로운 열로 추가하세요.

정답 코드

planets_df <- data.frame(
  planet = c("Mercury", "Venus", "Earth", "Mars"),
  gravity = c(3.7, 8.9, 9.8, 3.7),
  temperature_c = c(167, 464, 15, -65),
  pressure_atm = c(0, 92, 1, 0.006)
)

# 섭씨를 화씨로 변환하는 함수 정의
celsius_to_fahrenheit <- function(celsius) {
  fahrenheit <- celsius * (9/5) + 32
  return(fahrenheit)
}

# planets_df의 temperature_c 열에 함수 적용
# 데이터 프레임의 열은 벡터이므로, sapply를 사용하면 결과가 깔끔한 벡터로 나옴
fahrenheit_temps <- sapply(planets_df$temperature_c, celsius_to_fahrenheit)

# 새로운 열로 추가
planets_df$temperature_f <- fahrenheit_temps

print(planets_df)

해설

이 문제는 데이터 프레임의 특정 열(벡터)에 함수를 적용하는 방법을 보여줍니다. R에서는 벡터화 연산이 잘 되어 있어 apply 계열 함수 없이도 가능하지만, 함수 적용 방식을 명확히 보여주기 위해 sapply를 사용했습니다.

  • planets_df$temperature_c: 데이터 프레임 planets_df에서 temperature_c라는 이름의 열을 벡터로 선택하는 방법입니다.
  • sapply(planets_df$temperature_c, ...): temperature_c 벡터의 각 요소(온도 값)에 대해 celsius_to_fahrenheit 함수를 차례로 적용하고, 그 결과를 하나의 벡터로 묶어 반환합니다.
  • planets_df$temperature_f <- ...: 데이터 프레임에 새로운 열을 추가하는 방법입니다. $ 기호를 사용하여 존재하지 않는 열 이름을 지정하고 값을 할당하면 새로운 열이 생성됩니다.

더 간단한 R 다운 방법: R의 함수는 대부분 벡터화되어 있으므로, 함수에 벡터를 직접 전달할 수 있습니다.

# 함수에 벡터를 직접 전달
planets_df$temperature_f_vectorized <- celsius_to_fahrenheit(planets_df$temperature_c)
print(planets_df)

이 방법이 더 간결하고 빠릅니다. 하지만 sapply는 함수가 벡터화를 지원하지 않거나, 각 요소에 대해 더 복잡한 로직을 수행해야 할 때 유용하게 사용될 수 있습니다.


118. 상점별 일일 매출 데이터 요약하기

상황 설명: 당신은 여러 지점을 가진 프랜차이즈 카페의 데이터 분석가입니다. 일자별로 각 지점의 매출액 데이터가 기록되어 있습니다. 각 지점별 평균 매출액을 계산하여 어떤 지점이 가장 실적이 좋은지 파악해야 합니다.

과제:

  1. sales_data 데이터 프레임을 만드세요. store (지점명)과 sales (매출액) 두 개의 열을 포함해야 합니다.
  2. tapply() 함수를 사용하여 store 그룹별로 sales의 평균을 계산하세요.
  3. 결과를 출력하고 해석하세요.

정답 코드

sales_data <- data.frame(
  date = seq(as.Date("2023-05-01"), by = "day", length.out = 15),
  store = rep(c("강남점", "홍대점", "종로점"), 5),
  sales = c(120, 150, 110, 130, 145, 125, 115, 160, 100, 125, 155, 115, 140, 170, 130) * 10000
)

print("--- 원본 매출 데이터 ---")
print(sales_data)

# tapply(데이터, 그룹, 함수)
# sales 데이터를 store 그룹으로 묶어서 mean 함수를 적용
average_sales_by_store <- tapply(sales_data$sales, sales_data$store, mean)

print("--- 지점별 평균 매출액 ---")
print(average_sales_by_store)

해설

이 문제는 그룹별로 데이터를 요약하는 데 특화된 tapply() 함수를 소개합니다. dplyr 패키지의 group_by()summarise() 조합과 유사한 기능을 기본 R 함수로 수행할 수 있습니다.

  • tapply(X, INDEX, FUN):
    • X: 함수를 적용할 데이터 벡터 (여기서는 sales_data$sales)
    • INDEX: 데이터를 그룹화할 기준이 되는 팩터(factor) 또는 리스트 (여기서는 sales_data$store). tapply는 내부적으로 INDEX를 팩터로 변환하여 처리합니다.
    • FUN: 각 그룹에 적용할 함수 (여기서는 mean)
  • 동작 원리: tapplysales_data$store의 값("강남점", "홍대점", "종로점")을 기준으로 sales_data$sales 벡터를 세 그룹으로 나눕니다. 그리고 각 그룹에 대해 mean 함수를 적용하여 평균값을 계산한 후, 그룹 이름을 이름으로 갖는 벡터를 반환합니다.
  • tapply는 SQL의 GROUP BY 절과 매우 유사한 개념으로, 데이터 분석에서 매우 빈번하게 사용되는 강력한 도구입니다.

119. 게임 캐릭터 직업별 평균 능력치 계산

상황 설명: RPG 게임 "The Chronicles of R"의 캐릭터 데이터가 있습니다. 각 캐릭터는 직업(class)과 공격력(attack)을 가지고 있습니다. 직업별로 평균 공격력을 계산하여 밸런스를 확인해야 합니다.

과제:

  1. 캐릭터 이름, 직업, 공격력을 담은 characters 데이터 프레임을 만드세요.
  2. tapply()를 사용하여 'Warrior', 'Mage', 'Archer' 각 직업별 평균 공격력을 계산하세요.
  3. tapply()를 한 번 더 사용하여, 각 직업별 캐릭터가 몇 명인지도 계산해 보세요. (힌트: length 함수 사용)

정답 코드

characters <- data.frame(
  name = c("Aran", "Evan", "Mercedes", "Kain", "Luminous", "Xenon"),
  class = c("Warrior", "Mage", "Archer", "Archer", "Mage", "Warrior"),
  attack = c(150, 180, 130, 140, 195, 160)
)

print("--- 캐릭터 정보 ---")
print(characters)

# 직업별 평균 공격력 계산
avg_attack_by_class <- tapply(characters$attack, characters$class, mean)
print("--- 직업별 평균 공격력 ---")
print(avg_attack_by_class)

# 직업별 캐릭터 수 계산
count_by_class <- tapply(characters$attack, characters$class, length)
# characters$name 이나 다른 열을 사용해도 결과는 동일
print("--- 직업별 캐릭터 수 ---")
print(count_by_class)

해설

이 문제는 tapply()의 다양한 활용법을 보여줍니다. 동일한 그룹화 기준(characters$class)에 대해 다른 요약 함수(mean, length)를 적용하는 방법을 연습합니다.

  • tapply(characters$attack, characters$class, mean): attack 데이터를 class로 그룹화하여 평균(mean)을 계산합니다.
  • tapply(characters$attack, characters$class, length): attack 데이터를 class로 그룹화하여 각 그룹의 요소 개수(length)를 셉니다. 이는 SQL의 COUNT(*)와 동일한 역할을 합니다. tapply의 첫 번째 인자로 어떤 열을 넣어도 length 함수는 해당 그룹의 행 수를 세기 때문에 결과는 같습니다.
  • 이처럼 tapply는 평균, 합계, 개수, 표준편차 등 그룹별로 원하는 거의 모든 통계량을 쉽게 계산할 수 있게 해줍니다.

120. 중첩 리스트(Nested List) 데이터 처리

상황 설명: 여러 명의 학생 데이터가 중첩 리스트 형태로 저장되어 있습니다. 각 학생 리스트는 이름(name)과 성적 벡터(scores)를 포함하고 있습니다. 모든 학생의 평균 점수를 계산하여 출력해야 합니다.

과제:

  1. 두 명 이상의 학생 정보를 포함하는 중첩 리스트 students_list를 만드세요.
  2. lapply() 또는 sapply()를 사용하여 각 학생의 평균 점수를 계산하세요.
    • 힌트: lapply에 적용할 익명 함수 function(student) { ... }를 사용하면 편리합니다. 이 함수는 학생 리스트 하나를 입력받아 평균 점수를 반환해야 합니다.
  3. 계산된 평균 점수들을 출력하세요.

정답 코드

student1 <- list(name = "김 R", scores = c(85, 90, 88))
student2 <- list(name = "이 코딩", scores = c(92, 95, 98))
student3 <- list(name = "박 분석", scores = c(78, 82, 80))

students_list <- list(student1, student2, student3)

print("--- 원본 중첩 리스트 데이터 ---")
str(students_list) # 리스트 구조 확인

# sapply와 익명 함수를 사용하여 각 학생의 평균 점수 계산
average_scores <- sapply(students_list, function(student) {
  # 각 학생(리스트)의 scores 요소에 접근하여 평균을 계산
  return(mean(student$scores))
})

print("--- 학생별 평균 점수 ---")
print(average_scores)

# 결과에 학생 이름을 붙여주면 더 좋습니다.
student_names <- sapply(students_list, function(student) student$name)
names(average_scores) <- student_names
print("--- 학생 이름과 함께 보는 평균 점수 ---")
print(average_scores)

해설

이 문제는 복잡한 데이터 구조인 중첩 리스트를 apply 계열 함수로 우아하게 처리하는 방법을 보여줍니다.

  • 중첩 리스트: 리스트 안에 또 다른 리스트가 요소로 포함된 구조입니다. JSON이나 XML 같은 반정형 데이터를 R로 불러왔을 때 흔히 볼 수 있습니다.
  • 익명 함수 (Anonymous Function): function(인자) { 표현식 } 형태로, 이름을 따로 지정하지 않고 함수가 필요한 곳에 즉석에서 만들어 사용하는 함수입니다. lapplysapply처럼 다른 함수에 인자로 함수를 전달할 때 매우 유용합니다.
  • sapply(students_list, function(student) { ... }): sapplystudents_list의 각 요소(즉, student1, student2, student3 리스트)를 function(student)student 인자로 하나씩 전달합니다.
  • mean(student$scores): 함수 내부에서는 전달받은 student 리스트에서 $ 기호를 사용하여 scores 벡터에 접근하고, mean() 함수로 평균을 계산합니다.
  • 이 예제는 for 루프를 사용했다면 코드가 훨씬 길고 복잡해졌을 작업을 sapply와 익명 함수를 통해 단 몇 줄로 간결하게 해결할 수 있음을 보여주는 좋은 사례입니다.

알겠습니다. 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 사용자를 위한 R 프로그래밍 문제 시리즈를 생성하겠습니다.

범위: 초급 7단계 (121~140번) 주제: 사용자 정의 함수 작성 및 환경/디버깅 기초

이 단계에서는 R의 핵심 역량인 '자동화'와 '문제 해결'의 첫걸음을 떼게 됩니다. 반복적인 작업을 함수로 묶어 재사용성을 높이고, 코드에 문제가 생겼을 때 원인을 체계적으로 찾아내는 방법을 학습합니다.


121. 모험의 시작을 알리는 인사 함수

문제 상황: 당신은 새로운 롤플레잉 게임(RPG)의 개발자입니다. 게임을 시작할 때 모든 모험가에게 동일한 환영 메시지를 출력하는 기능을 만들고 싶습니다. 이 기능을 R 함수로 구현해 보세요.

과제 지시 사항:

  1. greet_adventurer라는 이름의 함수를 만드세요.
  2. 이 함수는 아무런 인자(argument)도 받지 않습니다.
  3. 함수를 호출하면 콘솔에 "모험가여, 우리의 세계에 온 것을 환영합니다!"라는 문자열을 출력해야 합니다.
  4. 함수를 정의한 후, 직접 호출하여 메시지가 정상적으로 출력되는지 확인하세요.

정답 코드

# 1. greet_adventurer 함수 정의
greet_adventurer <- function() {
  # 3. 함수가 호출되면 실행될 코드
  print("모험가여, 우리의 세계에 온 것을 환영합니다!")
}

# 4. 함수 호출
greet_adventurer()

해설

이 문제는 R에서 가장 기본적인 형태의 사용자 정의 함수를 만드는 방법을 다룹니다.

  • function_name <- function(arguments) { ... }: R에서 함수를 정의하는 기본 구문입니다.
    • greet_adventurer <-: greet_adventurer라는 이름의 객체에 함수를 할당합니다. R에서는 함수도 하나의 객체로 취급됩니다.
    • function(): 이 객체가 함수임을 선언합니다. 괄호 () 안에는 함수가 받을 인자(입력값)를 정의하는데, 이 문제에서는 인자가 없으므로 비워둡니다.
    • { ... }: 중괄호 안에는 함수가 호출되었을 때 실행될 코드 블록이 들어갑니다.
  • print(): 괄호 안의 내용을 콘솔에 출력하는 함수입니다. 여기서는 지정된 환영 메시지를 출력하는 역할을 합니다.
  • greet_adventurer(): 함수 이름 뒤에 괄호 ()를 붙여 함수를 '호출(call)'하거나 '실행(execute)'합니다. 이 코드를 실행하면 R은 greet_adventurer에 저장된 함수 정의를 찾아 중괄호 안의 코드를 실행합니다.

122. 맞춤형 환영 메시지 함수

문제 상황: 게임 개발을 이어가던 당신은 모든 모험가에게 똑같은 메시지를 보여주는 것이 아쉽다고 생각했습니다. 이제 모험가의 이름을 입력받아, 그 이름이 포함된 맞춤형 환영 메시지를 보여주는 기능을 만들고 싶습니다.

과제 지시 사항:

  1. greet_by_name이라는 이름의 함수를 만드세요.
  2. 이 함수는 name이라는 하나의 인자를 받습니다.
  3. 함수는 "전설적인 영웅, [이름]! 당신의 여정을 응원합니다." 형태의 메시지를 생성하여 반환(return)해야 합니다.
  4. paste0() 또는 paste() 함수를 사용하여 문자열을 조합하세요.
  5. "아라곤"이라는 이름으로 함수를 호출하고, 반환된 결과를 message_for_aragorn 변수에 저장한 후 출력하세요.

정답 코드

# 1, 2. greet_by_name 함수 정의
greet_by_name <- function(name) {
  # 3, 4. 메시지 생성 및 반환
  message <- paste0("전설적인 영웅, ", name, "! 당신의 여정을 응원합니다.")
  return(message)
}

# 5. 함수 호출 및 결과 저장, 출력
message_for_aragorn <- greet_by_name(name = "아라곤")
print(message_for_aragorn)

해설

이 문제는 함수에 인자(argument)를 전달하고, 함수가 처리한 결과를 값으로 반환(return)하는 방법을 보여줍니다.

  • function(name): 함수를 정의할 때 괄호 안에 name이라는 인자를 선언했습니다. 함수를 호출할 때 이 name 자리에 값을 전달하면, 함수 내부 코드 블록에서 name이라는 변수처럼 사용할 수 있습니다.
  • paste0(): 여러 문자열을 공백 없이 하나로 합쳐주는 함수입니다. paste("A", "B")"A B"를 반환하지만, paste0("A", "B")"AB"를 반환합니다. 여기서는 이름과 메시지 사이에 불필요한 공백이 생기지 않도록 paste0()를 사용했습니다.
  • return(message): 함수가 실행을 마치고 호출된 곳으로 돌려줄 값을 명시적으로 지정합니다. return()을 사용하면 그 즉시 함수 실행이 종료되고 해당 값을 반환합니다. R에서는 return()을 생략할 경우, 함수 본문의 마지막으로 평가된 표현식의 결과가 자동으로 반환됩니다. 하지만 명시적으로 return()을 쓰는 것이 코드의 의도를 명확하게 하는 좋은 습관입니다.
  • greet_by_name(name = "아라곤"): 함수를 호출할 때 인자이름 = 값 형태로 값을 전달했습니다. 이를 '명명된 인자(named argument)'라고 하며, 인자의 순서가 헷갈릴 때 코드의 가독성을 높여줍니다.

123. 커피숍 주문 금액 계산기

문제 상황: 당신은 동네 작은 커피숍의 매출 관리를 돕고 있습니다. 아메리카노(4,100원)와 라떼(4,600원)의 판매 잔 수를 입력하면 총매출을 계산해주는 함수가 필요합니다.

과제 지시 사항:

  1. calculate_coffee_total이라는 이름의 함수를 만드세요.
  2. 이 함수는 americano_cupslatte_cups라는 두 개의 인자를 받습니다.
  3. 아메리카노 가격은 4100, 라떼 가격은 4600으로 함수 내에서 계산하세요.
  4. 총매출((아메리카노 잔 수 * 4100) + (라떼 잔 수 * 4600))을 계산하여 반환하세요.
  5. 아메리카노 5잔, 라떼 3잔이 팔렸을 경우의 총매출을 계산하여 출력하세요.

정답 코드

# 1, 2. calculate_coffee_total 함수 정의
calculate_coffee_total <- function(americano_cups, latte_cups) {
  # 3. 가격 설정
  americano_price <- 4100
  latte_price <- 4600
  
  # 4. 총 매출 계산 및 반환
  total_sales <- (americano_cups * americano_price) + (latte_cups * latte_price)
  return(total_sales)
}

# 5. 함수 호출 및 결과 출력
daily_sales <- calculate_coffee_total(americano_cups = 5, latte_cups = 3)
print(daily_sales)

해설

이 문제는 여러 개의 인자를 받아 계산을 수행하고 결과를 반환하는, 보다 실용적인 함수를 만드는 연습입니다.

  • function(americano_cups, latte_cups): 함수가 두 개의 입력값을 받을 수 있도록 인자를 쉼표(,)로 구분하여 정의했습니다. 함수를 호출할 때 이 두 인자에 해당하는 값을 모두 전달해야 합니다.
  • 함수 내부 변수: americano_price, latte_price, total_sales와 같이 함수 내부에서 선언된 변수들은 '지역 변수(local variable)'입니다. 이 변수들은 함수가 실행되는 동안에만 존재하며, 함수 실행이 끝나면 사라집니다. 함수 밖에서는 이 변수들에 접근할 수 없습니다. 이를 '변수의 범위(scope)'라고 합니다.
  • 계산 로직: 함수의 핵심적인 역할은 입력받은 americano_cupslatte_cups를 이용해 총매출을 계산하는 것입니다. 이처럼 복잡한 계산이나 반복되는 로직을 함수로 만들어두면, 필요할 때마다 간단히 함수를 호출하는 것만으로 동일한 작업을 수행할 수 있어 코드의 재사용성과 유지보수성이 크게 향상됩니다.

124. 아이템 강화 확률 계산기 (기본 성공률)

문제 상황: 게임 개발자로서, 당신은 아이템 강화 시스템을 만들고 있습니다. 아이템 강화에는 기본 성공 확률이 있고, 여기에 행운 스탯이 보너스로 더해집니다. 이 최종 강화 성공 확률을 계산하는 함수를 만들어야 합니다. 대부분의 아이템은 기본 성공 확률이 20%입니다.

과제 지시 사항:

  1. calculate_enchant_chance라는 이름의 함수를 만드세요.
  2. 이 함수는 luck_statbase_chance 두 개의 인자를 받습니다.
  3. base_chance 인자의 기본값(default value)을 0.2 (20%)로 설정하세요.
  4. 최종 성공 확률은 base_chance + (luck_stat * 0.005)로 계산합니다. (행운 스탯 1당 0.5%p 증가)
  5. 계산된 최종 성공 확률을 반환하세요.
  6. 다음 두 가지 경우에 대해 함수를 호출하고 결과를 출력하세요.
    • 행운 스탯이 50이고, 기본 성공 확률을 사용하는 경우
    • 행운 스탯이 50이고, 특별한 아이템이라 기본 성공 확률이 0.4 (40%)인 경우

정답 코드

# 1, 2, 3. calculate_enchant_chance 함수 정의
calculate_enchant_chance <- function(luck_stat, base_chance = 0.2) {
  # 4. 최종 성공 확률 계산
  final_chance <- base_chance + (luck_stat * 0.005)
  
  # 5. 결과 반환
  return(final_chance)
}

# 6. 함수 호출 및 결과 출력
# 경우 1: 기본 성공 확률 사용
normal_item_chance <- calculate_enchant_chance(luck_stat = 50)
print(paste("일반 아이템 강화 확률:", normal_item_chance))

# 경우 2: 특별한 기본 성공 확률 사용
special_item_chance <- calculate_enchant_chance(luck_stat = 50, base_chance = 0.4)
print(paste("특별 아이템 강화 확률:", special_item_chance))

해설

이 문제는 함수 인자에 '기본값(default value)'을 설정하는 방법을 배웁니다.

  • base_chance = 0.2: 함수를 정의할 때 인자에 =를 사용하여 값을 할당하면, 이 값이 해당 인자의 기본값이 됩니다.
  • 기본값의 동작 방식:
    • 함수를 호출할 때 base_chance에 대한 값을 명시적으로 전달하지 않으면 (첫 번째 호출처럼), R은 자동으로 기본값인 0.2를 사용합니다.
    • base_chance에 새로운 값을 전달하면 (두 번째 호출처럼 base_chance = 0.4), 기본값은 무시되고 새로 전달된 값이 사용됩니다.
  • 장점: 기본값 설정은 함수를 더 유연하게 만들어 줍니다. 자주 사용되는 값은 기본값으로 설정해두어 함수 호출을 간결하게 만들고, 특별한 경우에는 다른 값을 지정하여 사용할 수 있습니다. 필수적이지 않은 옵션을 설정할 때 매우 유용합니다.

125. 우주선 상태 보고 함수

문제 상황: 당신은 화성 탐사선의 비행 관제사입니다. 탐사선으로부터 현재 속도(km/s)와 남은 연료량(%) 데이터를 받았습니다. 이 두 데이터를 바탕으로 탐사선의 상태가 "정상", "주의", "위험" 중 무엇인지 판별하고, 상세 정보와 함께 상태를 보고하는 함수를 만들어야 합니다.

과제 지시 사항:

  1. report_ship_status라는 이름의 함수를 만드세요.
  2. speedfuel_level 두 인자를 받습니다.
  3. 함수 내부에서 다음 로직에 따라 status를 결정하세요.
    • fuel_level이 20 미만이거나 speed가 15를 초과하면 "위험"
    • fuel_level이 50 미만이거나 speed가 10을 초과하면 "주의"
    • 그 외 모든 경우는 "정상"
  4. 함수는 status, current_speed, remaining_fuel 세 가지 정보를 담은 명명된 리스트(named list)를 반환해야 합니다.
  5. speed = 12, fuel_level = 45인 경우의 상태를 보고받아 출력하세요.

정답 코드

# 1, 2. report_ship_status 함수 정의
report_ship_status <- function(speed, fuel_level) {
  
  # 3. 상태 판별 로직
  if (fuel_level < 20 | speed > 15) {
    status <- "위험"
  } else if (fuel_level < 50 | speed > 10) {
    status <- "주의"
  } else {
    status <- "정상"
  }
  
  # 4. 명명된 리스트 생성 및 반환
  report <- list(
    status = status,
    current_speed = speed,
    remaining_fuel = fuel_level
  )
  
  return(report)
}

# 5. 함수 호출 및 결과 출력
ship_report <- report_ship_status(speed = 12, fuel_level = 45)
print(ship_report)

해설

이 문제는 함수 내부에 조건문(if-else if-else)을 사용하여 복잡한 로직을 처리하고, 여러 종류의 정보를 하나의 결과물(리스트)로 묶어 반환하는 방법을 다룹니다.

  • if-else if-else 조건문:
    • if (fuel_level < 20 | speed > 15): 첫 번째 조건을 검사합니다. |는 OR 연산자로, 두 조건 중 하나라도 TRUE이면 전체가 TRUE가 됩니다. 조건이 참이면 {} 안의 코드를 실행하고 나머지 else if, else는 건너뜁니다.
    • else if (fuel_level < 50 | speed > 10): 첫 번째 if 조건이 거짓일 경우에만 이 조건을 검사합니다.
    • else: 위의 모든 조건이 거짓일 경우 실행됩니다.
  • list(): 여러 R 객체를 담을 수 있는 데이터 구조입니다. 이름 = 값 형태로 요소를 생성하면 '명명된 리스트'가 되어, 나중에 $ 기호를 이용해 (ship_report$status처럼) 특정 정보에 쉽게 접근할 수 있습니다.
  • 함수의 반환값: 함수는 단 하나의 객체만 반환할 수 있습니다. 따라서 상태, 속도, 연료량처럼 여러 정보를 반환하고 싶을 때, 이들을 리스트나 데이터 프레임 등으로 묶어서 하나의 객체로 만들어 반환하는 것이 일반적인 패턴입니다.

126. 섭씨-화씨 온도 변환기

문제 상황: 당신은 글로벌 날씨 앱을 만들고 있습니다. 서버로부터는 섭씨(Celsius) 온도로 데이터를 받지만, 일부 사용자에게는 화씨(Fahrenheit) 온도로 보여줘야 합니다. 섭씨 온도를 화씨 온도로 변환하는 함수를 작성하세요. 이 함수는 단일 값이 아닌 온도 벡터 전체를 한 번에 변환할 수 있어야 합니다.

과제 지시 사항:

  1. celsius_to_fahrenheit라는 이름의 함수를 만드세요.
  2. celsius라는 인자를 하나 받습니다. 이 인자는 숫자 벡터가 될 수 있습니다.
  3. 섭씨를 화씨로 변환하는 공식 화씨 = (섭씨 * 9/5) + 32 를 사용하여 계산하세요.
  4. 계산된 화씨 온도 (벡터)를 반환하세요.
  5. c(0, 10, 25, 30, 100) 벡터를 함수에 전달하여 변환된 결과를 출력하세요.

정답 코드

# 1, 2. celsius_to_fahrenheit 함수 정의
celsius_to_fahrenheit <- function(celsius) {
  # 3. 화씨 온도 계산
  fahrenheit <- (celsius * 9/5) + 32
  
  # 4. 결과 반환
  return(fahrenheit)
}

# 5. 벡터를 인자로 함수 호출 및 결과 출력
celsius_temps <- c(0, 10, 25, 30, 100)
fahrenheit_temps <- celsius_to_fahrenheit(celsius = celsius_temps)
print(fahrenheit_temps)

해설

이 문제는 R의 강력한 기능인 '벡터화(vectorization)'를 함수에 어떻게 적용하는지 보여줍니다.

  • 벡터화: R의 많은 기본 연산자들(+, -, *, / 등)은 벡터 단위로 동작하도록 설계되었습니다. celsius * 9/5 코드를 실행하면, R은 celsius 벡터의 각각의 요소9/5를 곱한 새로운 벡터를 생성합니다. 그 다음, 이 새로운 벡터의 각각의 요소에 32를 더합니다.
  • for 루프 불필요: 다른 프로그래밍 언어에서는 벡터의 각 요소를 하나씩 순회하며 계산하기 위해 for 루프를 사용해야 할 수 있습니다. 하지만 R에서는 벡터화 덕분에 단 한 줄의 코드로 이 모든 계산을 간결하고 효율적으로 처리할 수 있습니다.
  • 함수의 재사용성: 이렇게 벡터화된 연산을 사용하는 함수를 만들면, 단일 값(celsius_to_fahrenheit(25))을 처리할 때나 여러 값으로 이루어진 벡터(celsius_to_fahrenheit(c(0, 10, 25)))를 처리할 때나 동일한 함수를 수정 없이 사용할 수 있어 매우 편리합니다.

127. 데이터 정규화 함수 만들기

문제 상황: 머신러닝 모델을 훈련시키기 전, 여러 변수(feature)들의 스케일을 통일시키는 '정규화(Normalization)' 과정은 매우 중요합니다. 가장 널리 쓰이는 방법 중 하나는 '최소-최대 정규화(Min-Max Normalization)'입니다. 숫자 벡터를 입력받아 0과 1 사이의 값으로 정규화하는 함수를 만들어 보세요.

과제 지시 사항:

  1. normalize_vector라는 이름의 함수를 만드세요.
  2. x라는 숫자 벡터를 인자로 받습니다.
  3. 함수 내부에서 x가 비어있거나 모든 값이 동일한 경우와 같은 예외 상황을 처리하세요. 만약 모든 값이 같다면, 0으로만 이루어진 벡터를 반환해야 합니다.
  4. 최소-최대 정규화 공식 $x_{norm} = \frac{x - min(x)}{max(x) - min(x)}$ 을 사용하여 값을 계산하세요.
  5. 정규화된 벡터를 반환하세요.
  6. c(10, 20, 30, 40, 50) 벡터를 정규화하여 결과를 출력하세요.

정답 코드

# 1, 2. normalize_vector 함수 정의
normalize_vector <- function(x) {
  # 3. 예외 처리: 모든 값이 동일한 경우
  # max(x) - min(x)가 0이 되어 0으로 나누는 에러를 방지
  if (max(x) == min(x)) {
    return(rep(0, length(x)))
  }
  
  # 4. 최소-최대 정규화 계산
  min_val <- min(x)
  max_val <- max(x)
  normalized <- (x - min_val) / (max_val - min_val)
  
  # 5. 결과 반환
  return(normalized)
}

# 6. 함수 호출 및 결과 출력
my_vector <- c(10, 20, 30, 40, 50)
normalized_vector <- normalize_vector(my_vector)
print(normalized_vector)

해설

이 문제는 통계적 개념을 함수로 구현하고, 잠재적인 오류(0으로 나누기)를 방지하기 위한 예외 처리를 포함하는 방법을 다룹니다.

  • 수학 공식의 코드화:
    • $min(x)$는 R의 min(x) 함수로, $max(x)$max(x) 함수로 직접 변환할 수 있습니다.
    • 분자 x - min(x)와 분모 max(x) - min(x) 모두 벡터화 연산을 통해 벡터 x의 모든 요소에 대해 한 번에 계산됩니다.
    • LaTeX 수식: $$ x_{norm} = \frac{x - \min(x)}{\max(x) - \min(x)} $$
  • 예외 처리(Edge Case Handling):
    • 만약 입력 벡터 xc(5, 5, 5, 5)처럼 모든 값이 같다면, max(x) - min(x)5 - 5 = 0이 됩니다. 숫자를 0으로 나누는 것은 수학적으로 불가능하며, R에서는 NaN (Not a Number) 또는 Inf (Infinity)를 결과로 내어 문제를 일으킬 수 있습니다.
    • if (max(x) == min(x)) 구문은 이러한 상황을 미리 감지합니다. 이 경우, 모든 값의 편차가 0이므로 정규화된 값은 모두 0이 되는 것이 타당합니다. rep(0, length(x))는 입력 벡터와 동일한 길이의 0으로 채워진 벡터를 생성하여 반환합니다.
  • 데이터 과학에서의 중요성: 이런 정규화 함수는 데이터 전처리(preprocessing) 단계에서 필수적입니다. 직접 만들어봄으로써 데이터 과학의 기본 원리를 더 깊이 이해할 수 있습니다.

128. 함수 내에서 다른 함수 호출하기

문제 상황: 당신은 통계 분석 패키지를 만들고 있습니다. 데이터의 중심 경향성과 퍼진 정도를 함께 요약하는 함수를 만들려고 합니다. 이를 위해, 평균을 계산하는 함수와 표준편차를 계산하는 함수를 각각 만든 뒤, 이 두 함수를 호출하여 최종 요약 보고서를 생성하는 종합 함수를 작성해 보세요.

과제 지시 사항:

  1. 평균을 계산하는 calculate_mean 함수를 만드세요. (내장 함수 mean() 사용)
  2. 표준편차를 계산하는 calculate_sd 함수를 만드세요. (내장 함수 sd() 사용)
  3. summarize_vector라는 종합 함수를 만드세요. 이 함수는 숫자 벡터 x를 인자로 받습니다.
  4. summarize_vector 함수 내부에서 calculate_mean(x)calculate_sd(x)를 호출하여 각각 평균과 표준편차를 구하세요.
  5. 평균, 표준편차, 그리고 관측치 개수(length(x))를 포함하는 명명된 리스트를 반환하세요.
  6. rnorm(100, mean = 50, sd = 10)으로 생성한 100개의 난수 벡터에 대해 summarize_vector를 실행하고 결과를 출력하세요.

정답 코드

# 1. 평균 계산 함수
calculate_mean <- function(x) {
  return(mean(x))
}

# 2. 표준편차 계산 함수
calculate_sd <- function(x) {
  return(sd(x))
}

# 3. 종합 요약 함수
summarize_vector <- function(x) {
  # 4. 다른 함수들 호출
  avg <- calculate_mean(x)
  std_dev <- calculate_sd(x)
  
  # 5. 결과 리스트 생성 및 반환
  summary_list <- list(
    mean = avg,
    standard_deviation = std_dev,
    count = length(x)
  )
  return(summary_list)
}

# 6. 함수 실행 및 결과 출력
set.seed(42) # 결과 재현을 위한 시드 설정
random_data <- rnorm(100, mean = 50, sd = 10)
data_summary <- summarize_vector(random_data)
print(data_summary)

해설

이 문제는 코드의 모듈화(modularity) 개념을 보여줍니다. 복잡한 작업을 여러 개의 작고 단순한 함수로 나눈 뒤, 이들을 조합하여 최종 목표를 달성하는 방식입니다.

  • 모듈화의 장점:
    • 가독성: summarize_vector 함수는 그 자체로 "평균을 구하고, 표준편차를 구해서, 요약 리스트를 만든다"는 명확한 흐름을 보여줍니다. 각 계산의 세부 구현은 해당 함수 내부에 숨겨져 있어 전체 로직을 파악하기 쉽습니다.
    • 재사용성: calculate_mean 함수는 이제 다른 곳에서도 평균이 필요할 때마다 가져다 쓸 수 있습니다.
    • 유지보수: 만약 표준편차를 계산하는 방식을 바꿔야 한다면, calculate_sd 함수만 수정하면 됩니다. summarize_vector나 다른 부분은 건드릴 필요가 없습니다.
  • 함수 호출 스택: summarize_vector(random_data)가 호출되면, R은 summarize_vector의 코드를 실행합니다. 실행 도중 calculate_mean(x)를 만나면, 잠시 summarize_vector의 실행을 멈추고 calculate_mean 함수로 이동하여 실행합니다. calculate_mean이 값을 반환하면, 다시 summarize_vector의 멈췄던 부분으로 돌아와 다음 코드를 실행합니다. 이를 '함수 호출 스택'의 원리라고 합니다.

129. 작업 환경 탐색하기: ls()rm()

문제 상황: 당신은 여러 데이터 분석 프로젝트를 동시에 진행하고 있습니다. R 세션에 너무 많은 변수(객체)들이 쌓여서 작업 공간이 혼란스러워졌습니다. 현재 어떤 변수들이 있는지 확인하고, 불필요한 변수들을 선택적으로 삭제하여 작업 환경을 정리하는 연습을 해봅시다.

과제 지시 사항:

  1. 다음 세 개의 변수를 생성하세요:
    • project_a_data <- data.frame(id = 1:5, value = rnorm(5))
    • project_b_model <- "Linear Regression"
    • temp_variable <- "This is temporary"
  2. ls() 함수를 사용하여 현재 작업 환경에 있는 모든 객체의 이름을 출력하세요.
  3. temp_variable 객체가 더 이상 필요 없다고 판단하여 rm() 함수로 삭제하세요.
  4. 다시 ls() 함수를 사용하여 temp_variable이 성공적으로 삭제되었는지 확인하세요.

정답 코드

# 1. 변수 생성
project_a_data <- data.frame(id = 1:5, value = rnorm(5))
project_b_model <- "Linear Regression"
temp_variable <- "This is temporary"

# 2. 현재 객체 목록 확인
print("--- 삭제 전 객체 목록 ---")
print(ls())

# 3. 특정 객체 삭제
rm(temp_variable)

# 4. 삭제 후 객체 목록 확인
print("--- 삭제 후 객체 목록 ---")
print(ls())

해설

이 문제는 R의 작업 환경(workspace) 또는 전역 환경(global environment)을 관리하는 기본적인 함수인 ls()rm()의 사용법을 익힙니다.

  • R의 환경(Environment): 환경은 변수(객체)들이 저장되는 공간입니다. 우리가 보통 R 콘솔에서 변수를 만들면, 그것은 '전역 환경'에 저장됩니다. 함수 내부에서 만들어진 변수는 해당 함수의 '지역 환경'에 저장되고 함수가 끝나면 사라집니다.
  • ls(): "list"의 약자로, 현재 환경에 있는 객체들의 이름을 문자열 벡터로 반환합니다. 복잡한 분석을 하다가 어떤 변수를 만들었는지 기억나지 않을 때 매우 유용합니다. ls(pattern = "project")처럼 특정 패턴을 포함하는 객체만 찾아볼 수도 있습니다.
  • rm(): "remove"의 약자로, 괄호 안에 지정된 이름의 객체를 현재 환경에서 삭제합니다. 메모리를 확보하거나, 더 이상 사용하지 않는 중간 결과물들을 정리하여 작업 공간을 깔끔하게 유지하는 데 사용됩니다. 여러 개를 한 번에 지우려면 rm(obj1, obj2)와 같이 쉼표로 나열하거나, rm(list = c("obj1", "obj2"))를 사용합니다.
  • 좋은 습관: 분석의 재현성을 위해, 스크립트 시작 부분에 rm(list = ls()) 코드를 넣어 이전에 작업하던 환경을 깨끗이 비우고 시작하는 경우가 많습니다. 이는 스크립트가 이전에 존재하던 변수에 의존하지 않고 독립적으로 실행될 수 있도록 보장합니다.

130. 버그 찾기 대작전 (1): print() 디버깅

문제 상황: 당신은 게임 캐릭터의 최종 데미지를 계산하는 함수를 만들었습니다. 데미지 공식은 (기본 공격력 + 아이템 공격력) * 크리티컬 배율 입니다. 그런데 함수를 테스트해보니 예상과 다른 결과가 나옵니다. 함수 코드 중간중간에 print()를 삽입하여 어느 계산 단계에서 문제가 발생하는지 추적해 보세요.

문제 코드:

calculate_damage <- function(base_atk, item_atk, is_critical) {
  total_atk <- base_atk + item_atk
  
  if (is_critical) {
    critical_multiplier = "2.0" # 버그! 문자열로 할당됨
  } else {
    critical_multiplier = 1.0
  }
  
  final_damage <- total_atk * critical_multiplier
  return(final_damage)
}

# 테스트: 기본공격력 100, 아이템공격력 50, 크리티컬 발생
calculate_damage(100, 50, TRUE) 
# 에러 발생: Error in total_atk * critical_multiplier : non-numeric argument to binary operator

과제 지시 사항:

  1. 위의 calculate_damage 함수를 복사하여 당신의 R 스크립트에 붙여넣으세요.
  2. total_atk이 계산된 직후, critical_multiplier가 결정된 직후, 그리고 final_damage가 계산되기 직전에 각 변수의 값을 print() 함수로 출력하도록 코드를 추가하세요.
  3. 수정된 함수를 실행하고 출력되는 메시지를 관찰하여 버그의 원인을 찾아내세요.
  4. 버그를 수정한 최종 코드를 작성하세요. ("2.0"2.0으로 변경)

정답 코드

# 1, 2. print()를 추가하여 디버깅하는 함수
debug_calculate_damage <- function(base_atk, item_atk, is_critical) {
  print("--- 함수 시작 ---")
  
  total_atk <- base_atk + item_atk
  print(paste("total_atk 값:", total_atk, "타입:", class(total_atk)))
  
  if (is_critical) {
    critical_multiplier <- "2.0" # 버그 지점
  } else {
    critical_multiplier <- 1.0
  }
  print(paste("critical_multiplier 값:", critical_multiplier, "타입:", class(critical_multiplier)))
  
  print("final_damage 계산 직전...")
  final_damage <- total_atk * critical_multiplier
  return(final_damage)
}

# 3. 수정된 함수 실행
# debug_calculate_damage(100, 50, TRUE)
# 출력 결과 분석:
# [1] "--- 함수 시작 ---"
# [1] "total_atk 값: 150 타입: numeric"
# [1] "critical_multiplier 값: 2.0 타입: character"  <-- 문제 발견!
# [1] "final_damage 계산 직전..."
# Error in total_atk * critical_multiplier : non-numeric argument to binary operator

# 4. 버그를 수정한 최종 함수
calculate_damage_fixed <- function(base_atk, item_atk, is_critical) {
  total_atk <- base_atk + item_atk
  
  if (is_critical) {
    critical_multiplier <- 2.0 # 버그 수정: 문자열 "2.0"을 숫자 2.0으로 변경
  } else {
    critical_multiplier <- 1.0
  }
  
  final_damage <- total_atk * critical_multiplier
  return(final_damage)
}

# 수정된 함수 테스트
final_result <- calculate_damage_fixed(100, 50, TRUE)
print(paste("최종 데미지:", final_result))

해설

이 문제는 가장 원시적이면서도 효과적인 디버깅 방법인 'print 디버깅' 또는 'printf 디버깅'을 연습합니다.

  • 디버깅(Debugging): 코드에 존재하는 오류(버그)를 찾아내고 수정하는 과정을 말합니다.
  • print() 디버깅의 원리: 코드의 실행 흐름 중간중간에 변수의 값이나 특정 메시지를 출력하게 함으로써, 프로그램이 내가 의도한 대로 동작하고 있는지, 변수들이 예상한 값을 가지고 있는지 눈으로 직접 확인하는 방법입니다.
  • 문제 해결 과정:
    1. print()를 추가한 함수를 실행하자, critical_multiplier의 타입이 character(문자열)로 출력되는 것을 확인했습니다.
    2. 에러 메시지 non-numeric argument to binary operator는 "숫자가 아닌 인자가 이항 연산자(*)에 사용되었다"는 뜻입니다.
    3. 이 두 가지 정보를 종합하면, 숫자여야 할 critical_multiplier가 문자열이라서 total_atk (숫자)와 곱셈을 할 수 없다는 것이 버그의 원인임을 명확히 알 수 있습니다.
    4. 원인을 찾았으니, "2.0"을 숫자 2.0으로 수정하여 문제를 해결합니다.
  • print()는 간단한 버그를 빠르게 찾는 데 매우 유용하지만, 코드가 복잡해지면 print() 문을 추가하고 삭제하는 것이 번거로워집니다. 이럴 때 다음 문제들에서 배울 더 체계적인 디버깅 도구들이 필요해집니다.

131. 에러의 근원 추적하기: traceback()

문제 상황: 당신은 여러 단계의 데이터 처리 파이프라인을 만들었습니다. 첫 번째 함수는 원시 데이터를 가져오고, 두 번째 함수는 데이터를 정제하며, 세 번째 함수는 통계를 계산합니다. 그런데 최종적으로 파이프라인을 실행하니 에러가 발생했습니다. 에러가 정확히 어느 함수의 어느 부분에서 발생했는지 추적하고 싶습니다.

문제 코드:

# 데이터 로드 (가상)
load_data <- function(source) {
  return(data.frame(
    player = c("A", "B", "C", "D"),
    score = c(100, 150, "200", 120) # 버그: 점수 하나가 문자열
  ))
}

# 데이터 정제
clean_data <- function(raw_data) {
  # 점수가 0보다 큰 데이터만 필터링한다고 가정
  clean <- raw_data[raw_data$score > 0, ]
  return(clean)
}

# 통계 계산
calculate_stats <- function(df) {
  avg_score <- mean(df$score)
  return(list(average = avg_score))
}

# 파이프라인 실행
raw <- load_data("game_server")
cleaned <- clean_data(raw) # 여기서 에러 발생
stats <- calculate_stats(cleaned)
print(stats)

과제 지시 사항:

  1. 위의 문제 코드를 실행하여 에러를 발생시키세요.
  2. 에러가 발생한 직후, R 콘솔에 traceback() 함수를 실행하세요.
  3. traceback()의 출력 결과를 보고, 에러가 어떤 함수 호출 순서(call stack)를 거쳐 발생했는지, 그리고 가장 직접적인 원인이 된 코드 라인이 무엇인지 설명하세요.

정답 코드

# 1. 문제 코드 실행
# ... (위의 코드 블록 실행) ...
# 에러 발생:
# Error in raw_data$score > 0 : 
#   '>' not meaningful for factors
# In addition: Warning message:
# In Ops.factor(raw_data$score, 0) : ‘>’ not meaningful for factors

# 2. traceback() 실행
traceback()

# 3. 출력 결과 및 해석
# 예시 출력:
# 2: clean_data(raw) at #23
# 1: raw_data$score > 0
#
# 해석:
# 에러는 2번 호출 스택에서 발생했습니다. 
# 즉, 스크립트 23번째 줄 근처에 있는 `clean_data(raw)`를 실행하던 중에 문제가 생겼습니다.
# 더 구체적으로, 에러의 직접적인 원인은 1번 스택에 나타난 `raw_data$score > 0` 연산입니다.
# `clean_data` 함수 내부에서 `score` 열에 대해 `>` 비교 연산을 시도하다가 에러가 났음을 알 수 있습니다.
# (이유: `load_data`에서 "200" 때문에 score 열이 factor 또는 character로 자동 변환되어 숫자 비교가 불가능해졌기 때문)

해설

이 문제는 에러 발생 후 그 경로를 역추적하는 traceback() 함수의 사용법을 배웁니다.

  • traceback(): 마지막으로 발생한 에러가 어떤 함수 호출들을 거쳐서 발생했는지를 순서대로 보여줍니다. 이 순서를 '호출 스택(call stack)'이라고 합니다. traceback()의 출력은 아래에서 위로 읽습니다. 가장 아래쪽(가장 높은 번호)이 최초의 함수 호출이고, 가장 위쪽(가장 낮은 번호)이 에러가 직접 발생한 지점입니다.
  • 해석 방법:
    • 2: clean_data(raw) at #23: 전역 환경(스크립트의 23번째 줄)에서 clean_data 함수가 raw를 인자로 하여 호출되었음을 의미합니다.
    • 1: raw_data$score > 0: clean_data 함수 내부에서 raw_data$score > 0 이라는 코드가 실행되다가 에러가 발생했음을 의미합니다.
  • 디버깅 전략: traceback()을 통해 "어디서" 문제가 발생했는지(clean_data 함수 내부)를 특정했습니다. 이제 우리는 clean_data 함수로 들어가기 직전의 raw 데이터의 상태(str(raw) 등을 통해)를 살펴보거나, clean_data 함수 자체를 디버깅하여 "왜" score > 0 연산이 실패했는지를 파고들 수 있습니다. 이 경우, score 열이 숫자가 아니라는 것이 근본 원인입니다.

132. 코드 속으로 들어가기: browser()

문제 상황: 당신은 우주선의 연료 소모량을 시뮬레이션하는 함수를 작성 중입니다. 함수는 비행 거리(distance)와 운석 지대 통과 여부(is_asteroid_field)를 입력받아 소모된 연료량을 계산합니다. 그런데 운석 지대를 통과했을 때 연료 소모량이 예상보다 적게 나옵니다. browser()를 이용해 함수 실행을 중간에 멈추고, 변수 값들을 직접 확인하여 버그를 찾아보세요.

문제 코드:

calculate_fuel_consumption <- function(distance, is_asteroid_field) {
  base_consumption <- distance * 0.5 # 1km당 0.5L 소모
  
  if (is_asteroid_field) {
    # 운석 지대에서는 회피 기동으로 연료 50L 추가 소모
    extra_consumption <- 50
  }
  
  # 버그! extra_consumption이 if문 밖에서 정의되지 않을 수 있음
  total_consumption <- base_consumption + extra_consumption 
  
  return(total_consumption)
}

# 테스트: 운석 지대를 통과하지 않은 경우 (정상 동작)
calculate_fuel_consumption(100, FALSE) # 에러 발생!

# 테스트: 운석 지대를 통과한 경우 (의도와 다른 결과가 나올 수 있음)
# calculate_fuel_consumption(100, TRUE)

과제 지시 사항:

  1. 위의 calculate_fuel_consumption 함수 코드에서, total_consumption을 계산하는 라인 바로 위에 browser()를 삽입하세요.
  2. calculate_fuel_consumption(100, FALSE)를 호출하여 함수를 실행하세요.
  3. 실행이 멈추고 Browse[1]> 프롬프트가 나타나면, 다음을 순서대로 입력하며 변수들의 상태를 확인하세요.
    • distance
    • is_asteroid_field
    • base_consumption
    • extra_consumption (이때 어떤 일이 일어나는지 주목하세요!)
  4. 확인한 내용을 바탕으로 버그의 원인을 설명하고, 올바르게 수정한 코드를 작성하세요.

정답 코드

# 1. browser()를 삽입한 함수
debug_fuel_consumption <- function(distance, is_asteroid_field) {
  base_consumption <- distance * 0.5
  
  if (is_asteroid_field) {
    extra_consumption <- 50
  }
  
  # browser()를 이용해 이 지점에서 코드 실행을 멈춤
  browser() 
  
  total_consumption <- base_consumption + extra_consumption
  
  return(total_consumption)
}

# 2. 함수 호출
# debug_fuel_consumption(100, FALSE)

# 3. 디버깅 세션에서 변수 확인
# Browse[1]> distance
# [1] 100
# Browse[1]> is_asteroid_field
# [1] FALSE
# Browse[1]> base_consumption
# [1] 50
# Browse[1]> extra_consumption
# Error: object 'extra_consumption' not found

# 4. 원인 설명 및 코드 수정
# 원인: is_asteroid_field가 FALSE일 경우, if문 블록이 실행되지 않아
# extra_consumption 변수가 생성조차 되지 않습니다.
# 따라서 total_consumption을 계산하려 할 때 'extra_consumption'을 찾을 수 없다는 에러가 발생합니다.

# 수정된 코드
calculate_fuel_consumption_fixed <- function(distance, is_asteroid_field) {
  base_consumption <- distance * 0.5
  
  # extra_consumption을 0으로 초기화
  extra_consumption <- 0 
  
  if (is_asteroid_field) {
    extra_consumption <- 50
  }
  
  total_consumption <- base_consumption + extra_consumption
  
  return(total_consumption)
}

# 수정된 함수 테스트
print(calculate_fuel_consumption_fixed(100, FALSE))
print(calculate_fuel_consumption_fixed(100, TRUE))

해설

이 문제는 가장 강력한 R 디버깅 도구 중 하나인 browser()의 사용법을 익힙니다.

  • browser(): 코드에 이 함수를 삽입하면, R은 코드를 실행하다가 browser()를 만나는 순간 실행을 일시정지하고, 해당 시점의 함수 환경으로 사용자를 들여보내 줍니다.
  • 디버깅 프롬프트 Browse[1]>:
    • 이 프롬프트가 나타나면, 우리는 해당 함수 내부에 있는 것처럼 행동할 수 있습니다.
    • 함수 내의 지역 변수 이름(예: base_consumption)을 입력하면 그 값을 확인할 수 있습니다.
    • ls()를 입력하면 현재 환경의 모든 변수를 볼 수 있습니다.
    • n 또는 Enter 키: 다음 한 줄의 코드를 실행합니다.
    • c: 나머지 코드를 계속 실행하고 디버깅 모드를 빠져나갑니다.
    • Q: 실행을 완전히 중단하고 디버깅 모드를 빠져나갑니다.
  • 문제 해결: browser() 환경에서 extra_consumption을 확인하려 하자 'not found' 에러가 발생했습니다. 이를 통해 is_asteroid_fieldFALSE인 시나리오에서 이 변수가 아예 생성되지 않는다는 사실을 명확히 알 수 있었습니다. 해결책은 if문 이전에 extra_consumption을 0으로 '초기화(initialize)'하여 어떤 경우에도 이 변수가 존재하도록 보장하는 것입니다.

133. 함수에 디버그 깃발 꽂기: debug()undebug()

문제 상황: 당신은 복잡한 데이터 처리 함수 process_data를 가지고 있습니다. 이 함수는 여러 단계로 구성되어 있어 어디서 문제가 생기는지 알기 어렵습니다. 함수의 코드를 직접 수정하여 browser()를 넣는 대신, 함수 자체에 '디버그 모드'를 설정하여 함수가 호출될 때마다 자동으로 디버깅 세션에 진입하도록 하고 싶습니다.

문제 코드:

process_data <- function(data) {
  # 1단계: 결측치 처리
  data <- na.omit(data)
  
  # 2단계: 값 변환 (버그: log(0)은 -Inf를 생성)
  data$value_log <- log(data$value) 
  
  # 3단계: 요약
  summary_val <- summary(data$value_log)
  return(summary_val)
}

# 테스트 데이터
my_data <- data.frame(id = 1:5, value = c(10, 5, 0, 20, 15))

과제 지시 사항:

  1. debug(process_data)를 실행하여 process_data 함수에 디버그 모드를 설정하세요.
  2. process_data(my_data)를 호출하세요. 함수 시작 부분에서 Browse[2]> 프롬프트와 함께 실행이 멈추는 것을 확인하세요.
  3. n 키를 여러 번 눌러 코드를 한 줄씩 실행하세요. data$value_log <- log(data$value) 라인이 실행된 후, data 변수의 내용을 확인하여 어떤 문제가 발생했는지 찾아내세요.
  4. Q를 눌러 디버깅을 중단하세요.
  5. undebug(process_data)를 실행하여 디버그 모드를 해제하세요.
  6. 버그의 원인을 설명하고, log() 함수에 0이 들어가지 않도록 데이터를 필터링하는 방식으로 코드를 수정하세요.

정답 코드

# 문제 함수 및 데이터
process_data <- function(data) {
  data <- na.omit(data)
  data$value_log <- log(data$value)
  summary_val <- summary(data$value_log)
  return(summary_val)
}
my_data <- data.frame(id = 1:5, value = c(10, 5, 0, 20, 15))

# 1. 디버그 모드 설정
debug(process_data)

# 2. 함수 호출 -> 디버깅 세션 시작
# process_data(my_data)

# 3. 디버깅 세션 내에서...
# Browse[2]> n
# debug at #3: data <- na.omit(data)
# Browse[2]> n
# debug at #4: data$value_log <- log(data$value)
# Browse[2]> n
# debug at #6: summary_val <- summary(data$value_log)
# Browse[2]> data
#   id value value_log
# 1  1    10  2.302585
# 2  2     5  1.609438
# 3  3     0      -Inf   <-- 문제 발견!
# 4  4    20  2.995732
# 5  5    15  2.708050
# Browse[2]> Q

# 5. 디버그 모드 해제
undebug(process_data)

# 6. 원인 설명 및 코드 수정
# 원인: my_data의 'value' 열에 0이 포함되어 있습니다. log(0)은 음의 무한대(-Inf)를 반환하며,
# 이는 후속 분석에서 원치 않는 결과를 초래할 수 있습니다.

# 수정된 코드
process_data_fixed <- function(data) {
  data <- na.omit(data)
  
  # 0보다 큰 값만 필터링하여 로그 변환의 오류를 방지
  data_positive <- data[data$value > 0, ]
  
  data_positive$value_log <- log(data_positive$value)
  
  summary_val <- summary(data_positive$value_log)
  return(summary_val)
}

# 수정된 함수 테스트
process_data_fixed(my_data)

해설

이 문제는 browser()를 코드에 직접 삽입하는 대신, 함수 자체를 디버깅 모드로 전환하는 debug()undebug()를 다룹니다.

  • debug(function_name): R에게 "다음에 function_name이 호출되면, 코드 첫 줄부터 바로 browser() 모드로 들어가"라고 알려주는 것과 같습니다. 이는 함수의 소스 코드를 직접 수정할 수 없거나(예: 패키지에 내장된 함수) 여러 번 디버깅해야 할 때 매우 편리합니다.
  • undebug(function_name): 함수에 설정된 디버그 모드를 해제합니다. 디버깅이 끝나면 반드시 실행하여 함수가 정상적으로 동작하도록 되돌려 놓아야 합니다.
  • debugonce(function_name): debug()와 유사하지만, 단 한 번만 디버깅 모드로 진입하고 그 후에는 자동으로 디버그 모드가 해제됩니다. 일회성 확인에 유용합니다.
  • 문제 해결: debug()를 통해 process_data 함수 내부로 들어가 한 줄씩 실행해보니, log() 함수가 적용된 후 data 데이터 프레임에 -Inf가 생성된 것을 발견했습니다. 이를 통해 log(0)이 문제의 원인임을 파악하고, 로그를 취하기 전에 0 이하의 값을 제거하는 논리적인 해결책을 도출할 수 있었습니다.

134. 변수 범위(Scope)의 이해

문제 상황: 당신은 게임에서 플레이어의 누적 점수를 기록하는 스크립트를 작성하고 있습니다. 전역 변수 total_score를 0으로 설정하고, 몬스터를 잡을 때마다 점수를 추가하는 add_score 함수를 만들었습니다. 하지만 함수를 호출해도 total_score가 변하지 않는 문제가 발생했습니다.

문제 코드:

total_score <- 0

add_score <- function(points) {
  # 함수 내에서 total_score를 증가시키려고 시도
  total_score <- total_score + points
  cat(paste("함수 내에서 점수 추가:", total_score, "\n"))
}

cat(paste("함수 호출 전:", total_score, "\n"))
add_score(100)
cat(paste("함수 호출 후:", total_score, "\n")) # 왜 0일까?

과제 지시 사항:

  1. 위 코드를 실행하고 출력 결과를 확인하세요. total_score가 왜 함수 호출 후에도 0으로 남아있는지, R의 '변수 범위(Scope)' 규칙에 근거하여 설명하세요.
  2. 전역 환경의 변수를 함수 내에서 수정하기 위한 올바른 방법인 '초대입 할당 연산자' <<-를 사용하여 add_score 함수를 수정하세요.
  3. 수정된 함수를 사용하여 코드를 다시 실행하고, total_score가 정상적으로 100으로 업데이트되는지 확인하세요.

정답 코드

# 1. 원인 설명
# R에서 함수 내에서 변수에 값을 할당하면(예: `total_score <- ...`),
# R은 기본적으로 그 함수만의 '지역 환경(local environment)'에 새로운 'total_score'라는
# 지역 변수(local variable)를 생성합니다.
# 이 지역 변수는 함수 외부의 '전역 환경(global environment)'에 있는
# 'total_score'와는 이름만 같을 뿐, 완전히 다른 별개의 변수입니다.
# 따라서 함수 내에서 값을 바꾸더라도 전역 변수에는 아무런 영향을 주지 못합니다.
# 함수가 종료되면 이 지역 변수는 사라집니다.

# 2, 3. <<- 를 사용하여 수정한 코드
total_score <- 0

add_score_fixed <- function(points) {
  # <<- 연산자는 현재 환경에서 변수를 찾지 않고,
  # 상위 환경(이 경우 전역 환경)으로 거슬러 올라가 변수를 찾아 값을 할당합니다.
  total_score <<- total_score + points
  cat(paste("함수 내에서 점수 추가:", total_score, "\n"))
}

cat(paste("함수 호출 전:", total_score, "\n"))
add_score_fixed(100)
cat(paste("함수 호출 후:", total_score, "\n"))

해설

이 문제는 R 프로그래밍에서 매우 중요하지만 혼동하기 쉬운 개념인 '변수 범위(Lexical Scoping)'를 다룹니다.

  • 변수 범위(Scope): 변수가 어느 영역에서 유효한지를 정의하는 규칙입니다.
    • 전역 환경(Global Environment): R 세션의 가장 바깥 레벨. 여기서 만든 변수는 어디서든 (주의해서) 접근할 수 있습니다.
    • 지역 환경(Local Environment): 함수가 호출될 때마다 생성되는 임시 작업 공간. 함수 내에서 생성된 변수는 기본적으로 이 안에 존재하며, 함수가 끝나면 사라집니다.
  • <- vs. <<-:
    • <- (일반 할당): 항상 현재 환경에 변수를 생성하거나 값을 갱신합니다. 함수 안에서 사용되면 지역 변수를 만듭니다.
    • <<- (초대입 할당): 현재 환경을 건너뛰고, 한 단계 바깥쪽(부모) 환경에서 해당 이름의 변수를 찾습니다. 찾으면 그 변수의 값을 바꾸고, 없으면 계속 바깥으로 찾아 나갑니다. 만약 전역 환경까지 갔는데도 변수가 없으면 전역 환경에 새로 만듭니다.
  • 주의사항: <<-는 코드의 동작을 예측하기 어렵게 만들 수 있으므로(이를 '부작용(side effect)'이라 함), 꼭 필요한 경우가 아니면 남용하지 않는 것이 좋습니다. 데이터를 변경해야 한다면, 함수가 변경된 데이터를 return하고, 함수를 호출한 쪽에서 그 반환값으로 원래 변수를 덮어쓰는 방식(total_score <- add_score(total_score, 100))이 더 명확하고 안전한 코딩 스타일입니다.

135. 재귀 함수: 팩토리얼 계산기

문제 상황: 수학에서 팩토리얼(factorial), 기호로 n!은 1부터 n까지의 모든 양의 정수를 곱한 것입니다 (예: 5! = 5 * 4 * 3 * 2 * 1). 이 팩토리얼은 자기 자신을 호출하는 '재귀(recursion)'의 개념을 사용하여 우아하게 구현할 수 있습니다. n! = n * (n-1)! 이라는 점화식을 이용하여 팩토리얼 계산 함수를 작성해 보세요.

과제 지시 사항:

  1. calculate_factorial이라는 이름의 함수를 만드세요.
  2. 숫자 n을 인자로 받습니다.
  3. 함수 내에서, n이 0이면 1을 반환해야 합니다. (이를 '종료 조건' 또는 'base case'라고 합니다.)
  4. n이 0보다 크면, n * calculate_factorial(n - 1)을 반환해야 합니다. (이를 '재귀 호출'이라고 합니다.)
  5. calculate_factorial(5)를 호출하여 결과가 120이 나오는지 확인하세요.

정답 코드

# 1, 2. calculate_factorial 함수 정의
calculate_factorial <- function(n) {
  # 3. 종료 조건 (Base Case): 재귀를 멈추는 지점
  if (n == 0) {
    return(1)
  } 
  # 4. 재귀 호출 (Recursive Step)
  else {
    return(n * calculate_factorial(n - 1))
  }
}

# 5. 함수 테스트
result <- calculate_factorial(5)
print(result)

해설

이 문제는 함수가 자기 자신을 호출하는 고급 기법인 '재귀 함수(Recursive Function)'의 기본 구조를 이해하는 데 초점을 맞춥니다.

  • 재귀의 두 가지 핵심 요소:
    1. 종료 조건 (Base Case): 재귀 호출이 무한히 반복되지 않도록 멈추는 조건입니다. 이 조건이 없으면 함수는 자기 자신을 끊임없이 호출하다가 '스택 오버플로우' 에러를 내며 멈추게 됩니다. 팩토리얼에서는 0! = 1이라는 수학적 정의가 완벽한 종료 조건이 됩니다.
    2. 재귀 호출 (Recursive Step): 문제를 더 작은 단위의 동일한 문제로 나누어 해결하는 부분입니다. calculate_factorial(5)5 * calculate_factorial(4)로, calculate_factorial(4)는 다시 4 * calculate_factorial(3)으로... 이런 식으로 문제가 점점 작아져 마침내 종료 조건인 calculate_factorial(0)에 도달하게 됩니다.
  • 실행 과정 calculate_factorial(3) 예시:
    1. calculate_factorial(3) 호출 -> 3 * calculate_factorial(2) 반환 시도
    2. calculate_factorial(2) 호출 -> 2 * calculate_factorial(1) 반환 시도
    3. calculate_factorial(1) 호출 -> 1 * calculate_factorial(0) 반환 시도
    4. calculate_factorial(0) 호출 -> 종료 조건! 1을 반환.
    5. calculate_factorial(1)1 * 1 = 1을 반환.
    6. calculate_factorial(2)2 * 1 = 2를 반환.
    7. calculate_factorial(3)3 * 2 = 6을 최종 반환.

재귀는 문제의 구조가 재귀적으로 정의될 때 코드를 매우 간결하고 직관적으로 만들어주지만, 디버깅이 어렵고 비효율적일 수 있다는 단점도 있습니다.


136. NA를 안전하게 처리하는 통계 함수

문제 상황: 실제 데이터에는 결측값(NA)이 많이 포함되어 있습니다. R의 기본 통계 함수들(mean(), sum() 등)은 벡터에 NA가 하나라도 있으면 결과로 NA를 반환하는 경우가 많습니다. NA를 자동으로 제외하고 평균을 계산하는 더 안전한(robust) 함수를 직접 만들어 보세요.

과제 지시 사항:

  1. safe_mean이라는 이름의 함수를 만드세요.
  2. 숫자 벡터 x를 인자로 받습니다.
  3. 함수 내부에서, x에 포함된 모든 값이 NA인지 확인하세요. 만약 그렇다면, NA를 반환하세요.
  4. 그렇지 않다면, mean() 함수의 na.rm = TRUE 옵션을 사용하여 NA를 제외한 값들의 평균을 계산하고 그 결과를 반환하세요.
  5. 다음 세 가지 벡터에 대해 함수를 테스트하고 결과를 비교하세요.
    • v1 <- c(1, 2, 3, 4, 5)
    • v2 <- c(1, 2, NA, 4, 5)
    • v3 <- c(NA, NA, NA)

정답 코드

# 1, 2. safe_mean 함수 정의
safe_mean <- function(x) {
  # 3. 모든 값이 NA인지 확인하는 예외 처리
  if (all(is.na(x))) {
    return(NA)
  }
  
  # 4. na.rm = TRUE를 사용하여 평균 계산
  mean_val <- mean(x, na.rm = TRUE)
  return(mean_val)
}

# 5. 함수 테스트
v1 <- c(1, 2, 3, 4, 5)
v2 <- c(1, 2, NA, 4, 5)
v3 <- c(NA, NA, NA)

print(paste("v1의 안전한 평균:", safe_mean(v1)))
print(paste("v2의 안전한 평균:", safe_mean(v2)))
print(paste("v3의 안전한 평균:", safe_mean(v3)))

해설

이 문제는 실제 데이터 분석에서 흔히 마주치는 결측값(NA) 문제를 다루는 사용자 정의 함수를 만드는 실용적인 예제입니다.

  • na.rm = TRUE: 많은 R의 통계 함수들은 na.rm (NA remove)이라는 인자를 가지고 있습니다. 이 값을 TRUE로 설정하면, 계산을 수행하기 전에 벡터에서 NA 값을 모두 제거합니다. safe_mean(v2)는 내부적으로 mean(c(1, 2, 4, 5))를 계산하는 것과 같습니다.
  • is.na(x): 벡터 x의 각 요소가 NA인지 아닌지를 검사하여 TRUE/FALSE로 이루어진 논리 벡터를 반환합니다. is.na(v2)c(FALSE, FALSE, TRUE, FALSE, FALSE)를 반환합니다.
  • all(): 논리 벡터를 인자로 받아, 모든 요소가 TRUE일 때만 TRUE를 반환합니다. all(is.na(v3))v3의 모든 요소가 NA이므로 TRUE를 반환합니다.
  • 강건한(Robust) 함수: 이처럼 예상치 못한 입력(예: NA만 있는 벡터)에 대해서도 에러를 내거나 이상한 값을 반환하지 않고, 논리적으로 타당한 결과(이 경우 NA)를 내놓는 함수를 '강건하다' 또는 '안전하다'고 말합니다. 데이터 분석 파이프라인을 만들 때 이런 함수들을 사용하면 전체 프로세스가 더 안정적으로 동작합니다.

137. 가변 인자 함수: 모든 숫자 더하기

문제 상황: 당신은 몇 개의 숫자가 입력될지 모르는 상황에서, 입력된 모든 숫자를 더하는 함수를 만들고 싶습니다. 2개를 더할 수도, 5개를 더할 수도 있어야 합니다. R의 특별한 인자인 ... (점점점, ellipsis)을 사용하여 이를 구현해 보세요.

과제 지시 사항:

  1. sum_all이라는 이름의 함수를 만드세요.
  2. 이 함수는 ...를 인자로 받습니다.
  3. 함수 내부에서, ...로 전달된 모든 인자들을 list()를 사용해 하나의 리스트로 묶으세요.
  4. 리스트로 묶인 숫자들을 unlist()를 사용해 숫자 벡터로 변환하세요.
  5. sum() 함수를 이용해 이 벡터의 모든 요소를 더한 값을 반환하세요.
  6. sum_all(1, 2, 3)sum_all(10, 20, 30, 40, 50)을 각각 호출하여 결과를 확인하세요.

정답 코드

# 1, 2. sum_all 함수 정의
sum_all <- function(...) {
  # 3. ... 인자들을 리스트로 묶기
  numbers_list <- list(...)
  
  # 4. 리스트를 벡터로 변환
  numbers_vector <- unlist(numbers_list)
  
  # 5. 벡터의 합계 계산 및 반환
  total <- sum(numbers_vector)
  return(total)
}

# 6. 함수 테스트
print(sum_all(1, 2, 3))
print(sum_all(10, 20, 30, 40, 50))

해설

이 문제는 정해지지 않은 개수의 인자를 받는 '가변 인자 함수'를 만드는 방법을 다룹니다.

  • ... (Ellipsis): 함수 정의에서 ...는 "여기에 몇 개가 오든 모든 인자를 받겠다"는 의미의 특수한 약속입니다. 이는 c(), paste(), list()와 같은 많은 내장 함수들이 임의의 개수 인자를 처리할 수 있는 비결입니다.
  • ... 처리 방법:
    1. 함수 내부에서 ... 자체를 직접 다룰 수는 없습니다.
    2. list(...)를 사용하면 ...로 전달된 모든 인자들이 순서대로 리스트의 요소로 담깁니다. 예를 들어 sum_all(10, 20, 30)을 호출하면, 함수 내부에서 numbers_listlist(10, 20, 30)이 됩니다.
    3. unlist()는 리스트를 벡터로 풀어주는 함수입니다. unlist(list(10, 20, 30))은 숫자 벡터 c(10, 20, 30)을 생성합니다.
  • 활용: 이 기법은 여러 개의 데이터프레임을 하나로 합치거나, 여러 개의 그래프를 한 번에 그리는 등, 처리할 대상의 개수가 유동적인 함수를 만들 때 매우 유용합니다.

138. 미니 프로젝트: 학생 성적 처리기

문제 상황: 당신은 학생들의 성적이 담긴 데이터프레임을 받아, 각 학생의 평균 점수를 계산하고 합격/불합격 여부를 판정하는 종합 처리 함수를 만들어야 합니다.

데이터:

grades_df <- data.frame(
  name = c("Alice", "Bob", "Charlie", "David"),
  korean = c(85, 90, 78, 65),
  math = c(92, 88, 95, 70),
  english = c(88, 95, 80, 72)
)

과제 지시 사항:

  1. process_grades라는 이름의 함수를 만드세요. 이 함수는 위와 같은 형식의 데이터프레임을 인자로 받습니다.
  2. 함수 내부에서, korean, math, english 세 과목의 평균 점수를 계산하여 average라는 새로운 열을 데이터프레임에 추가하세요. (rowMeans() 함수를 사용하면 편리합니다.)
  3. average 점수가 80점 이상이면 "Pass", 미만이면 "Fail" 값을 갖는 result라는 새로운 열을 추가하세요. (ifelse() 함수 사용)
  4. 최종적으로 averageresult 열이 추가된 데이터프레임을 반환하세요.
  5. grades_df를 이용해 함수를 실행하고, 그 결과를 출력하여 확인하세요.

정답 코드

# 데이터 생성
grades_df <- data.frame(
  name = c("Alice", "Bob", "Charlie", "David"),
  korean = c(85, 90, 78, 65),
  math = c(92, 88, 95, 70),
  english = c(88, 95, 80, 72)
)

# 1. process_grades 함수 정의
process_grades <- function(df) {
  # 2. 평균 점수 열 추가
  # df[, c("korean", "math", "english")]는 점수 열들만 선택합니다.
  df$average <- rowMeans(df[, c("korean", "math", "english")])
  
  # 3. 합격/불합격 판정 열 추가
  df$result <- ifelse(df$average >= 80, "Pass", "Fail")
  
  # 4. 최종 데이터프레임 반환
  return(df)
}

# 5. 함수 실행 및 결과 출력
final_grades <- process_grades(grades_df)
print(final_grades)

해설

이 문제는 지금까지 배운 함수 작성, 데이터프레임 조작, 조건부 로직을 종합적으로 활용하는 미니 프로젝트입니다.

  • rowMeans(): 데이터프레임이나 행렬을 인자로 받아, 각 행(row)의 평균을 계산해주는 매우 유용한 함수입니다. df[, c("korean", "math", "english")]는 데이터프레임 df에서 이름(name) 열을 제외한 점수 관련 열들만 선택하는 부분입니다.
  • ifelse(test, yes, no): 벡터화된 if-else문입니다.
    • test: 평가할 조건이 담긴 논리 벡터 (예: df$average >= 80c(TRUE, TRUE, TRUE, FALSE) 같은 벡터를 생성).
    • yes: testTRUE인 위치에 사용할 값.
    • no: testFALSE인 위치에 사용할 값.
    • ifelsefor 루프 없이 조건에 따라 벡터 전체에 값을 할당할 수 있어 R에서 매우 효율적이고 널리 쓰이는 함수입니다.
  • 데이터 처리 파이프라인: 이 process_grades 함수는 데이터 처리의 작은 파이프라인 역할을 합니다. 원시 데이터(raw data)를 입력받아, 정해진 규칙에 따라 처리(processing)하고, 유용한 정보가 추가된 결과물(processed data)을 내놓습니다. 실제 데이터 분석에서는 이런 함수들을 여러 개 연결하여 복잡한 작업을 자동화합니다.

139. 디버깅 챌린지: 겹함수 에러 추적

문제 상황: 당신은 할인율을 계산하는 함수와 최종 가격을 계산하는 함수를 따로 만들었습니다. 그런데 두 함수를 함께 사용하니 에러가 발생합니다. debug()traceback()을 종합적으로 활용하여 어느 함수에서, 왜 문제가 발생하는지 정확히 진단하고 해결해 보세요.

문제 코드:

# 회원 등급에 따라 할인율을 반환하는 함수
get_discount_rate <- function(membership_level) {
  if (membership_level == "VIP") {
    rate <- 0.2
  } else if (membership_level == "Gold") {
    rate <- 0.1
  } else {
    rate <- "0" # 버그! 문자열 "0"을 반환
  }
  return(rate)
}

# 최종 가격을 계산하는 함수
calculate_final_price <- function(original_price, member_level) {
  discount_rate <- get_discount_rate(member_level)
  final_price <- original_price * (1 - discount_rate)
  return(final_price)
}

# 테스트: Gold 등급 (정상 동작)
print(calculate_final_price(10000, "Gold"))

# 테스트: 일반(Normal) 등급 (에러 발생)
print(calculate_final_price(10000, "Normal"))

과제 지시 사항:

  1. 코드를 실행하여 에러를 확인하고, traceback()을 실행하여 에러 발생 경로를 파악하세요.
  2. debug(calculate_final_price)를 실행하여 디버그 모드를 켜세요.
  3. calculate_final_price(10000, "Normal")을 다시 실행하고 디버깅 세션으로 진입하세요.
  4. n을 눌러 한 줄씩 실행하다가 get_discount_rate()가 호출된 직후, discount_rate 변수의 값과 타입을 확인하세요.
  5. 버그의 원인을 파악하고 get_discount_rate 함수를 올바르게 수정하세요.

정답 코드

# 1. traceback() 실행 후 분석
# > traceback()
# 2: calculate_final_price(10000, "Normal")
# 1: original_price * (1 - discount_rate)
# 분석: 에러는 calculate_final_price 함수 내부의 곱셈 연산에서 발생했다.
# discount_rate 변수가 숫자 타입이 아닐 가능성이 높다.

# 문제 함수들
get_discount_rate <- function(membership_level) {
  if (membership_level == "VIP") { rate <- 0.2 } 
  else if (membership_level == "Gold") { rate <- 0.1 } 
  else { rate <- "0" } # 버그 지점
  return(rate)
}
calculate_final_price <- function(original_price, member_level) {
  discount_rate <- get_discount_rate(member_level)
  final_price <- original_price * (1 - discount_rate)
  return(final_price)
}

# 2, 3, 4. 디버깅 과정
# > debug(calculate_final_price)
# > calculate_final_price(10000, "Normal")
# Browse[2]> n
# debug at #2: discount_rate <- get_discount_rate(member_level)
# Browse[2]> n
# debug at #3: final_price <- original_price * (1 - discount_rate)
# Browse[2]> discount_rate
# [1] "0"
# Browse[2]> class(discount_rate)
# [1] "character"  <-- 결정적 증거!

# 5. 버그 수정
get_discount_rate_fixed <- function(membership_level) {
  if (membership_level == "VIP") {
    rate <- 0.2
  } else if (membership_level == "Gold") {
    rate <- 0.1
  } else {
    rate <- 0 # 수정: 문자열 "0"을 숫자 0으로 변경
  }
  return(rate)
}

# 수정한 함수를 사용하는 최종 가격 계산 함수
calculate_final_price_fixed <- function(original_price, member_level) {
  discount_rate <- get_discount_rate_fixed(member_level)
  final_price <- original_price * (1 - discount_rate)
  return(final_price)
}

# 최종 테스트
print(calculate_final_price_fixed(10000, "Normal"))

해설

이 챌린지는 여러 함수가 얽혀 있을 때 발생하는 버그를 체계적으로 추적하는 종합적인 디버깅 능력을 요구합니다.

  • Top-Down 디버깅: 우리는 가장 바깥쪽 함수인 calculate_final_price에서 디버깅을 시작했습니다. traceback()은 문제가 이 함수 내부에 있음을 알려주었고, debug()를 통해 내부로 들어갔습니다.
  • 경계 확인: 함수와 함수 사이의 '경계'에서 값이 어떻게 전달되는지 확인하는 것이 중요합니다. 디버깅 과정에서 get_discount_rate가 반환한 discount_rate의 값이 예상과 다른 타입(character)임을 발견했습니다. 이것이 문제의 핵심이었습니다.
  • 근본 원인 수정: 문제가 발생한 곳은 calculate_final_price의 곱셈 연산이지만, 근본 원인은 get_discount_rate가 잘못된 타입의 값을 반환했기 때문입니다. 따라서 수정은 get_discount_rate 함수에서 이루어져야 합니다. 이처럼 디버깅은 표면적인 증상이 아닌 근본 원인을 찾아 해결하는 과정입니다.

140. 함수 공장: 연산자 함수 만들기

문제 상황: 당신은 다양한 종류의 수학적 연산을 수행하는 함수들을 동적으로 생성하고 싶습니다. 예를 들어, "더하기 5"를 수행하는 함수, "곱하기 10"을 수행하는 함수를 일일이 만드는 대신, 원하는 숫자와 연산을 지정하면 해당 기능을 하는 함수를 '찍어내는' 함수(함수 공장, Function Factory)를 만들어 보세요.

과제 지시 사항:

  1. create_operator라는 이름의 함수를 만드세요. 이 함수는 operation (문자열, 예: "+" 또는 "*" )과 value (숫자) 두 인자를 받습니다.
  2. create_operator 함수는 내부에 새로운 함수를 정의하고 그 함수를 반환해야 합니다.
  3. 내부 함수는 x라는 단일 인자를 받으며, x operation value 연산을 수행하여 결과를 반환해야 합니다. (예: x + 5 또는 x * 10)
  4. create_operator를 사용하여 다음 두 함수를 생성하세요.
    • add_five: 숫자에 5를 더하는 함수
    • multiply_by_ten: 숫자에 10을 곱하는 함수
  5. 생성된 add_five(10)multiply_by_ten(10)을 각각 호출하여 결과(15와 100)를 확인하세요.

정답 코드

# 1. 함수 공장(Function Factory) 정의
create_operator <- function(operation, value) {
  
  # 2, 3. 내부 함수 정의
  operator_func <- function(x) {
    # match.fun을 사용해 문자열로 연산자를 찾아 함수로 변환
    op_fun <- match.fun(operation)
    return(op_fun(x, value))
  }
  
  # 내부 함수를 반환
  return(operator_func)
}

# 4. 함수 공장을 이용해 새로운 함수들 생성
add_five <- create_operator(operation = "+", value = 5)
multiply_by_ten <- create_operator(operation = "*", value = 10)

# 5. 생성된 함수들 테스트
print(paste("10 + 5 =", add_five(10)))
print(paste("10 * 10 =", multiply_by_ten(10)))

# 생성된 함수의 내용 확인
print(add_five)

해설

이 문제는 R의 고급 기능 중 하나인 '함수형 프로그래밍'의 핵심 개념, 즉 함수가 다른 함수를 생성하여 반환할 수 있다는 것을 보여줍니다.

  • 함수 공장 (Function Factory): create_operator와 같이 함수를 '생산'하는 함수를 말합니다. 이는 코드의 중복을 줄이고 매우 유연한 프로그래밍을 가능하게 합니다.
  • 클로저 (Closure): create_operator가 반환한 add_five와 같은 함수를 '클로저'라고 부릅니다. 클로저는 자신이 생성될 때의 환경을 '기억'합니다.
    • add_five 함수는 create_operatoroperation = "+" 이고 value = 5 일 때의 환경에서 만들어졌습니다.
    • 따라서 add_five는 자기 자신의 코드(function(x) ...) 뿐만 아니라, operationvalue의 값도 함께 가지고 다닙니다.
    • add_five를 호출하면, 이 함수는 자신이 기억하고 있는 operation("+")과 value(5)를 사용하여 계산을 수행합니다.
  • match.fun(operation): 문자열로 된 연산자(예: "+")를 실제 R의 연산 함수( + )로 찾아 바꿔주는 유용한 함수입니다. 이를 통해 if (operation == "+") ... else if (operation == "*") ... 와 같은 긴 코드를 피할 수 있습니다.

이러한 함수 공장 패턴은 R의 tidyverse 패키지 등에서 고급 기능을 구현하는 데 널리 사용되는 강력한 기법입니다.

R 프로그래밍 문제 (초급 8단계: 141-160번)

주제: 데이터 입출력 및 기본 텍스트 처리


141. CSV 파일 불러오기 기초

당신은 작은 동네 빵집의 데이터 분석가입니다. 매일의 제품별 판매량이 기록된 sales.csv 파일을 분석하여 어떤 빵이 가장 인기가 많은지 알아보고자 합니다. 분석의 첫 단계로, 이 CSV 파일을 R로 불러와야 합니다.

과제: 아래와 같은 내용을 가진 sales.csv 파일이 있다고 가정하고, 이 파일을 R로 불러와 sales_df라는 이름의 데이터 프레임으로 저장하는 코드를 작성하세요.

# sales.csv 내용
Product,Quantity,Date
Croissant,35,2023-04-01
Baguette,25,2023-04-01
Sourdough,15,2023-04-01
Croissant,41,2023-04-02
Baguette,20,2023-04-02
Sourdough,18,2023-04-02

정답 코드

# 현재 작업 디렉토리에 sales.csv 파일이 있다고 가정합니다.
# 실습을 위해 직접 파일을 생성하려면 다음 코드를 실행하세요.
write.csv(data.frame(
  Product = c("Croissant", "Baguette", "Sourdough", "Croissant", "Baguette", "Sourdough"),
  Quantity = c(35, 25, 15, 41, 20, 18),
  Date = c("2023-04-01", "2023-04-01", "2023-04-01", "2023-04-02", "2023-04-02", "2023-04-02")
), "sales.csv", row.names = FALSE, quote = FALSE)

# sales.csv 파일을 읽어와 sales_df에 저장
sales_df <- read.csv("sales.csv")

# 불러온 데이터 확인
print(sales_df)

해설

read.csv() 함수는 CSV(Comma-Separated Values) 파일을 R의 데이터 프레임(data frame)으로 읽어오는 가장 기본적인 함수입니다.

  • read.csv("sales.csv"): 첫 번째 인수로 읽어올 파일의 이름(또는 경로)을 문자열로 전달합니다.
  • 이 함수는 기본적으로 파일의 첫 번째 줄을 **헤더(header)**로 인식하여 데이터 프레임의 열 이름으로 사용합니다.
  • 또한, 값들을 구분하는 **구분자(separator)**로 쉼표(,)를 자동으로 사용합니다.
  • 데이터 분석의 첫걸음은 데이터를 R 환경으로 정확하게 불러오는 것이며, read.csv()는 그 시작을 담당하는 매우 중요한 함수입니다.

142. 다른 구분자를 가진 파일 불러오기

이번에는 유럽 지사에서 보낸 판매량 데이터를 받았습니다. 유럽에서는 종종 쉼표(,) 대신 세미콜론(;)을 구분자로 사용합니다. 이 파일을 올바르게 불러오려면 구분자를 직접 지정해주어야 합니다.

과제: 아래와 같이 세미콜론으로 구분된 sales_eu.csv 파일이 있다고 가정하고, 이 파일을 sales_eu_df 데이터 프레임으로 정확하게 불러오는 코드를 작성하세요.

# sales_eu.csv 내용
Product;Quantity;Date
Croissant;35;2023-04-01
Baguette;25;2023-04-01
Sourdough;15;2023-04-01

정답 코드

# sales_eu.csv 파일 생성
write.table(data.frame(
  Product = c("Croissant", "Baguette", "Sourdough"),
  Quantity = c(35, 25, 15),
  Date = c("2023-04-01", "2023-04-01", "2023-04-01")
), "sales_eu.csv", sep = ";", row.names = FALSE, quote = FALSE)

# sep 인자를 사용하여 세미콜론을 구분자로 지정
sales_eu_df <- read.csv("sales_eu.csv", sep = ";")

# 불러온 데이터 확인
print(sales_eu_df)

해설

read.csv() 함수는 다양한 옵션을 인자(argument)로 받아 유연하게 파일을 읽을 수 있습니다.

  • sep = ";": sep 인자는 "separator"의 약자로, 데이터 값을 구분하는 문자를 지정합니다. 기본값은 ","이지만, 이 문제처럼 다른 구분자를 사용하는 경우 sep에 해당 문자를 지정해주면 됩니다.
  • 만약 sep을 지정하지 않고 이 파일을 읽으려 했다면, R은 모든 열을 하나의 큰 열로 인식하여 Product;Quantity;Date 라는 이름의 열 하나만 생성했을 것입니다. 정확한 데이터 분석을 위해서는 데이터의 구조를 파악하고 그에 맞는 옵션을 사용하는 것이 중요합니다.

143. 헤더가 없는 파일 불러오기

센서로부터 수집된 원본(raw) 데이터를 받았습니다. 이 데이터 파일에는 열 이름을 담고 있는 헤더 행이 없고, 순수하게 데이터 값만 기록되어 있습니다.

과제: 아래와 같은 sensor_log.txt 파일은 헤더가 없습니다. 이 파일을 sensor_df 데이터 프레임으로 불러오세요. R이 자동으로 열 이름을 V1, V2, V3 등으로 생성하도록 하세요.

# sensor_log.txt 내용
101,25.4,60.1
102,25.5,60.3
101,25.5,60.2
103,22.1,75.8

정답 코드

# sensor_log.txt 파일 생성
write.table(data.frame(
  V1 = c(101, 102, 101, 103),
  V2 = c(25.4, 25.5, 25.5, 22.1),
  V3 = c(60.1, 60.3, 60.2, 75.8)
), "sensor_log.txt", sep = ",", row.names = FALSE, col.names = FALSE, quote = FALSE)

# header = FALSE 인자를 사용하여 헤더가 없음을 명시
sensor_df <- read.csv("sensor_log.txt", header = FALSE)

# 불러온 데이터 확인
print(sensor_df)

해설

  • header = FALSE: 이 인자는 read.csv() 함수에게 "파일의 첫 번째 줄을 데이터로 취급하고, 헤더로 사용하지 말라"고 알려줍니다.
  • 이 옵션을 사용하면, R은 자동으로 V1, V2, V3, ... 와 같은 형식으로 열 이름을 부여합니다. V는 "Variable"을 의미합니다.
  • 만약 header = FALSE를 지정하지 않으면, R은 첫 번째 데이터 행(101,25.4,60.1)을 열 이름으로 잘못 해석하여 데이터 프레임의 구조가 망가지게 됩니다.

144. 헤더 없는 파일에 직접 열 이름 부여하기

이전 문제에 이어서, 센서 데이터의 각 열이 무엇을 의미하는지 알게 되었습니다. 첫 번째 열은 'SensorID', 두 번째 열은 'Temperature', 세 번째 열은 'Humidity' 입니다. V1, V2 대신 이 의미 있는 이름을 데이터 프레임을 불러올 때 직접 지정해주고 싶습니다.

과제: sensor_log.txt 파일을 불러오면서, 열 이름을 각각 SensorID, Temperature, Humidity로 지정하여 sensor_named_df 데이터 프레임을 생성하세요.

# sensor_log.txt 내용
101,25.4,60.1
102,25.5,60.3
101,25.5,60.2
103,22.1,75.8

정답 코드

# sensor_log.txt 파일이 이미 있다고 가정합니다.

# col.names 인자를 사용하여 열 이름을 직접 지정
sensor_named_df <- read.csv("sensor_log.txt", header = FALSE, col.names = c("SensorID", "Temperature", "Humidity"))

# 불러온 데이터 확인
print(sensor_named_df)

해설

  • col.names = c(...): 이 인자는 데이터 프레임을 불러올 때 사용할 열 이름의 벡터를 직접 지정하는 역할을 합니다.
  • **header = FALSE**와 함께 사용해야 합니다. 만약 header = TRUE(기본값)인 상태에서 col.names를 사용하면, R은 파일의 첫 번째 줄(헤더)을 읽은 후, 그 헤더를 col.names로 지정된 이름으로 덮어씁니다. 헤더가 없는 파일이므로 header = FALSE를 명시적으로 써주는 것이 가장 안전하고 명확한 방법입니다.
  • 이 방법은 데이터가 헤더 없이 제공될 때, 분석의 편의성과 코드의 가독성을 높이기 위해 매우 유용합니다.

145. 특정 문자열을 결측치(NA)로 처리하기

게임 캐릭터의 능력치 데이터를 분석하던 중, 일부 값이 -999 또는 N/A로 기록된 것을 발견했습니다. 이는 데이터가 없거나 기록되지 않았음을 의미하는 결측치입니다. R이 이 값들을 문자열이 아닌 NA (Not Available)로 인식하게 하여 통계 계산에서 자동으로 제외되도록 해야 합니다.

과제: 아래 character_stats.csv 파일에서 -999N/A 값을 결측치(NA)로 처리하여 stats_df 데이터 프레임으로 불러오세요.

# character_stats.csv 내용
Character,Strength,Magic,Agility
Warrior,100,10,50
Mage,20,120,N/A
Archer,70,50,110
Rogue,-999,30,150

정답 코드

# character_stats.csv 파일 생성
write.csv(data.frame(
  Character = c("Warrior", "Mage", "Archer", "Rogue"),
  Strength = c("100", "20", "70", "-999"),
  Magic = c("10", "120", "50", "30"),
  Agility = c("50", "N/A", "110", "150")
), "character_stats.csv", row.names = FALSE, quote = FALSE)

# na.strings 인자를 사용하여 결측치로 처리할 문자열 지정
stats_df <- read.csv("character_stats.csv", na.strings = c("-999", "N/A"))

# 불러온 데이터 및 구조 확인
print(stats_df)
str(stats_df)

해설

  • na.strings = c(...): 이 인자는 파일 내에서 어떤 문자열들을 R의 공식 결측치인 NA로 변환할지 지정하는 벡터입니다.
  • 위 코드에서는 c("-999", "N/A")를 전달하여, 파일 내의 모든 -999N/A 문자열을 NA로 대체하도록 했습니다.
  • str(stats_df) 함수로 구조를 확인해보면, Strength, Magic, Agility 열이 int (정수) 또는 num (숫자) 타입으로 올바르게 인식된 것을 볼 수 있습니다. 만약 na.strings 옵션을 사용하지 않았다면, 이 열들은 문자(chr) 타입으로 잘못 인식되어 mean(), sum() 같은 수학적 계산이 불가능했을 것입니다. 데이터 클리닝의 매우 중요한 과정 중 하나입니다.

146. 공백(space)으로 구분된 파일 불러오기

천문학 연구실에서 항성(star)의 밝기와 위치 데이터를 받았습니다. 이 데이터는 쉼표나 탭이 아닌, 하나 이상의 공백으로 열이 구분되어 있습니다. 이런 형식의 파일은 read.table() 함수를 사용하는 것이 더 적합합니다.

과제: 아래와 같이 공백으로 구분된 star_data.txt 파일을 star_df 데이터 프레임으로 불러오세요. 파일의 첫 줄은 헤더입니다.

# star_data.txt 내용
StarID  Magnitude   RA          Dec
S-101   4.5         10.6847     41.2690
S-102   8.2         11.2311     -5.8912
S-103   -1.46       15.4567     -59.5123

정답 코드

# star_data.txt 파일 생성
# 여러 공백을 표현하기 위해 write.table 사용
cat("StarID  Magnitude   RA          Dec
S-101   4.5         10.6847     41.2690
S-102   8.2         11.2311     -5.8912
S-103   -1.46       15.4567     -59.5123", file = "star_data.txt")

# read.table 함수를 사용하여 공백 구분 파일 읽기
star_df <- read.table("star_data.txt", header = TRUE)

# 불러온 데이터 확인
print(star_df)

해설

  • read.table(): 이 함수는 read.csv()보다 더 일반적인 데이터 임포트 함수입니다. 기본 구분자가 쉼표가 아닌 **공백(whitespace)**입니다. 공백에는 스페이스(space), 탭(tab), 개행(newline) 등이 포함됩니다.
  • header = TRUE: read.csv()와 마찬가지로, 파일의 첫 줄을 헤더로 인식하라는 의미입니다. read.table()의 기본값은 header = FALSE이므로, 헤더가 있을 경우 반드시 TRUE로 명시해주어야 합니다.
  • read.csv()는 사실 read.table(file, header = TRUE, sep = ",")와 거의 동일한 설정의 단축 함수입니다. 데이터의 형태에 따라 더 적합한 함수를 선택하는 것이 좋습니다.

147. 탭(Tab)으로 구분된 파일 불러오기

유전체(Genomics) 데이터를 다룰 때 자주 사용되는 TSV (Tab-Separated Values) 형식의 파일을 받았습니다. 이 파일은 탭 문자로 열이 구분되어 있습니다.

과제: 아래와 같이 탭으로 구분된 gene_expression.tsv 파일을 gene_df 데이터 프레임으로 불러오는 코드를 작성하세요.

# gene_expression.tsv 내용 (공백이 아닌 탭으로 구분되어 있다고 상상하세요)
GeneID	Expression	P.Value
GeneA	2.51	0.045
GeneB	-1.23	0.001
GeneC	0.89	0.124

정답 코드

# gene_expression.tsv 파일 생성
write.table(data.frame(
  GeneID = c("GeneA", "GeneB", "GeneC"),
  Expression = c(2.51, -1.23, 0.89),
  P.Value = c(0.045, 0.001, 0.124)
), "gene_expression.tsv", sep = "\t", row.names = FALSE, quote = FALSE)

# 방법 1: read.table 사용
gene_df_1 <- read.table("gene_expression.tsv", header = TRUE, sep = "\t")

# 방법 2: read.delim 사용 (TSV 파일을 위한 단축 함수)
gene_df_2 <- read.delim("gene_expression.tsv")

# 결과 확인 (두 방법 모두 동일한 결과를 냄)
print(gene_df_1)
print(gene_df_2)

해설

탭으로 구분된 파일을 읽는 두 가지 주요 방법이 있습니다.

  1. read.table(..., sep = "\t"): read.table() 함수에 구분자 인자로 sep = "\t"을 전달합니다. \t는 R에서 탭 문자를 나타내는 이스케이프 시퀀스입니다.
  2. read.delim(): 이 함수는 탭으로 구분된 파일을 읽기 위해 미리 설정된 단축 함수입니다. 내부적으로 read.table(file, header = TRUE, sep = "\t")과 동일하게 동작하여 코드를 더 간결하게 만들어 줍니다.
  • 실무에서는 TSV 파일을 다룰 때 read.delim()을 사용하는 것이 더 흔하고 가독성이 좋습니다.

148. 파일의 일부 상단 라인 건너뛰고 불러오기

데이터 파일 맨 위에 데이터에 대한 설명이나 주석이 몇 줄 포함되어 있는 경우가 많습니다. 실제 데이터는 4번째 줄부터 시작합니다. 이 주석 부분을 제외하고 순수한 데이터만 불러와야 합니다.

과제: 아래 weather_data.csv 파일에서 상위 3줄의 주석을 건너뛰고, 4번째 줄부터 데이터로 읽어 weather_df 데이터 프레임을 생성하세요. 4번째 줄이 헤더가 됩니다.

# weather_data.csv 내용
# 서울 지역 기상 관측 데이터
# 측정일시: 2023-10-27
# 데이터 제공: 기상청
Date,Temperature,Precipitation
2023-10-27,15.5,5.2
2023-10-28,16.2,0.0
2023-10-29,14.8,1.5

정답 코드

# weather_data.csv 파일 생성
cat("# 서울 지역 기상 관측 데이터
# 측정일시: 2023-10-27
# 데이터 제공: 기상청
Date,Temperature,Precipitation
2023-10-27,15.5,5.2
2023-10-28,16.2,0.0
2023-10-29,14.8,1.5", file = "weather_data.csv")

# skip 인자를 사용하여 상위 3줄을 건너뜀
weather_df <- read.csv("weather_data.csv", skip = 3)

# 불러온 데이터 확인
print(weather_df)

해설

  • skip = 3: skip 인자는 파일의 맨 위에서부터 지정된 숫자만큼의 줄을 무시하고 데이터를 읽기 시작하라는 의미입니다. 이 경우, 3줄을 건너뛰고 4번째 줄부터 읽기 시작합니다.
  • read.csv()skip으로 건너뛴 바로 다음 줄을 헤더(기본값 header=TRUE이므로)로 인식합니다. 따라서 4번째 줄인 Date,Temperature,Precipitation이 정확하게 열 이름으로 사용됩니다.
  • 이 기능은 로그 파일이나 자동 생성된 리포트처럼 메타데이터가 파일 상단에 포함된 경우 매우 유용합니다.

149. 데이터 프레임을 CSV 파일로 내보내기

R에서 데이터를 분석하고 처리한 후, 그 결과를 다른 사람과 공유하거나 다른 소프트웨어에서 사용하기 위해 파일로 저장해야 합니다. 가장 일반적인 방법은 CSV 파일로 내보내는 것입니다.

과제: R에 내장된 iris 데이터셋의 첫 10개 행만 선택하여 iris_subset이라는 데이터 프레임을 만드세요. 그런 다음, 이 데이터 프레임을 iris_output.csv라는 이름의 CSV 파일로 저장하세요.

정답 코드

# iris 데이터셋의 첫 10개 행 선택
iris_subset <- head(iris, 10)

# 데이터 프레임을 CSV 파일로 저장
write.csv(iris_subset, "iris_output.csv")

# 저장이 잘 되었는지 확인 (R에서 다시 읽어보기)
saved_data <- read.csv("iris_output.csv")
print(saved_data)

해설

  • write.csv(): 데이터 프레임을 CSV 파일로 내보내는 함수입니다.
  • write.csv(iris_subset, "iris_output.csv"): 첫 번째 인자로는 저장할 R 객체(데이터 프레임), 두 번째 인자로는 저장될 파일의 이름을 문자열로 전달합니다.
  • write.csv()를 실행하고 생성된 iris_output.csv 파일을 열어보면, 첫 번째 열에 1, 2, 3, ... 과 같은 행 번호가 추가된 것을 볼 수 있습니다. 이는 R 데이터 프레임의 행 이름(row names)이 함께 저장되기 때문입니다.

150. 행 이름 없이 CSV 파일로 내보내기

이전 문제에서 write.csv()를 사용했을 때 불필요한 행 번호가 첫 열에 추가되었습니다. 대부분의 경우 이 행 번호는 필요 없으며, 데이터의 깔끔함을 위해 제외하는 것이 좋습니다.

과제: iris 데이터셋의 Speciessetosa인 행들만 필터링하여 setosa_df 데이터 프레임을 만드세요. 그 다음, 이 데이터 프레임을 행 이름이 포함되지 않도록 setosa_data.csv 파일로 저장하세요.

정답 코드

# Species가 setosa인 행들 필터링
setosa_df <- iris[iris$Species == "setosa", ]

# row.names = FALSE 인자를 사용하여 행 이름 없이 저장
write.csv(setosa_df, "setosa_data.csv", row.names = FALSE)

# 저장이 잘 되었는지 확인 (파일을 열어보거나 R에서 다시 읽어보기)
# 첫 번째 열이 Sepal.Length로 시작하는 것을 볼 수 있음
saved_data <- read.csv("setosa_data.csv")
print(head(saved_data))

해설

  • row.names = FALSE: write.csv() 함수에서 이 인자를 사용하면 데이터 프레임의 행 이름을 파일에 쓰지 않습니다.
  • 이것은 매우 흔하게 사용되는 옵션입니다. R 외부의 다른 프로그램(예: Python, Excel, Tableau)에서 데이터를 사용할 때, R의 행 이름은 보통 불필요한 인덱스 열로 인식되어 데이터 처리를 번거롭게 만들 수 있기 때문입니다.
  • 데이터 사이언티스트의 팁: 특별한 이유가 없는 한, 데이터를 외부에 공유하거나 저장할 때는 row.names = FALSE를 습관적으로 사용하는 것이 좋습니다.

151. 두 문자열 열 합치기

도서관 데이터베이스에서 책 제목(Title)과 저자(Author) 정보를 가지고 있습니다. 보고서를 위해 "제목 - 저자" 형식의 새로운 열을 만들어야 합니다.

과제: 아래 books_df 데이터 프레임에서 Title 열과 Author 열을 합쳐 "Pride and Prejudice - Jane Austen"과 같은 형식의 Full.Info 열을 새로 추가하세요.

books_df <- data.frame(
  Title = c("Pride and Prejudice", "To Kill a Mockingbird", "The Great Gatsby"),
  Author = c("Jane Austen", "Harper Lee", "F. Scott Fitzgerald")
)

정답 코드

books_df <- data.frame(
  Title = c("Pride and Prejudice", "To Kill a Mockingbird", "The Great Gatsby"),
  Author = c("Jane Austen", "Harper Lee", "F. Scott Fitzgerald")
)

# paste 함수를 사용하여 두 열을 합치고 새로운 열 생성
books_df$Full.Info <- paste(books_df$Title, books_df$Author, sep = " - ")

# 결과 확인
print(books_df)

해설

  • paste(): 이 함수는 여러 개의 문자열을 하나로 합치는 역할을 합니다.
  • paste(..., sep = " "): ... 부분에 합치고 싶은 문자열 벡터들을 쉼표로 구분하여 나열합니다. sep 인자는 합쳐지는 문자열들 사이에 들어갈 구분자를 지정합니다. 기본값은 공백(" ")입니다.
  • books_df$Title, books_df$Author: 합치고 싶은 두 열을 paste 함수에 전달합니다. R은 각 행별로 이 두 열의 값을 가져와 sep으로 지정된 " - "를 중간에 넣고 합칩니다.
  • 이 결과는 벡터 형태로 반환되므로, books_df$Full.Info <- ... 와 같이 새로운 열로 간단하게 할당할 수 있습니다.

152. 구분자 없이 문자열 합치기

고객 ID(C_ID)와 주문 날짜(OrderDate)를 합쳐서 고유한 주문 ID(OrderID)를 생성해야 합니다. 예를 들어 고객 ID가 101이고 주문 날짜가 20230401이면, 주문 ID는 C101D20230401이 되어야 합니다.

과제: 아래 orders_df 데이터 프레임에 C_IDOrderDate를 이용하여 위 형식에 맞는 OrderID 열을 추가하세요.

orders_df <- data.frame(
  C_ID = c(101, 205, 101),
  OrderDate = c(20230401, 20230401, 20230402),
  Amount = c(5000, 12000, 8000)
)

정답 코드

orders_df <- data.frame(
  C_ID = c(101, 205, 101),
  OrderDate = c(20230401, 20230401, 20230402),
  Amount = c(5000, 12000, 8000)
)

# paste0 함수와 paste 함수를 조합하여 고유 ID 생성
orders_df$OrderID <- paste0("C", orders_df$C_ID, "D", orders_df$OrderDate)

# 결과 확인
print(orders_df)

해설

  • paste0(...): 이 함수는 paste(..., sep = "")의 단축형입니다. 즉, 여러 문자열을 구분자 없이 바로 이어서 붙여줍니다.
  • paste0("C", orders_df$C_ID, "D", orders_df$OrderDate): 이 코드는 각 행에 대해 다음 순서로 문자열을 합칩니다.
    1. 고정된 문자열 "C"
    2. 해당 행의 C_ID
    3. 고정된 문자열 "D"
    4. 해당 행의 OrderDate
  • paste0는 고유 식별자(unique identifier)를 생성하거나, 파일 이름을 동적으로 만들거나, 로그 메시지를 조합하는 등 프로그래밍 전반에서 매우 유용하게 사용됩니다.

153. 문자열의 일부 추출하기

날짜가 "YYYY-MM-DD" 형식의 문자열로 저장되어 있습니다. 연도별 분석을 위해 이 문자열에서 연도(YYYY) 부분만 추출해야 합니다.

과제: sales_df 데이터 프레임의 Date 열에서 연도에 해당하는 앞의 4글자만 추출하여 Year라는 새로운 열을 만드세요.

sales_df <- data.frame(
  Product = c("A", "B", "C"),
  Date = c("2022-10-05", "2023-01-15", "2022-12-25")
)

정답 코드

sales_df <- data.frame(
  Product = c("A", "B", "C"),
  Date = c("2022-10-05", "2023-01-15", "2022-12-25")
)

# substr 함수를 사용하여 문자열의 일부를 추출
sales_df$Year <- substr(sales_df$Date, start = 1, stop = 4)

# 결과 확인
print(sales_df)

해설

  • substr(x, start, stop): 이 함수는 문자열 벡터 x의 각 요소에 대해, start 위치에서 시작하여 stop 위치에서 끝나는 부분 문자열(substring)을 추출합니다.
  • substr(sales_df$Date, start = 1, stop = 4): Date 열의 각 문자열에 대해,
    • start = 1: 첫 번째 글자부터
    • stop = 4: 네 번째 글자까지
  • 를 추출하라는 의미입니다. "YYYY-MM-DD" 형식에서 이는 정확히 연도(YYYY)에 해당합니다.
  • substr은 고정된 형식의 코드(예: 사원번호, 제품코드)에서 특정 정보를 뽑아낼 때 매우 효과적입니다.

154. 문자열 길이 계산하기

사용자 데이터베이스에서 비밀번호(password) 열의 보안성을 검토하려고 합니다. 첫 번째 단계는 각 사용자의 비밀번호 길이가 너무 짧지 않은지 확인하는 것입니다.

과제: 아래 users_df 데이터 프레임에서 Password 열에 있는 각 비밀번호의 길이를 계산하여 PW_Length라는 새 열에 저장하세요.

users_df <- data.frame(
  UserID = c("user01", "user02", "user03"),
  Password = c("pass123", "my_super_secret_pw", "12345")
)

정답 코드

users_df <- data.frame(
  UserID = c("user01", "user02", "user03"),
  Password = c("pass123", "my_super_secret_pw", "12345")
)

# nchar 함수를 사용하여 각 문자열의 길이를 계산
users_df$PW_Length <- nchar(users_df$Password)

# 결과 확인
print(users_df)

해설

  • nchar(): 이 함수는 문자열 벡터의 각 요소에 대해 문자의 개수(길이)를 계산하여 숫자 벡터로 반환합니다.
  • nchar(users_df$Password)Password 열의 각 문자열 "pass123", "my_super_secret_pw", "12345"의 길이를 각각 계산하여 c(7, 18, 5)라는 숫자 벡터를 생성합니다.
  • 이 함수는 데이터 유효성 검사(validation)에 매우 유용합니다. 예를 들어, 주민등록번호는 13자리여야 하고, 우편번호는 5자리여야 한다는 등의 규칙을 확인할 때 사용할 수 있습니다.

155. 특정 패턴을 포함하는 행 필터링하기

온라인 쇼핑몰의 상품 목록 데이터가 있습니다. 이 중에서 "유기농(Organic)"이라는 단어가 포함된 상품만 따로 모아서 프로모션을 진행하려고 합니다.

과제: 아래 products_df 데이터 프레임에서 ProductName 열에 "Organic"이라는 단어가 포함된 모든 행을 필터링하여 organic_products 데이터 프레임을 만드세요.

products_df <- data.frame(
  ProductID = 1:5,
  ProductName = c("Organic Apple Juice", "Whole Wheat Bread", "Free-Range Eggs", "Organic Baby Spinach", "Greek Yogurt"),
  Price = c(4500, 3200, 5800, 2500, 5000)
)

정답 코드

products_df <- data.frame(
  ProductID = 1:5,
  ProductName = c("Organic Apple Juice", "Whole Wheat Bread", "Free-Range Eggs", "Organic Baby Spinach", "Greek Yogurt"),
  Price = c(4500, 3200, 5800, 2500, 5000)
)

# grepl 함수를 사용하여 "Organic" 패턴이 포함되었는지 여부를 TRUE/FALSE로 확인
is_organic <- grepl("Organic", products_df$ProductName)

# TRUE인 행들만 선택하여 필터링
organic_products <- products_df[is_organic, ]

# 결과 확인
print(organic_products)

해설

  • grepl(pattern, x): 이 함수는 정규표현식(regular expression) pattern이 문자열 벡터 x의 각 요소에 포함되어 있는지를 검사하여, 포함되어 있으면 TRUE, 아니면 FALSE를 담은 논리(logical) 벡터를 반환합니다.
  • grepl("Organic", products_df$ProductName): ProductName 열의 각 상품명에 "Organic"이라는 문자열이 있는지 확인하여 c(TRUE, FALSE, FALSE, TRUE, FALSE)를 반환합니다.
  • products_df[is_organic, ]: 데이터 프레임의 행 인덱싱에 논리 벡터를 사용하면 TRUE에 해당하는 행들만 선택할 수 있습니다. 이를 **논리적 인덱싱(logical indexing)**이라고 하며, R에서 데이터를 필터링하는 가장 강력하고 기본적인 방법입니다.

156. 문자열 패턴 바꾸기 (단순 치환)

고객 주소 데이터에서 "서울특별시"를 "서울"로, "부산광역시"를 "부산"으로 줄여서 표기하고 싶습니다. 데이터의 일관성을 높이고 가독성을 향상시키기 위한 작업입니다.

과제: 아래 address_df 데이터 프레임의 City 열에서 "특별시"와 "광역시"라는 단어를 모두 제거하여 City_short라는 새 열에 저장하세요.

address_df <- data.frame(
  Name = c("Kim", "Lee", "Park"),
  City = c("서울특별시", "부산광역시", "서울특별시")
)

정답 코드

address_df <- data.frame(
  Name = c("Kim", "Lee", "Park"),
  City = c("서울특별시", "부산광역시", "서울특별시")
)

# gsub 함수를 사용하여 "특별시" 또는 "광역시"를 빈 문자열("")로 치환
address_df$City_short <- gsub("특별시|광역시", "", address_df$City)

# 결과 확인
print(address_df)

해설

  • gsub(pattern, replacement, x): 이 함수는 문자열 벡터 x의 각 요소에서 pattern에 해당하는 모든 부분을 찾아 replacement로 바꿉니다.
  • pattern = "특별시|광역시": 여기서 | (파이프)는 정규표현식에서 "OR"를 의미합니다. 즉, "특별시" 또는 "광역시" 둘 중 하나라도 일치하면 찾아냅니다.
  • replacement = "": 바꿀 내용으로 빈 문자열을 지정하면, 찾은 패턴을 삭제하는 효과를 가집니다.
  • gsub은 데이터 클리닝 과정에서 불필요한 문자(단위, 특수기호 등)를 제거하거나, 특정 단어를 표준화하는 데 매우 강력한 도구입니다.

157. 데이터 프레임을 탭으로 구분된 파일로 내보내기

분석한 유전체 데이터를 다른 생물정보학 분석 도구로 전달해야 합니다. 이 도구는 TSV (Tab-Separated Values) 형식의 파일을 입력으로 받습니다.

과제: R의 내장 데이터셋 mtcarsmtcars_output.tsv라는 이름의 탭으로 구분된 파일로 저장하세요. 행 이름은 저장하지 마세요.

정답 코드

# mtcars 데이터셋을 탭으로 구분된 파일로 저장
write.table(mtcars, "mtcars_output.tsv", sep = "\t", row.names = FALSE, quote = FALSE)

# 저장이 잘 되었는지 확인 (R에서 다시 읽어보기)
saved_data <- read.delim("mtcars_output.tsv")
print(head(saved_data))

해설

  • write.table(): write.csv()보다 더 일반적인 데이터 내보내기 함수입니다. 다양한 구분자를 지정할 수 있습니다.
  • sep = "\t": 구분자로 탭(\t)을 사용하도록 지정합니다.
  • row.names = FALSE: 행 이름을 저장하지 않습니다.
  • quote = FALSE: 문자열 값 주변에 따옴표(")를 붙이지 않도록 합니다. 많은 TSV 파일 형식에서 따옴표를 사용하지 않는 것을 선호하기 때문에, 이 옵션을 함께 사용하는 경우가 많습니다.

158. 문자열을 분리하여 여러 열로 만들기

직원 정보가 LastName, FirstName 형식의 하나의 열에 저장되어 있습니다. 분석을 위해 성(LastName)과 이름(FirstName)을 별개의 열로 분리해야 합니다.

과제: 아래 employees_df 데이터 프레임의 FullName 열을 쉼표(,)를 기준으로 분리하여 LastNameFirstName 열을 새로 만드세요. (힌트: strsplit 함수와 do.call, rbind 조합은 복잡하므로, 초급 단계에서는 tidyr 패키지의 separate 함수를 사용하는 것이 더 직관적입니다.)

employees_df <- data.frame(
  EmployeeID = 101:103,
  FullName = c("Smith, John", "Jones, Emily", "Williams, David")
)

정답 코드

# tidyr 패키지 설치 및 로드 (한 번만 설치하면 됩니다)
# install.packages("tidyr")
library(tidyr)

employees_df <- data.frame(
  EmployeeID = 101:103,
  FullName = c("Smith, John", "Jones, Emily", "Williams, David")
)

# separate 함수를 사용하여 열 분리
employees_separated_df <- separate(employees_df, col = FullName, into = c("LastName", "FirstName"), sep = ", ")

# 결과 확인
print(employees_separated_df)

해설

이 문제는 초급 수준을 약간 넘을 수 있지만, 매우 실용적인 텍스트 처리 작업입니다. tidyr 패키지는 데이터 정제를 매우 편리하게 만들어 줍니다.

  • library(tidyr): tidyr 패키지를 사용하기 위해 메모리에 로드합니다.
  • separate(data, col, into, sep): tidyr 패키지의 핵심 함수 중 하나입니다.
    • data: 대상 데이터 프레임 (employees_df).
    • col: 분리할 열의 이름 (FullName).
    • into: 분리 후 생성될 새 열들의 이름 벡터 (c("LastName", "FirstName")).
    • sep: 분리의 기준이 될 구분자 (", "). 쉼표와 공백을 함께 처리합니다.
  • tidyr와 같은 패키지를 사용하면 복잡한 기본 R 코드를 작성하지 않고도 직관적이고 가독성 높은 코드로 데이터 정제 작업을 수행할 수 있습니다.

159. 파일 경로와 파일명을 합쳐 완전한 경로 만들기

프로젝트 폴더 안에 data라는 하위 폴더가 있고, 그 안에 raw_data.csv 파일이 있습니다. 스크립트의 유연성을 위해 폴더 경로와 파일 이름을 변수로 관리하고, 이 둘을 합쳐 파일에 접근하려고 합니다.

과제: folder_path 변수와 file_name 변수에 각각 폴더 경로와 파일 이름이 저장되어 있습니다. 이 두 변수를 사용하여 운영체제(Windows, Mac, Linux)에 맞는 완전한 파일 경로를 생성하고 출력하세요.

folder_path <- "C:/my_project/data" # 예시 경로
file_name <- "raw_data.csv"

정답 코드

folder_path <- "C:/my_project/data" # Windows 예시, R에서는 / 사용 권장
# folder_path <- "/Users/my_user/my_project/data" # Mac/Linux 예시

file_name <- "raw_data.csv"

# file.path 함수를 사용하여 운영체제에 맞는 경로 생성
full_path <- file.path(folder_path, file_name)

# 생성된 경로 출력
print(full_path)

해설

  • file.path(...): 이 함수는 여러 문자열을 받아서 현재 실행 중인 운영체제(OS)에 맞는 파일 경로 구분자(/ 또는 \)를 사용하여 하나의 경로로 합쳐줍니다.
  • paste() 대신 file.path()를 써야 할까요?
    • 이식성(Portability): paste(folder_path, file_name, sep = "/")와 같이 직접 구분자를 지정하면, 이 코드는 Windows에서도 작동은 하지만 표준적인 방법이 아닙니다. file.path()를 사용하면 코드를 변경하지 않고도 Windows, macOS, Linux 등 모든 환경에서 올바르게 동작하는 경로를 만들 수 있습니다.
    • 가독성: 코드를 읽는 사람이 파일 경로를 다루고 있다는 것을 명확하게 알 수 있습니다.
  • 협업이나 다양한 환경에서 코드를 실행해야 하는 데이터 과학 프로젝트에서 file.path() 사용은 좋은 습관입니다.

160. 데이터 클리닝 종합: 단위 제거 및 숫자 변환

온라인 마켓의 상품 무게 데이터가 "5.2kg", "1200g", "2.5 kg" 처럼 숫자와 단위가 섞인 문자열로 되어 있습니다. 분석을 위해 이 데이터를 모두 kg 단위의 숫자로 통일해야 합니다.

과제: 아래 products_weight_df 데이터 프레임의 Weight 열을 다음과 같이 처리하여 Weight_kg라는 숫자형 열을 새로 만드세요.

  1. gsub()을 사용하여 "kg", "g", 그리고 공백을 제거합니다.
  2. ifelse()를 사용하여 원래 단위가 "g"이었던 값은 1000으로 나누어 kg으로 변환합니다.
  3. 최종 결과를 숫자형으로 변환합니다.
products_weight_df <- data.frame(
  Product = c("Rice", "Sugar", "Flour", "Salt"),
  Weight = c("5.2kg", "1200g", "2.5 kg", "500 g")
)

정답 코드

products_weight_df <- data.frame(
  Product = c("Rice", "Sugar", "Flour", "Salt"),
  Weight = c("5.2kg", "1200g", "2.5 kg", "500 g")
)

# 1. 단위를 포함하는지 여부 확인
contains_g <- grepl("g", products_weight_df$Weight)

# 2. 단위와 공백 제거하여 숫자 부분만 추출
weight_numeric_str <- gsub("kg|g| ", "", products_weight_df$Weight)

# 3. 문자열을 숫자로 변환
weight_numeric <- as.numeric(weight_numeric_str)

# 4. 'g' 단위를 가졌던 값들을 1000으로 나누어 kg으로 변환
products_weight_df$Weight_kg <- ifelse(contains_g, weight_numeric / 1000, weight_numeric)

# 결과 확인
print(products_weight_df)
str(products_weight_df)

해설

이 문제는 데이터 입출력 및 텍스트 처리 기술을 종합적으로 활용하는 실용적인 데이터 클리닝 예제입니다.

  1. grepl("g", ...): Weight 열에 'g'이 포함되어 있는지 여부를 TRUE/FALSE 벡터(contains_g)로 미리 저장합니다. 이 정보는 나중에 g을 kg으로 변환할 때 사용됩니다.
  2. gsub("kg|g| ", "", ...): gsub과 정규표현식 |(OR)를 사용하여 "kg" 또는 "g" 또는 " "(공백)을 모두 찾아 빈 문자열("")로 바꿉니다. 이 과정을 통해 "5.2", "1200", "2.5", "500" 과 같은 순수한 숫자 형태의 '문자열'을 얻습니다.
  3. as.numeric(...): gsub의 결과는 여전히 문자열이므로, 수학적 계산을 위해 as.numeric 함수로 숫자형으로 변환합니다.
  4. ifelse(condition, value_if_true, value_if_false):
    • condition: 1번 단계에서 만든 contains_g 논리 벡터를 사용합니다.
    • value_if_true: 해당 행이 'g'을 포함했었다면(TRUE), 숫자 값을 1000으로 나눕니다 (g -> kg).
    • value_if_false: 'g'을 포함하지 않았다면(FALSE, 즉 원래 kg 단위였다면), 숫자 값을 그대로 사용합니다.
  • 이처럼 여러 함수를 단계적으로 조합하여 복잡한 데이터 정제 문제를 해결하는 것은 데이터 분석가의 핵심 역량 중 하나입니다.

네, 알겠습니다. 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, Tidyverse의 dplyr 핵심 함수들을 중심으로 한 초급 9단계(161-180번) R 프로그래밍 문제들을 생성해 드리겠습니다. 각 문제는 흥미로운 시나리오와 함께 상세한 해설을 포함하고 있습니다.


161. 게임 캐릭터 필터링: 강력한 전사 선별하기

문제 상황: 당신은 'R의 왕국'이라는 게임의 밸런스 디자이너입니다. 캐릭터들의 능력치 데이터가 담긴 데이터프레임 characters가 있습니다. 다음 업데이트에서 공격력이 특정 수치 이상인 전사(Warrior) 캐릭터들에게 새로운 스킬을 부여하려고 합니다.

과제: characters 데이터프레임에서 attack (공격력)이 90을 초과하는 캐릭터들의 정보만 필터링하여 출력하세요.

# 라이브러리 로드 및 데이터 생성
library(dplyr)
library(tibble)

characters <- tribble(
  ~name,      ~class,     ~level, ~attack, ~defense,
  "Arin",     "Warrior",  50,     95,      80,
  "Luna",     "Mage",     52,     88,      60,
  "Borin",    "Warrior",  48,     89,      85,
  "Sylph",    "Archer",   51,     92,      65,
  "Grom",     "Warrior",  55,     105,     90
)

정답 코드

characters %>% 
  filter(attack > 90)

해설

이 문제는 dplyr 패키지의 가장 기본적인 함수 중 하나인 filter()를 사용하여 특정 조건을 만족하는 행(row)을 추출하는 방법을 다룹니다.

  1. library(dplyr): dplyr 패키지를 사용하기 위해 라이브러리를 로드합니다.
  2. characters %>% ...: 파이프 연산자(%>%)는 왼쪽의 객체(여기서는 characters 데이터프레임)를 오른쪽 함수의 첫 번째 인자로 전달합니다. 즉, filter(characters, attack > 90)과 동일한 의미이며, 코드를 더 읽기 쉽게 만들어 줍니다.
  3. filter(attack > 90): filter() 함수는 주어진 조건에 맞는 행들만 남깁니다. 여기서 조건은 attack 열의 값이 90보다 큰 경우입니다. filter는 각 행에 대해 attack > 90이라는 논리 조건(TRUE/FALSE)을 평가하고, TRUE인 행들만 선택하여 새로운 데이터프레임을 반환합니다.

결과적으로 Arin, Sylph, Grom 캐릭터의 정보가 출력됩니다. 이처럼 filter는 데이터에서 원하는 부분집합을 손쉽게 추출하는 데 매우 유용합니다.


162. 커피숍 주문 분석: 특정 시간대 VIP 주문 찾기

문제 상황: 당신은 'R-presso'라는 카페의 데이터 분석가입니다. 하루 동안의 모든 주문 기록이 orders 데이터프레임에 저장되어 있습니다. VIP 고객을 대상으로 한 마케팅을 위해, 10,000원 이상을 결제하고 오후 2시(14시) 이후에 주문한 기록을 찾고 싶습니다.

과제: orders 데이터프레임에서 price가 10000 이상이면서(>=) hour가 14 이상인(>=) 주문들만 필터링하세요.

# 데이터 생성
orders <- tribble(
  ~order_id, ~item,         ~price, ~hour,
  101,       "Latte",       5000,   9,
  102,       "Cake & Amer", 12000,  15,
  103,       "Smoothie",    7000,   16,
  104,       "Sandwich Set",15000,  12,
  105,       "Tea Set",     18000,  17
)

정답 코드

orders %>% 
  filter(price >= 10000 & hour >= 14)

해설

이 문제는 filter() 함수 내에서 여러 조건을 동시에 만족하는 데이터를 추출하는 방법을 보여줍니다.

  1. price >= 10000 & hour >= 14: filter() 함수 안에 두 가지 조건을 & (AND) 연산자로 연결했습니다.
    • price >= 10000: 가격이 10000원 이상인 주문을 찾습니다.
    • hour >= 14: 주문 시간이 14시 이후인 주문을 찾습니다.
    • & 연산자는 두 조건이 모두 TRUE일 때만 전체 결과가 TRUE가 되도록 합니다.

따라서 이 코드는 가격 조건과 시간 조건을 모두 만족하는 102번과 105번 주문 기록만을 추출합니다. 데이터 분석에서 여러 기준을 동시에 적용하여 특정 세그먼트를 찾아내는 것은 매우 흔한 작업입니다.


163. 우주 탐사선 데이터: 이상 신호 또는 고위험 지역 탐사 기록 찾기

문제 상황: 당신은 화성 탐사선 'Perseverance-R'의 비행 관제사입니다. 탐사선으로부터 수신된 센서 데이터가 sensor_logs에 저장되어 있습니다. 센서 상태(status)가 'Error'이거나, 탐사 지역의 위험도(risk_level)가 5 이상인 기록은 즉시 확인해야 합니다.

과제: sensor_logs 데이터프레임에서 status가 "Error" 이거나(|) risk_level이 5 이상인(>= 5) 모든 로그를 필터링하세요.

# 데이터 생성
sensor_logs <- tribble(
  ~log_id, ~timestamp, ~temperature, ~risk_level, ~status,
  "A01",   "14:00",    -25,          3,           "OK",
  "A02",   "14:05",    -26,          6,           "OK",
  "A03",   "14:10",    150,          4,           "Error",
  "A04",   "14:15",    -30,          2,           "OK",
  "A05",   "14:20",    -28,          7,           "Warning"
)

정답 코드

sensor_logs %>% 
  filter(status == "Error" | risk_level >= 5)

해설

이 문제는 filter() 함수에서 | (OR) 연산자를 사용하여 여러 조건 중 하나라도 만족하는 데이터를 추출하는 방법을 다룹니다.

  1. status == "Error": status 열의 값이 문자열 "Error"와 정확히 일치하는지 확인합니다. R에서 비교 연산자는 == (두 개)를 사용합니다.
  2. risk_level >= 5: risk_level 열의 값이 5 이상인지 확인합니다.
  3. |: OR 연산자는 연결된 조건 중 하나라도 TRUE이면 전체 결과가 TRUE가 되도록 합니다.

이 코드는 status가 "Error"인 로그(A03)와 risk_level이 5 이상인 로그(A02, A05)를 모두 포함하여 결과를 반환합니다. 이처럼 OR 조건을 사용하면 여러 종류의 중요한 이벤트를 한 번에 포착할 수 있습니다.


164. 고객 관리 시스템: 특정 부서 직원 목록 조회

문제 상황: 당신은 회사의 인사팀 직원입니다. 전체 직원 명단이 employees 데이터프레임에 있습니다. 이번에 'Sales' 부서와 'Marketing' 부서 직원들에게 보낼 공지사항이 있어, 해당 부서 직원들의 목록만 빠르게 확인해야 합니다.

과제: employees 데이터프레임에서 department가 "Sales" 또는 "Marketing"인 직원들을 필터링하세요. %in% 연산자를 사용해 보세요.

# 데이터 생성
employees <- tribble(
  ~emp_id, ~name,      ~department,
  "E01",   "Kim",      "Sales",
  "E02",   "Lee",      "Engineering",
  "E03",   "Park",     "Marketing",
  "E04",   "Choi",     "Sales",
  "E05",   "Jung",     "HR"
)

정답 코드

employees %>% 
  filter(department %in% c("Sales", "Marketing"))

해설

이 문제는 여러 특정 값 중 하나에 해당하는 데이터를 효율적으로 필터링하는 %in% 연산자의 사용법을 배웁니다.

  1. c("Sales", "Marketing"): c() 함수를 사용하여 우리가 찾고 싶은 부서 이름들의 벡터(목록)를 만듭니다.
  2. department %in% ...: %in% 연산자는 왼쪽의 값(각 행의 department 값)이 오른쪽의 벡터에 포함되어 있는지 확인합니다. 만약 포함되어 있다면 TRUE를, 아니면 FALSE를 반환합니다.

이 방법은 filter(department == "Sales" | department == "Marketing")과 동일한 결과를 내지만, 찾아야 할 값이 많아질수록(예: 10개 부서) %in%을 사용하는 것이 훨씬 간결하고 효율적입니다.


165. 학생 성적표 정리: 이름과 최종 점수만 보기

문제 상황: 한 학기 동안의 학생 성적 데이터가 gradebook에 있습니다. 이 데이터에는 중간고사, 기말고사, 과제 점수 등 여러 정보가 포함되어 있습니다. 학부모 상담을 위해 학생의 이름(student)과 최종 점수(final_score)만 깔끔하게 정리된 표가 필요합니다.

과제: gradebook 데이터프레임에서 studentfinal_score 열(column)만 선택하여 출력하세요.

# 데이터 생성
gradebook <- tribble(
  ~student_id, ~student, ~midterm, ~final_exam, ~homework, ~final_score,
  202301,      "Alice",  85,       92,          95,        91.5,
  202302,      "Bob",    78,       88,          90,        85.0,
  202303,      "Charlie",92,       95,          100,       96.5
)

정답 코드

gradebook %>% 
  select(student, final_score)

해설

이 문제는 dplyrselect() 함수를 사용하여 데이터프레임에서 원하는 열만 선택하는 방법을 보여줍니다. filter()가 행을 다루는 함수라면, select()는 열을 다루는 함수입니다.

  1. select(student, final_score): select() 함수 안에 원하는 열의 이름을 쉼표로 구분하여 나열합니다.
  2. dplyr은 따옴표 없이 열 이름을 바로 사용할 수 있어 코드가 간결해집니다 (Tidy evaluation).

select()를 사용하면 수십 개의 열이 있는 복잡한 데이터셋에서 분석에 필요한 핵심 정보만 추출하여 작업의 효율성을 높일 수 있습니다.


166. 센서 데이터 정제: 불필요한 메타데이터 열 제거하기

문제 상황: 실험실의 IoT 센서로부터 수집된 데이터 sensor_data가 있습니다. 이 데이터에는 실제 측정값 외에도 센서 ID, 펌웨어 버전 등 분석에 직접적으로 필요하지 않은 메타데이터 열들이 포함되어 있습니다. 데이터 분석 전, 이 불필요한 열들을 제거하여 데이터셋을 간소화하고 싶습니다.

과제: sensor_data 데이터프레임에서 sensor_idfirmware_version 열을 제외한 나머지 모든 열을 선택하세요.

# 데이터 생성
sensor_data <- tribble(
  ~timestamp, ~sensor_id, ~temperature, ~humidity, ~firmware_version,
  "10:00",    "T-01",     25.1,         45.5,      "v1.2.3",
  "10:01",    "H-05",     25.2,         45.8,      "v2.0.1",
  "10:02",    "T-01",     25.3,         45.6,      "v1.2.3"
)

정답 코드

sensor_data %>% 
  select(-sensor_id, -firmware_version)

해설

이 문제는 select() 함수에서 마이너스(-) 기호를 사용하여 특정 열을 제외하는 방법을 다룹니다.

  1. select(-column_name): select() 함수 내에서 열 이름 앞에 -를 붙이면 해당 열을 선택에서 제외하라는 의미가 됩니다.
  2. select(-sensor_id, -firmware_version): sensor_id 열과 firmware_version 열을 제외한 나머지 모든 열(timestamp, temperature, humidity)을 선택합니다.

이 방법은 남기고 싶은 열이 많고 제거하고 싶은 열이 적을 때 매우 유용합니다. select(timestamp, temperature, humidity)와 결과는 같지만, 코드는 더 간결해질 수 있습니다.


167. 설문조사 데이터 정리: 특정 패턴의 질문 열만 선택하기

문제 상황: 고객 만족도 설문조사 결과가 survey_results 데이터프레임에 저장되어 있습니다. 질문 문항은 q1_satisfaction, q2_recommend, q3_revisit 처럼 'q'로 시작하는 열 이름을 가집니다. 응답자의 기본 정보(id, age)를 제외하고 모든 질문 관련 열만 추출하고 싶습니다.

과제: survey_results 데이터프레임에서 이름이 "q"로 시작하는 모든 열을 선택하세요.

# 데이터 생성
survey_results <- tribble(
  ~id, ~age, ~q1_satisfaction, ~q2_recommend, ~q3_revisit,
  1,   34,   5,                5,             4,
  2,   28,   4,                4,             4,
  3,   45,   5,                4,             3
)

정답 코드

survey_results %>% 
  select(starts_with("q"))

해설

이 문제는 select() 함수와 함께 사용되는 'selection helper' 함수 중 하나인 starts_with()를 소개합니다. 이러한 헬퍼 함수들은 특정 패턴을 가진 열들을 쉽게 선택할 수 있게 해줍니다.

  1. starts_with("q"): select() 함수 내에서 사용되며, 열 이름이 문자열 "q"로 시작하는 모든 열을 선택하라는 규칙을 정의합니다.

starts_with() 외에도 유용한 헬퍼 함수들이 있습니다:

  • ends_with(): 특정 문자열로 끝나는 열 선택
  • contains(): 특정 문자열을 포함하는 열 선택
  • matches(): 정규 표현식에 맞는 열 선택
  • everything(): 모든 열 선택 (주로 순서 변경에 사용)

이러한 헬퍼 함수들은 특히 열이 수십, 수백 개에 달하는 넓은(wide) 데이터셋을 다룰 때 코드의 유연성과 가독성을 크게 향상시킵니다.


168. 재무 보고서용 데이터 재배열: 중요 열 앞으로 가져오기

문제 상황: 분기별 판매 기록 데이터 sales_records가 있습니다. 보고서를 작성하기 위해, 가장 중요한 정보인 date, product_name, revenue를 맨 앞으로 가져오고, 나머지 정보(예: region, sales_rep_id)는 그 뒤에 오도록 열의 순서를 바꾸고 싶습니다.

과제: sales_records 데이터프레임의 열 순서를 date, product_name, revenue가 먼저 오고, 그 다음에 나머지 모든 열이 오도록 재배열하세요.

# 데이터 생성
sales_records <- tribble(
  ~region, ~sales_rep_id, ~date,        ~product_name, ~units_sold, ~revenue,
  "East",  "SR02",        "2023-01-15", "Widget A",    100,         10000,
  "West",  "SR05",        "2023-01-16", "Widget B",    50,          7500,
  "East",  "SR02",        "2023-01-17", "Widget C",    120,         12000
)

정답 코드

sales_records %>% 
  select(date, product_name, revenue, everything())

해설

이 문제는 select() 함수를 사용하여 단순히 열을 선택하는 것뿐만 아니라, 열의 순서를 재배열하는 방법을 보여줍니다.

  1. select(date, product_name, revenue, ...): select() 함수에 열 이름을 나열하는 순서가 바로 새로운 데이터프레임의 열 순서가 됩니다.
  2. everything(): 이 헬퍼 함수는 select() 내에서 "아직 명시되지 않은 나머지 모든 열"을 의미합니다. 따라서 date, product_name, revenue를 먼저 명시하고 everything()을 뒤에 붙이면, 이 세 열이 맨 앞으로 오고 나머지 열들(region, sales_rep_id, units_sold)이 원래 순서대로 뒤따라오게 됩니다.

이 기능은 분석 보고서나 시각화를 위해 데이터의 가독성을 높이고 싶을 때 매우 유용합니다.


169. 온라인 쇼핑몰 판매 데이터: 총 판매액 계산하기

문제 상황: 당신은 온라인 쇼핑몰의 데이터 분석가입니다. 고객의 장바구니에 담긴 상품 목록 cart_items 데이터가 있습니다. 각 상품의 단가(unit_price)와 수량(quantity)은 있지만, 상품별 총 판매액(total_price)은 없습니다. 분석을 위해 이 값을 계산하여 새로운 열로 추가해야 합니다.

과제: cart_items 데이터프레임에 unit_pricequantity를 곱하여 total_price라는 새로운 열을 추가하세요.

# 데이터 생성
cart_items <- tribble(
  ~product_id, ~product_name, ~unit_price, ~quantity,
  "P001",      "Keyboard",    30000,       2,
  "P002",      "Mouse",       15000,       3,
  "P003",      "Monitor",     250000,      1
)

정답 코드

cart_items %>% 
  mutate(total_price = unit_price * quantity)

해설

이 문제는 dplyrmutate() 함수를 사용하여 기존 열들을 바탕으로 새로운 열을 생성하는 방법을 다룹니다. mutate()는 데이터 변환 및 특성 공학(feature engineering)의 핵심 함수입니다.

  1. mutate(...): 기존 데이터프레임에 새로운 열을 추가하거나 기존 열을 수정하여 새로운 데이터프레임을 반환합니다.
  2. total_price = unit_price * quantity: mutate() 함수 안에 새로운_열_이름 = 계산식 형태로 코드를 작성합니다. 여기서는 total_price라는 이름의 새 열을 만들고, 그 값은 기존의 unit_price 열과 quantity 열의 값을 곱한 결과로 채워집니다. 이 계산은 각 행별로 수행됩니다.

mutate()를 사용하면 데이터에 없는 파생 변수(derived variables)를 손쉽게 만들 수 있어 더 깊이 있는 분석을 가능하게 합니다.


170. 학생 성적 분류: 합격/불합격 여부 표시하기

문제 상황: 기말고사 성적표 exam_scores가 있습니다. 60점 이상이면 'Pass', 60점 미만이면 'Fail'로 분류하여 결과를 나타내는 status 열을 추가하고 싶습니다.

과제: exam_scores 데이터프레임에 score가 60 이상이면 "Pass", 아니면 "Fail" 값을 갖는 status라는 새 열을 추가하세요. if_else() 함수를 사용해 보세요.

# 데이터 생성
exam_scores <- tribble(
  ~student, ~score,
  "Daniel", 85,
  "Emily",  58,
  "Frank",  92,
  "Grace",  60
)

정답 코드

exam_scores %>% 
  mutate(status = if_else(score >= 60, "Pass", "Fail"))

해설

이 문제는 mutate() 함수 내에서 조건에 따라 다른 값을 부여하는 if_else() 함수의 사용법을 배웁니다.

  1. if_else(condition, value_if_true, value_if_false): dplyr에서 제공하는 if_else() 함수는 세 개의 인자를 받습니다.
    • condition: 평가할 논리 조건 (여기서는 score >= 60).
    • value_if_true: 조건이 TRUE일 때 반환할 값 ("Pass").
    • value_if_false: 조건이 FALSE일 때 반환할 값 ("Fail").
  2. mutate(status = ...): if_else()의 결과를 status라는 새로운 열에 할당합니다. 이 함수는 각 행의 score 값을 확인하여 해당 행에 "Pass" 또는 "Fail"을 부여합니다.

if_else()는 기본 R의 ifelse()보다 더 빠르고 엄격한 타입 체크를 제공하여 예측 가능한 결과를 보장합니다. 데이터를 특정 카테고리로 분류하는 작업에 매우 유용합니다.


171. 고객 정보 통합: 성과 이름 합쳐서 전체 이름 만들기

문제 상황: 고객 데이터 customers에 성(last_name)과 이름(first_name)이 별개의 열로 저장되어 있습니다. 고객에게 이메일을 보낼 때 "Dear [전체 이름]," 형식으로 사용하기 위해, 두 열을 합쳐 full_name 열을 만들고 싶습니다.

과제: customers 데이터프레임에 first_namelast_name을 공백(" ")으로 연결하여 full_name 열을 생성하세요. (예: "Gildong Hong")

# 데이터 생성
customers <- tribble(
  ~customer_id, ~last_name, ~first_name,
  101,          "Hong",     "Gildong",
  102,          "Kim",      "Chulsoo",
  103,          "Lee",      "Younghee"
)

정답 코드

library(stringr) # str_c 함수를 위해 로드 (선택사항, paste0도 가능)

customers %>% 
  mutate(full_name = str_c(first_name, last_name, sep = " "))
  
# 또는 기본 R 함수 사용
# customers %>% 
#   mutate(full_name = paste(first_name, last_name, sep = " "))

해설

이 문제는 여러 문자열 열을 하나로 합치는 방법을 다룹니다. Tidyverse 생태계에서는 stringr 패키지의 str_c() 함수를, 기본 R에서는 paste() 함수를 주로 사용합니다.

  1. mutate(full_name = ...): full_name이라는 새 열을 만듭니다.
  2. str_c(first_name, last_name, sep = " "): str_c() 함수는 여러 문자열을 순서대로 합칩니다.
    • first_name, last_name: 합치고 싶은 열들을 순서대로 나열합니다.
    • sep = " ": 각 문자열 사이에 넣을 구분자(separator)를 지정합니다. 여기서는 공백 한 칸을 넣습니다.
  3. paste(first_name, last_name, sep = " "): paste() 함수도 동일한 역할을 합니다. sep 인자로 구분자를 지정합니다. (paste0sep=""paste의 단축형입니다.)

이러한 문자열 조합은 주소 만들기, 로그 메시지 생성, 식별자 조합 등 데이터 전처리 과정에서 빈번하게 사용됩니다.


172. 웹사이트 로그 분석: 클릭률(CTR) 계산하기

문제 상황: 당신은 웹사이트의 광고 성과를 분석하고 있습니다. 광고별 노출 수(impressions)와 클릭 수(clicks) 데이터가 ad_performance에 있습니다. 광고의 효율성을 나타내는 주요 지표인 클릭률(CTR, Click-Through Rate)을 계산하여 새로운 열로 추가해야 합니다.

클릭률(CTR)은 (클릭 수 / 노출 수) * 100 으로 계산합니다.

과제: ad_performance 데이터프레임에 클릭률을 계산하여 ctr이라는 이름의 새 열로 추가하세요.

# 데이터 생성
ad_performance <- tribble(
  ~ad_id, ~campaign,  ~impressions, ~clicks,
  "A01",  "Summer",   10000,        300,
  "A02",  "Summer",   12000,        350,
  "B01",  "Winter",   25000,        500
)

정답 코드

ad_performance %>% 
  mutate(ctr = (clicks / impressions) * 100)

해설

이 문제는 mutate()를 사용하여 비즈니스에 중요한 KPI(핵심 성과 지표)를 계산하는 실용적인 예시입니다.

  1. mutate(ctr = ...): ctr이라는 이름의 새 열을 생성합니다.
  2. (clicks / impressions) * 100: CTR의 수학적 정의를 코드로 그대로 옮겼습니다.
    • clicks / impressions: 각 광고(행)의 clicks 값을 impressions 값으로 나눕니다.
    • * 100: 결과를 백분율로 만들기 위해 100을 곱합니다.

수학적/통계적 공식은 다음과 같습니다. $$ \text{CTR} (%) = \frac{\text{Number of Clicks}}{\text{Number of Impressions}} \times 100 $$

이처럼 mutate()를 사용하면 기존 데이터를 바탕으로 복잡한 비즈니스 로직이나 통계 지표를 손쉽게 계산하고 데이터에 풍부한 정보를 더할 수 있습니다.


173. e스포츠 선수 랭킹: 점수 기준 오름차순 정렬

문제 상황: e스포츠 대회의 최종 점수표 player_scores가 있습니다. 공식 랭킹을 발표하기 위해, 선수들을 점수(score)가 낮은 순서부터 높은 순서로(오름차순) 정렬해야 합니다.

과제: player_scores 데이터프레임을 score 열을 기준으로 오름차순으로 정렬하세요.

# 데이터 생성
player_scores <- tribble(
  ~player, ~score, ~country,
  "Faker", 950,    "KR",
  "Rookie",920,    "CN",
  "Caps",  935,    "EU",
  "Perkz", 890,    "NA"
)

정답 코드

player_scores %>% 
  arrange(score)

해설

이 문제는 dplyrarrange() 함수를 사용하여 데이터프레임을 특정 열의 값에 따라 정렬하는 방법을 다룹니다.

  1. arrange(score): arrange() 함수에 정렬 기준으로 사용할 열의 이름을 전달합니다.
  2. 기본적으로 arrange()는 **오름차순(ascending order)**으로 정렬합니다. 즉, 가장 작은 값이 맨 위에 오고 가장 큰 값이 맨 아래에 오게 됩니다.

결과적으로 Perkz(890), Rookie(920), Caps(935), Faker(950) 순서로 데이터가 재정렬됩니다. 데이터 정렬은 순위를 매기거나, 시간 순서대로 이벤트를 보거나, 특정 패턴을 시각적으로 확인하는 데 필수적인 전처리 단계입니다.


174. 블로그 포스트 관리: 최신 글 순서로 정렬하기

문제 상황: 블로그 관리 시스템에서 모든 포스트의 목록을 blog_posts 데이터프레임으로 가지고 있습니다. 관리자 페이지에서는 가장 최근에 작성된 글이 맨 위에 보이도록 정렬해야 합니다.

과제: blog_posts 데이터프레임을 publish_date 열을 기준으로 최신 날짜가 가장 먼저 오도록(내림차순) 정렬하세요.

# 데이터 생성
blog_posts <- tribble(
  ~post_id, ~title,               ~publish_date,
  1,        "Intro to R",         "2023-01-10",
  2,        "Data Visualization", "2023-03-15",
  3,        "Tidyverse Guide",    "2023-02-20"
)

정답 코드

blog_posts %>% 
  arrange(desc(publish_date))

해설

이 문제는 arrange() 함수와 desc() 헬퍼 함수를 함께 사용하여 데이터를 내림차순(descending order)으로 정렬하는 방법을 보여줍니다.

  1. desc(publish_date): desc() 함수는 arrange() 내에서 사용되며, 지정된 열(publish_date)을 기준으로 내림차순 정렬을 하도록 지시합니다. 즉, 가장 큰 값(가장 최신 날짜)이 맨 위로 오게 됩니다.
  2. arrange(...): 이 지시에 따라 데이터프레임의 행 순서를 재배열합니다.

결과적으로 2023-03-15, 2023-02-20, 2023-01-10 순서로 포스트가 정렬됩니다. desc()는 숫자, 날짜, 문자열 등 순서를 정할 수 있는 모든 데이터 타입에 적용할 수 있습니다.


175. 도서관 도서 목록 정렬: 장르별, 제목별 정렬

문제 상황: 도서관의 도서 목록 library_books가 있습니다. 이용자들이 책을 쉽게 찾을 수 있도록, 목록을 먼저 장르(genre)의 알파벳 순으로 정렬하고, 같은 장르 내에서는 다시 책 제목(title)의 알파벳 순으로 정렬하고 싶습니다.

과제: library_books 데이터프레임을 genre로 먼저 오름차순 정렬하고, 그 다음 title로 오름차순 정렬하세요.

# 데이터 생성
library_books <- tribble(
  ~title,           ~author,    ~genre,
  "The Hobbit",     "Tolkien",  "Fantasy",
  "A-R-Pro",        "Hadley",   "Computer Science",
  "Dune",           "Herbert",  "Sci-Fi",
  "R for DS",       "Hadley",   "Computer Science",
  "Neuromancer",    "Gibson",   "Sci-Fi"
)

정답 코드

library_books %>% 
  arrange(genre, title)

해설

이 문제는 arrange() 함수에 여러 개의 열을 전달하여 다중 조건으로 정렬하는 방법을 다룹니다.

  1. arrange(genre, title): arrange()에 열 이름을 쉼표로 구분하여 여러 개 나열하면, R은 왼쪽에서 오른쪽 순서로 정렬 기준을 적용합니다.
    • 1차 정렬: 먼저 genre 열을 기준으로 전체 데이터를 오름차순 정렬합니다. ('Computer Science', 'Fantasy', 'Sci-Fi' 순)
    • 2차 정렬: genre 값이 동일한 그룹 내에서, title 열을 기준으로 다시 오름차순 정렬합니다. 예를 들어, 'Computer Science' 장르 내에서 "A-R-Pro"가 "R for DS"보다 먼저 오고, 'Sci-Fi' 장르 내에서 "Dune"이 "Neuromancer"보다 먼저 옵니다.

이처럼 다중 정렬은 계층적인 구조를 가진 데이터를 논리적으로 정리할 때 매우 유용합니다.


176. 온라인 상점 재고 관리: 특정 카테고리 상품 정보만 추출

문제 상황: 당신은 온라인 상점의 재고 관리 담당자입니다. 전체 상품 목록 products 데이터에서 'Electronics' 카테고리에 속하는 상품들의 이름(product_name), 가격(price), 재고 수량(stock) 정보만 보고 싶습니다.

과제: products 데이터프레임에서 category가 "Electronics"인 상품들만 필터링한 후, product_name, price, stock 열만 선택하여 출력하세요.

# 데이터 생성
products <- tribble(
  ~product_id, ~product_name, ~category,     ~price, ~stock,
  "P01",       "Laptop",      "Electronics", 1500000, 30,
  "P02",       "T-shirt",     "Apparel",     25000,   100,
  "P03",       "Smartphone",  "Electronics", 1200000, 50,
  "P04",       "Book",        "Books",       18000,   200
)

정답 코드

products %>% 
  filter(category == "Electronics") %>% 
  select(product_name, price, stock)

해설

이 문제는 dplyr의 여러 동사(verb)를 파이프(%>%)로 연결하여 순차적인 데이터 처리 파이프라인을 만드는 과정을 보여줍니다. 이는 Tidyverse 워크플로우의 핵심입니다.

  1. products %>% ...: products 데이터로 시작합니다.
  2. filter(category == "Electronics"): 첫 번째 단계로, category가 "Electronics"인 행들만 남깁니다. 이 결과로 Laptop과 Smartphone 정보만 담긴 임시 데이터프레임이 생성됩니다.
  3. %>% ...: filter의 결과가 다음 함수인 select로 전달됩니다.
  4. select(product_name, price, stock): 두 번째 단계로, 전달받은 데이터프레임에서 product_name, price, stock 세 개의 열만 선택합니다.

이처럼 여러 단계를 파이프로 연결하면, 중간 결과를 변수에 저장할 필요 없이 논리적인 순서대로 코드를 작성할 수 있어 가독성이 매우 높아집니다.


177. 레스토랑 메뉴 분석: 전체 메뉴의 평균 가격 계산하기

문제 상황: 레스토랑의 전체 메뉴와 가격 정보가 menu 데이터프레임에 있습니다. 레스토랑의 전반적인 가격 수준을 파악하기 위해 모든 메뉴의 평균 가격을 계산하고 싶습니다.

과제: menu 데이터프레임의 price 열의 평균값을 계산하세요. summarize() 함수를 사용해야 합니다.

# 데이터 생성
menu <- tribble(
  ~item,      ~category, ~price,
  "Steak",    "Main",    35000,
  "Pasta",    "Main",    22000,
  "Salad",    "Appetizer",15000,
  "Wine",     "Beverage", 8000
)

정답 코드

menu %>% 
  summarize(average_price = mean(price))

해설

이 문제는 데이터를 요약하는 dplyr의 핵심 함수 summarize()(또는 summarise())를 소개합니다. summarize()는 여러 행의 데이터를 하나의 요약된 값으로 축소시킵니다.

  1. summarize(...): 이 함수는 데이터프레임 전체를 하나의 그룹으로 보고, 지정된 요약 통계를 계산하여 단일 행으로 된 새로운 데이터프레임을 만듭니다.
  2. average_price = mean(price): summarize() 내에서 새로운_요약열_이름 = 요약함수(대상열) 형태로 사용합니다.
    • mean(price): price 열의 모든 값에 대해 평균을 계산하는 R의 기본 함수입니다.
    • average_price = ...: 계산된 평균값을 average_price라는 이름의 열에 저장합니다.

결과적으로, average_price라는 단일 열과 단일 행을 가진 tibble이 생성됩니다. summarize는 평균, 합계, 개수 등 데이터셋의 전반적인 특성을 파악하는 데 필수적입니다.


178. 도시별 기온 데이터 요약: 평균, 표준편차, 관측 횟수 계산

문제 상황: 여러 도시의 일일 최고 기온 데이터 daily_temps가 있습니다. 각 도시의 기온 데이터에 대한 기본적인 통계치를 요약하여 리포트를 작성해야 합니다.

과제: daily_temps 데이터프레임에 대해, temp 열의 평균(mean), 표준편차(standard deviation), 그리고 총 관측 횟수(number of observations)를 한 번에 계산하세요. 새 열의 이름은 각각 avg_temp, sd_temp, num_obs로 지정하세요.

# 데이터 생성
daily_temps <- tribble(
  ~city,   ~date,        ~temp,
  "Seoul", "2023-08-01", 33,
  "Seoul", "2023-08-02", 35,
  "Busan", "2023-08-01", 30,
  "Seoul", "2023-08-03", 32,
  "Busan", "2023-08-02", 31,
  "Busan", "2023-08-03", 32
)

정답 코드

daily_temps %>% 
  summarize(
    avg_temp = mean(temp),
    sd_temp = sd(temp),
    num_obs = n()
  )

해설

이 문제는 summarize() 함수 안에서 여러 개의 요약 통계치를 동시에 계산하는 방법을 보여줍니다.

  1. summarize(...): summarize() 함수 안에 쉼표로 구분하여 여러 요약식을 나열할 수 있습니다.
  2. avg_temp = mean(temp): temp 열의 평균을 계산합니다.
  3. sd_temp = sd(temp): temp 열의 표준편차를 계산합니다. 표준편차($\sigma$)는 데이터가 평균으로부터 얼마나 퍼져 있는지를 나타내는 척도입니다. $$ \sigma = \sqrt{\frac{\sum_{i=1}^{N}(x_i - \mu)^2}{N}} $$ (R의 sd 함수는 표본 표준편차를 계산하므로 분모가 $N-1$입니다.)
  4. num_obs = n(): n()dplyr에서 제공하는 특별한 함수로, 현재 그룹의 행 개수를 세어줍니다. 여기서는 group_by가 없으므로 전체 데이터프레임의 행 개수를 반환합니다.

이처럼 summarize()를 사용하면 데이터셋의 분포 특성을 나타내는 여러 기술 통계량(descriptive statistics)을 효율적으로 한 번에 계산할 수 있습니다.


179. 카페 메뉴 그룹 분석: 카테고리별 평균 가격 계산하기

문제 상황: 'R-presso' 카페의 메뉴 menu 데이터가 있습니다. 각 메뉴는 'Coffee', 'Tea', 'Bakery' 등의 카테고리로 나뉩니다. 카테고리별로 가격 정책이 적절한지 분석하기 위해, 각 카테고리의 평균 메뉴 가격을 계산해야 합니다.

과제: menu 데이터프레임을 category별로 그룹화한 뒤, 각 카테고리의 평균 price를 계산하세요.

# 데이터 생성
menu <- tribble(
  ~item,         ~category, ~price,
  "Americano",   "Coffee",  4500,
  "Latte",       "Coffee",  5000,
  "Green Tea",   "Tea",     4800,
  "Croissant",   "Bakery",  3500,
  "Earl Grey",   "Tea",     4800,
  "Scone",       "Bakery",  4000
)

정답 코드

menu %>% 
  group_by(category) %>% 
  summarize(avg_price = mean(price))

해설

이 문제는 dplyr의 가장 강력한 기능 중 하나인 group_by()summarize()의 조합을 보여줍니다. 이는 "Split-Apply-Combine" 전략을 구현하는 핵심적인 방법입니다.

  1. group_by(category): 이 함수는 데이터프레임을 category 열의 고유한 값('Bakery', 'Coffee', 'Tea')에 따라 여러 하위 그룹으로 나눕니다. 이 단계에서는 데이터가 눈에 띄게 변하지는 않지만, 이후의 dplyr 함수들이 각 그룹에 대해 독립적으로 작동하도록 만드는 '메타데이터'를 추가합니다.
  2. summarize(avg_price = mean(price)): group_by() 뒤에 오는 summarize()는 전체 데이터가 아닌, 각 그룹별로 요약 통계를 계산합니다.
    • 'Bakery' 그룹의 price(3500, 4000)에 대해 mean()을 계산합니다.
    • 'Coffee' 그룹의 price(4500, 5000)에 대해 mean()을 계산합니다.
    • 'Tea' 그룹의 price(4800, 4800)에 대해 mean()을 계산합니다.
  3. 최종 결과는 각 그룹(category)당 하나의 행을 가지며, 그룹 식별자와 해당 그룹의 요약 통계치(avg_price)를 포함하는 새로운 데이터프레임이 됩니다.

group_by()는 데이터 분석에서 세그먼트별 비교 분석을 수행하는 데 있어 가장 기본적이고 중요한 도구입니다.


180. 월별 최고 판매 도서 찾기: 데이터 분석 파이프라인 완성하기

문제 상황: 서점의 월별 도서 판매량 데이터 book_sales가 있습니다. 각 월별로 가장 많이 팔린 책(Top Seller)을 찾아 마케팅 부서에 전달해야 합니다.

과제: book_sales 데이터를 사용하여 다음 단계를 순서대로 수행하는 dplyr 파이프라인을 만드세요.

  1. month별로 그룹화합니다.
  2. 각 그룹(월) 내에서 판매량(units_sold)이 가장 높은 책을 찾습니다. (힌트: filter(units_sold == max(units_sold)))
  3. 결과를 month 순으로 정렬합니다.
# 데이터 생성
book_sales <- tribble(
  ~month, ~title,           ~units_sold,
  1,      "R for DS",       150,
  1,      "The Hobbit",     120,
  2,      "Dune",           200,
  2,      "R for DS",       180,
  3,      "The Hobbit",     250,
  3,      "Dune",           220
)

정답 코드

book_sales %>%
  group_by(month) %>%
  filter(units_sold == max(units_sold)) %>%
  ungroup() %>% # 좋은 습관: 그룹화 해제
  arrange(month)

해설

이 문제는 지금까지 배운 group_by, filter, arrange를 모두 활용하여 실제 분석 과제와 유사한 다단계 데이터 처리 파이프라인을 구축하는 종합 문제입니다.

  1. group_by(month): 데이터를 1월, 2월, 3월 그룹으로 나눕니다.
  2. filter(units_sold == max(units_sold)): 이 부분이 핵심입니다. group_by가 적용된 상태에서 filter를 사용하면, max(units_sold)와 같은 요약 함수가 각 그룹별로 계산됩니다.
    • 1월 그룹 내에서 max(units_sold)는 150이므로, units_sold가 150인 "R for DS" 행이 선택됩니다.
    • 2월 그룹 내에서 max(units_sold)는 200이므로, units_sold가 200인 "Dune" 행이 선택됩니다.
    • 3월 그룹 내에서 max(units_sold)는 250이므로, units_sold가 250인 "The Hobbit" 행이 선택됩니다.
  3. ungroup(): group_by로 설정된 그룹 정보를 해제합니다. 이후의 작업이 그룹의 영향을 받지 않도록 하는 좋은 습관입니다. 이 문제에서는 arrange가 그룹에 영향을 받지 않지만, 복잡한 파이프라인에서는 예기치 않은 오류를 방지해 줍니다.
  4. arrange(month): 최종 결과를 month 순서대로 정렬하여 보고하기 좋게 만듭니다.

이처럼 dplyr 동사들을 논리적으로 연결하면, 복잡해 보이는 데이터 분석 과제도 명확하고 읽기 쉬운 코드로 해결할 수 있습니다.

네, 알겠습니다. 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 사용자의 학습 여정을 돕기 위해 Tidyverse의 tidyr 패키지에 초점을 맞춘 초급 10단계 문제들을 생성하겠습니다. 특히 pivot_longer()pivot_wider()를 중심으로 데이터 정돈(Tidying)의 핵심 개념을 익힐 수 있도록 흥미롭고 실용적인 예제들을 준비했습니다.


181. 카페 분기별 메뉴 판매량 - 넓은 형식에서 긴 형식으로 바꾸기 (기초)

문제 상황: 당신은 'R 카페'의 데이터 분석가입니다. 매장 관리자가 2023년 분기별 주요 메뉴 판매량 데이터를 엑셀 파일 형태로 전달했습니다. 데이터가 각 분기별로 열이 나뉘어 있어(넓은 형식, Wide Format), 메뉴별 연간 판매 추이를 분석하기 어려운 상태입니다.

# 제공된 데이터
menu_sales_wide <- tibble::tribble(
  ~menu,         ~Q1,  ~Q2,  ~Q3,  ~Q4,
  "Americano",   520,  450,  550,  680,
  "Caffe Latte", 480,  420,  500,  620,
  "Green Tea",   150,  180,  140,  110
)

과제: pivot_longer() 함수를 사용하여 menu_sales_wide 데이터를 '긴 형식(Long Format)'으로 변환하세요. 결과 데이터는 menu, quarter, sales 세 개의 열을 가져야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
menu_sales_wide <- tibble::tribble(
  ~menu,         ~Q1,  ~Q2,  ~Q3,  ~Q4,
  "Americano",   520,  450,  550,  680,
  "Caffe Latte", 480,  420,  500,  620,
  "Green Tea",   150,  180,  140,  110
)

# 넓은 형식을 긴 형식으로 변환
menu_sales_long <- menu_sales_wide %>%
  pivot_longer(
    cols = c(Q1, Q2, Q3, Q4),
    names_to = "quarter",
    values_to = "sales"
  )

print(menu_sales_long)

해설

이 문제는 pivot_longer()의 가장 기본적인 사용법을 보여줍니다. 'Tidy Data'의 원칙 중 하나는 "각 관측치는 행을, 각 변수는 열을 구성해야 한다"는 것입니다. 기존 데이터에서 '판매량'이라는 단일 관측치가 Q1, Q2, Q3, Q4라는 여러 열에 흩어져 있었습니다.

  • cols = c(Q1, Q2, Q3, Q4): 변환할 열들을 명시적으로 지정합니다. cols = -menu 와 같이 고정할 열을 제외하는 방식으로도 지정할 수 있습니다.
  • names_to = "quarter": 기존의 열 이름(Q1, Q2, Q3, Q4)들이 들어갈 새로운 열의 이름을 "quarter"로 지정합니다. 이 열은 '어떤 분기의 판매량인가'라는 정보를 담는 변수가 됩니다.
  • values_to = "sales": 기존 열들에 있던 값(판매량)들이 들어갈 새로운 열의 이름을 "sales"로 지정합니다.

이렇게 긴 형식으로 데이터를 변환하면 ggplot2를 사용하여 메뉴별, 분기별 판매량 추이 그래프를 그리거나 dplyr을 사용하여 메뉴별 평균 판매량을 계산하는 등 후속 분석이 훨씬 용이해집니다.


182. 학생별 과목 성적 데이터 정돈하기

문제 상황: 한 학급의 학생별 과목 성적 데이터가 있습니다. 각 과목이 별도의 열로 구성되어 있어, 모든 학생의 전체 과목 평균을 계산하거나 특정 점수대의 학생 분포를 파악하기가 번거롭습니다.

# 제공된 데이터
student_scores_wide <- tibble::tribble(
  ~student_id, ~student_name, ~math, ~english, ~science,
  101,         "Alice",       85,    92,       78,
  102,         "Bob",         90,    88,       95,
  103,         "Charlie",     76,    85,       89
)

과제: pivot_longer()를 사용하여 student_scores_wide 데이터를 긴 형식으로 변환하세요. 학생 정보(student_id, student_name)는 그대로 유지하고, 과목과 점수를 별도의 열로 분리해야 합니다. 최종 데이터는 student_id, student_name, subject, score 열을 포함해야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
student_scores_wide <- tibble::tribble(
  ~student_id, ~student_name, ~math, ~english, ~science,
  101,         "Alice",       85,    92,       78,
  102,         "Bob",         90,    88,       95,
  103,         "Charlie",     76,    85,       89
)

# 데이터 정돈
student_scores_long <- student_scores_wide %>%
  pivot_longer(
    cols = math:science,
    names_to = "subject",
    values_to = "score"
  )

print(student_scores_long)

해설

이 문제는 여러 개의 식별자 열(student_id, student_name)이 있을 때 pivot_longer()를 사용하는 방법을 다룹니다.

  • cols = math:science: 변환할 열들을 dplyr의 선택 헬퍼(select helper)인 :를 사용하여 math 열부터 science 열까지 모두 선택했습니다. 이는 c(math, english, science)와 동일한 결과를 낳지만 코드가 더 간결합니다. cols = -c(student_id, student_name) 와 같이 식별자 열을 제외하는 방식으로도 지정할 수 있습니다.
  • names_to = "subject": 기존 열 이름(math, english, science)이 들어갈 새 열의 이름을 "subject"로 지정했습니다.
  • values_to = "score": 해당 과목의 점수 값이 들어갈 새 열의 이름을 "score"로 지정했습니다.

pivot_longer()cols에 명시되지 않은 열들(student_id, student_name)을 식별자(identifier)로 자동 인식하고, 변환된 모든 행에 해당 값을 그대로 복제하여 유지해줍니다. 이 덕분에 각 점수가 어떤 학생의 것인지 정보를 잃지 않고 데이터를 정돈할 수 있습니다.


183. 게임 캐릭터 스탯 데이터 재구성하기

문제 상황: 당신은 게임 개발팀의 데이터 분석가입니다. 게임 캐릭터들의 초기 스탯 데이터가 아래와 같이 '넓은 형식'으로 저장되어 있습니다. 각 스탯(stat)의 분포나 스탯 간의 관계를 분석하려면 데이터를 '긴 형식'으로 바꾸는 것이 효율적입니다.

# 제공된 데이터
character_stats_wide <- tibble::tribble(
  ~class,    ~str, ~dex, ~int, ~luk,
  "Warrior", 10,   4,    3,    5,
  "Mage",    3,    5,    10,   4,
  "Archer",  5,    10,   4,    3
)

과제: pivot_longer()를 사용하여 character_stats_wide 데이터를 긴 형식으로 변환하세요. class 열은 식별자로 유지하고, 스탯의 종류와 값을 담는 stat_namestat_value 열을 새로 생성하세요.

정답 코드

library(tidyverse)

# 제공된 데이터
character_stats_wide <- tibble::tribble(
  ~class,    ~str, ~dex, ~int, ~luk,
  "Warrior", 10,   4,    3,    5,
  "Mage",    3,    5,    10,   4,
  "Archer",  5,    10,   4,    3
)

# 데이터 재구성
character_stats_long <- character_stats_wide %>%
  pivot_longer(
    cols = -class,
    names_to = "stat_name",
    values_to = "stat_value"
  )

print(character_stats_long)

해설

이 문제는 cols 인수를 지정하는 또 다른 유용한 방법을 보여줍니다.

  • cols = -class: 이 코드는 class 열을 제외한 모든 열을 피벗 대상으로 지정하라는 의미입니다. 열이 많을 경우, 변환할 열을 일일이 나열하는 것보다 고정시킬 식별자 열을 제외하는 것이 훨씬 간편합니다.
  • names_to = "stat_name": 열 이름(str, dex, int, luk)이 stat_name이라는 새로운 열로 들어갑니다.
  • values_to = "stat_value": 각 스탯의 수치들이 stat_value라는 새로운 열로 들어갑니다.

이렇게 변환된 데이터는 "가장 높은 평균 스탯을 가진 직업은?", "모든 직업의 스탯 분포는?"과 같은 질문에 답하기 위한 분석을 수행하기에 매우 적합한 구조를 가집니다. 예를 들어, ggplot(aes(x = stat_name, y = stat_value, fill = class))와 같이 시각화를 바로 적용할 수 있습니다.


184. 도시별 월간 강수량 데이터 - 긴 형식에서 넓은 형식으로 바꾸기 (기초)

문제 상황: 당신은 기상청의 데이터 분석가입니다. 도시별 월간 강수량 데이터가 아래와 같이 '긴 형식'으로 정리되어 있습니다. 이제 각 도시의 월별 강수량을 한눈에 비교할 수 있는 보고서를 작성하기 위해 데이터를 '넓은 형식'으로 변환해야 합니다.

# 제공된 데이터
rainfall_long <- tibble::tribble(
  ~city,   ~month, ~rainfall_mm,
  "Seoul", "Jan",  21.6,
  "Seoul", "Feb",  26.4,
  "Busan", "Jan",  38.2,
  "Busan", "Feb",  45.1,
  "Gwangju", "Jan", 33.5,
  "Gwangju", "Feb", 42.8
)

과제: pivot_wider() 함수를 사용하여 rainfall_long 데이터를 '넓은 형식'으로 변환하세요. city 열을 기준으로, month 열의 값들을 새로운 열 이름으로 만들고, 해당 열의 값은 rainfall_mm에서 가져와야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
rainfall_long <- tibble::tribble(
  ~city,   ~month, ~rainfall_mm,
  "Seoul", "Jan",  21.6,
  "Seoul", "Feb",  26.4,
  "Busan", "Jan",  38.2,
  "Busan", "Feb",  45.1,
  "Gwangju", "Jan", 33.5,
  "Gwangju", "Feb", 42.8
)

# 긴 형식을 넓은 형식으로 변환
rainfall_wide <- rainfall_long %>%
  pivot_wider(
    names_from = month,
    values_from = rainfall_mm
  )

print(rainfall_wide)

해설

이 문제는 pivot_longer()와 정반대의 작업을 수행하는 pivot_wider()의 기본 사용법을 다룹니다. pivot_wider()는 분석에 용이한 긴 형식의 데이터를 사람이 읽기 편한 보고서 형태의 넓은 형식으로 바꿀 때 자주 사용됩니다.

  • names_from = month: 새로운 열의 이름으로 사용할 값이 들어있는 열을 month로 지정합니다. month 열의 고유한 값들("Jan", "Feb")이 새로운 열 이름이 됩니다.
  • values_from = rainfall_mm: 새로운 열에 채워질 값이 들어있는 열을 rainfall_mm로 지정합니다.
  • pivot_wider()는 지정되지 않은 나머지 열(city)을 자동으로 식별자 열(id_cols)로 사용하여 각 행을 고유하게 구분합니다. 즉, city 열의 값(Seoul, Busan, Gwangju)이 행을 구성하게 됩니다.

결과적으로, 각 도시가 하나의 행을 차지하고 각 월이 별도의 열이 되어 월별 강수량을 수평적으로 쉽게 비교할 수 있는 표가 만들어집니다.


185. 설문조사 응답 데이터 재구성하기

문제 상황: 설문조사 결과를 분석 중입니다. 데이터가 응답자 ID, 질문 번호, 응답 내용으로 구성된 '긴 형식'으로 되어 있습니다. 각 응답자가 어떤 질문에 어떻게 답했는지 한 행에 모두 표시하여 확인하고 싶습니다.

# 제공된 데이터
survey_long <- tibble::tribble(
  ~participant_id, ~question, ~response,
  1,               "Q1",      "Yes",
  1,               "Q2",      "No",
  2,               "Q1",      "No",
  2,               "Q2",      "Yes",
  3,               "Q1",      "Yes",
  3,               "Q2",      "Yes"
)

과제: pivot_wider()를 사용하여 survey_long 데이터를 넓은 형식으로 변환하세요. participant_id를 기준으로, question 열의 값(Q1, Q2)을 새로운 열 이름으로, response 열의 값을 해당 열의 값으로 사용하세요.

정답 코드

library(tidyverse)

# 제공된 데이터
survey_long <- tibble::tribble(
  ~participant_id, ~question, ~response,
  1,               "Q1",      "Yes",
  1,               "Q2",      "No",
  2,               "Q1",      "No",
  2,               "Q2",      "Yes",
  3,               "Q1",      "Yes",
  3,               "Q2",      "Yes"
)

# 데이터 재구성
survey_wide <- survey_long %>%
  pivot_wider(
    id_cols = participant_id,
    names_from = question,
    values_from = response
  )

print(survey_wide)

해설

이 문제는 pivot_wider()에서 식별자 열을 명시적으로 지정하는 방법을 보여줍니다.

  • id_cols = participant_id: 각 행을 고유하게 식별하는 기준이 되는 열을 participant_id로 명시적으로 지정했습니다. 이 인수는 필수는 아니지만, 코드의 명확성을 높여줍니다. pivot_wider는 이 인수가 없으면 names_fromvalues_from에 지정되지 않은 모든 열을 id_cols로 간주합니다.
  • names_from = question: question 열의 값들("Q1", "Q2")이 새로운 열의 이름이 됩니다.
  • values_from = response: response 열의 값들("Yes", "No")이 새로 생성된 Q1, Q2 열의 값이 됩니다.

이 변환을 통해 각 응답자(participant)가 하나의 행에 표시되고, 각 질문에 대한 응답을 해당 열에서 바로 확인할 수 있어 개별 응답자의 응답 패턴을 파악하기에 용이한 형태가 됩니다.


186. 우주 탐사선 센서 데이터 분리하기 (pivot_longer, names_sep)

문제 상황: 화성 탐사선 'R-Rover'가 센서 데이터를 보내왔습니다. 데이터의 열 이름이 센서ID_측정항목 형식(예: sensorA_temp, sensorA_pressure)으로 되어 있어 분석이 불편합니다. 센서 ID와 측정 항목을 별도의 열로 분리하여 깔끔한 'Tidy' 데이터로 만들어야 합니다.

# 제공된 데이터
probe_data_wide <- tibble::tribble(
  ~time, ~sensorA_temp, ~sensorA_pressure, ~sensorB_temp, ~sensorB_pressure,
  1,     -15.1,         6.2,               -14.8,         6.3,
  2,     -15.3,         6.1,               -15.0,         6.2,
  3,     -15.2,         6.2,               -14.9,         6.3
)

과제: pivot_longer()names_sep 인수를 사용하여 probe_data_wide 데이터를 정돈하세요. 최종 데이터는 time, sensor_id, measurement_type, value 열을 가져야 합니다. 열 이름에 있는 _를 기준으로 sensor_idmeasurement_type을 분리하세요.

정답 코드

library(tidyverse)

# 제공된 데이터
probe_data_wide <- tibble::tribble(
  ~time, ~sensorA_temp, ~sensorA_pressure, ~sensorB_temp, ~sensorB_pressure,
  1,     -15.1,         6.2,               -14.8,         6.3,
  2,     -15.3,         6.1,               -15.0,         6.2,
  3,     -15.2,         6.2,               -14.9,         6.3
)

# 데이터 정돈
probe_data_tidy <- probe_data_wide %>%
  pivot_longer(
    cols = -time,
    names_to = c("sensor_id", "measurement_type"),
    names_sep = "_",
    values_to = "value"
  )

print(probe_data_tidy)

해설

이 문제는 pivot_longer()의 강력한 기능 중 하나인 names_sep을 활용하여 하나의 열 이름을 여러 개의 새로운 열로 분리하는 방법을 다룹니다.

  • cols = -time: time 열을 제외한 모든 열을 변환 대상으로 지정합니다.
  • names_to = c("sensor_id", "measurement_type"): 기존 열 이름을 분리하여 저장할 두 개의 새로운 열 이름을 벡터 형태로 전달합니다.
  • names_sep = "_": tidyr에게 열 이름(예: "sensorA_temp")을 _ 문자를 기준으로 분리하라고 알려줍니다. 분리된 첫 번째 부분("sensorA")은 names_to의 첫 번째 요소인 sensor_id 열에, 두 번째 부분("temp")은 두 번째 요소인 measurement_type 열에 저장됩니다.

이 기법을 사용하면, 복잡하고 구조적인 정보를 담고 있는 열 이름을 매우 효율적으로 파싱(parsing)하여 의미 있는 여러 변수로 만들 수 있습니다. 이제 group_by(sensor_id, measurement_type)를 사용하여 각 센서의 측정 항목별 통계치를 쉽게 계산할 수 있습니다.


187. 임상시험 데이터 재구성하기 (pivot_longer, names_pattern)

문제 상황: 신약 개발을 위한 임상시험 데이터가 있습니다. 각 환자(patient_id)에 대해, 방문 시점(visit1, visit2)별로 혈압(bp)과 심박수(hr)를 측정한 데이터가 visit1_bp, visit1_hr 와 같은 형태로 기록되어 있습니다. 방문 시점과 측정 항목을 분리하여 분석에 용이한 형태로 만들어야 합니다.

# 제공된 데이터
clinical_data_wide <- tibble::tribble(
  ~patient_id, ~visit1_bp, ~visit1_hr, ~visit2_bp, ~visit2_hr,
  "P01",       120,        72,         122,        75,
  "P02",       135,        80,         131,        78,
  "P03",       118,        68,         120,        65
)

과제: pivot_longer()names_pattern 인수를 사용하여 clinical_data_wide 데이터를 정돈하세요. 정규 표현식(regular expression)을 사용하여 방문 시점과 측정 항목을 분리해야 합니다. 최종 데이터는 patient_id, visit, measurement, value 열을 가져야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
clinical_data_wide <- tibble::tribble(
  ~patient_id, ~visit1_bp, ~visit1_hr, ~visit2_bp, ~visit2_hr,
  "P01",       120,        72,         122,        75,
  "P02",       135,        80,         131,        78,
  "P03",       118,        68,         120,        65
)

# 데이터 정돈
clinical_data_tidy <- clinical_data_wide %>%
  pivot_longer(
    cols = -patient_id,
    names_to = c("visit", "measurement"),
    names_pattern = "visit(.)_(..)",
    values_to = "value"
  )

print(clinical_data_tidy)

해설

이 문제는 names_sep보다 더 유연하고 강력한 names_pattern 인수를 사용하는 방법을 보여줍니다. names_pattern은 정규 표현식(regex)을 사용하여 열 이름에서 원하는 부분을 추출합니다.

  • names_to = c("visit", "measurement"): 추출한 값을 저장할 두 개의 새로운 열을 지정합니다.
  • names_pattern = "visit(.)_(..)": 이 정규 표현식이 핵심입니다.
    • visit: "visit"라는 문자열과 정확히 일치합니다.
    • (.): 괄호 ()는 캡처 그룹(capture group)을 의미하며, 이 안에 매칭된 부분이 names_to의 첫 번째 열(visit)로 들어갑니다. .는 '임의의 한 문자'를 의미하므로, '1' 또는 '2'와 매칭됩니다.
    • _: _ 문자와 정확히 일치합니다.
    • (..): 두 번째 캡처 그룹입니다. 이 안에 매칭된 부분이 names_to의 두 번째 열(measurement)로 들어갑니다. ..는 '임의의 두 문자'를 의미하므로, 'bp' 또는 'hr'과 매칭됩니다.

names_pattern을 사용하면 _와 같은 명확한 구분자(separator)가 없거나 더 복잡한 패턴을 가진 열 이름도 효과적으로 파싱할 수 있어 데이터 정돈 작업의 유연성을 크게 높여줍니다.


188. 주식 데이터 정돈하기 (pivot_longer, .value)

문제 상황: 여러 주식 종목의 날짜별 가격과 거래량 데이터가 있습니다. 열 이름이 AAPL_price, AAPL_volume, GOOG_price, GOOG_volume과 같이 종목명_측정항목 형식으로 되어 있습니다. 각 종목을 행으로 유지하면서, 가격(price)과 거래량(volume)을 별도의 열로 가지는 깔끔한 데이터를 만들고 싶습니다.

# 제공된 데이터
stocks_wide <- tibble::tribble(
  ~date,       ~AAPL_price, ~AAPL_volume, ~GOOG_price, ~GOOG_volume,
  "2023-10-26", 170.5,       5.7e7,        125.6,       2.5e7,
  "2023-10-27", 168.2,       6.1e7,        123.9,       2.8e7
)

과제: pivot_longer()를 사용하여 stocks_wide 데이터를 정돈하세요. 최종 데이터는 date, ticker, price, volume 열을 가져야 합니다. names_sep.value 특수 지정자를 활용하여 문제를 해결하세요.

정답 코드

library(tidyverse)

# 제공된 데이터
stocks_wide <- tibble::tribble(
  ~date,       ~AAPL_price, ~AAPL_volume, ~GOOG_price, ~GOOG_volume,
  "2023-10-26", 170.5,       5.7e7,        125.6,       2.5e7,
  "2023-10-27", 168.2,       6.1e7,        123.9,       2.8e7
)

# 데이터 정돈
stocks_tidy <- stocks_wide %>%
  pivot_longer(
    cols = -date,
    names_to = c("ticker", ".value"),
    names_sep = "_"
  )

print(stocks_tidy)

해설

이 문제는 pivot_longer()의 매우 유용한 특수 기능인 .value를 소개합니다. .valuenames_to 인수에 사용될 때, 매칭된 부분이 새로운 열의 '이름'이 아니라, 기존 값들이 들어갈 여러 개의 '열' 자체를 가리키도록 만듭니다.

  • cols = -date: date를 제외한 모든 열을 변환합니다.
  • names_to = c("ticker", ".value"):
    • names_sep = "_"에 의해 "AAPL_price"는 "AAPL"과 "price"로 분리됩니다.
    • 첫 번째 부분("AAPL")은 names_to의 첫 번째 요소인 ticker 열에 저장됩니다.
    • 두 번째 부분("price")은 .value와 매칭됩니다. tidyr는 이를 보고 "price"라는 이름의 새로운 열을 만들고, 원래 AAPL_price 열에 있던 값을 이 price 열에 넣으라는 의미로 해석합니다.
    • 마찬가지로, "AAPL_volume"에서 분리된 "volume"은 volume이라는 새 열을 만들고 값을 채우는 데 사용됩니다.
  • names_sep = "_": 열 이름을 분리하는 기준을 _로 지정합니다.

결과적으로, pivot_longer()를 한 번만 사용했음에도 불구하고 pricevolume이라는 두 개의 측정값 열이 생성되었습니다. 이는 측정 항목이 여러 개일 때 데이터를 매우 효율적으로 정돈하는 강력한 방법입니다.


189. 실험 결과에서 결측치 처리하며 데이터 정돈하기

문제 상황: 세 가지 다른 처리(treatment) 조건 하에서 두 샘플(sample1, sample2)의 반응을 측정한 실험 데이터가 있습니다. 그런데 일부 실험이 누락되어 데이터에 NA 값이 포함되어 있습니다. 데이터를 긴 형식으로 변환하면서, 값이 없는(NA) 관측치는 분석에서 제외하고 싶습니다.

# 제공된 데이터
experiment_wide <- tibble::tribble(
  ~treatment, ~sample1, ~sample2,
  "A",        10.5,     12.1,
  "B",        NA,       15.3,
  "C",        9.8,      NA
)

과제: pivot_longer()를 사용하여 experiment_wide 데이터를 긴 형식으로 변환하세요. 변환 과정에서 NA 값을 가진 행은 결과 데이터에서 완전히 제거해야 합니다. 최종 데이터는 treatment, sample, response 열을 가져야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
experiment_wide <- tibble::tribble(
  ~treatment, ~sample1, ~sample2,
  "A",        10.5,     12.1,
  "B",        NA,       15.3,
  "C",        9.8,      NA
)

# 결측치를 제거하며 데이터 정돈
experiment_long <- experiment_wide %>%
  pivot_longer(
    cols = starts_with("sample"),
    names_to = "sample",
    values_to = "response",
    values_drop_na = TRUE
  )

print(experiment_long)

해설

이 문제는 데이터 정돈과 결측치 처리를 동시에 수행하는 방법을 보여줍니다.

  • cols = starts_with("sample"): dplyr의 선택 헬퍼 starts_with()를 사용하여 "sample"로 시작하는 모든 열(sample1, sample2)을 변환 대상으로 지정합니다.
  • values_to = "response": 값들이 들어갈 열의 이름을 "response"로 지정합니다.
  • values_drop_na = TRUE: 이 인수가 이 문제의 핵심입니다. 이 옵션을 TRUE로 설정하면, pivot_longer는 변환을 수행한 후 values_to로 지정된 열(response)에 NA 값이 있는 모든 행을 자동으로 제거합니다.

따라서 treatment "B"의 sample1treatment "C"의 sample2에 해당했던 관측치는 response 값이 NA이므로 최종 결과에서 제외됩니다. 이는 데이터를 정돈함과 동시에 불완전한 데이터를 깔끔하게 필터링하는 매우 효율적인 방법입니다.


190. 여러 측정값과 식별자를 가진 데이터 넓게 만들기

문제 상황: 어떤 공장의 센서 데이터가 시간(time), 센서 ID(sensor), 측정 항목(metric), 그리고 측정값(value)으로 구성된 '긴 형식'으로 저장되어 있습니다. 보고서를 위해 각 센서의 온도(temp)와 습도(humidity)를 시간대별로 나란히 비교할 수 있는 '넓은 형식'의 데이터로 만들고자 합니다.

# 제공된 데이터
sensor_long <- tibble::tribble(
  ~time, ~sensor, ~metric,    ~value,
  1,     "S1",    "temp",     22.1,
  1,     "S1",    "humidity", 45.5,
  1,     "S2",    "temp",     21.9,
  1,     "S2",    "humidity", 46.2,
  2,     "S1",    "temp",     22.3,
  2,     "S1",    "humidity", 45.1,
  2,     "S2",    "temp",     22.0,
  2,     "S2",    "humidity", 46.0
)

과제: pivot_wider()를 사용하여 sensor_long 데이터를 넓은 형식으로 변환하세요. 각 행이 고유한 timesensor 조합을 나타내도록 하고, metric 열의 값("temp", "humidity")을 새로운 열 이름으로, value 열의 값을 해당 열의 값으로 사용하세요.

정답 코드

library(tidyverse)

# 제공된 데이터
sensor_long <- tibble::tribble(
  ~time, ~sensor, ~metric,    ~value,
  1,     "S1",    "temp",     22.1,
  1,     "S1",    "humidity", 45.5,
  1,     "S2",    "temp",     21.9,
  1,     "S2",    "humidity", 46.2,
  2,     "S1",    "temp",     22.3,
  2,     "S1",    "humidity", 45.1,
  2,     "S2",    "temp",     22.0,
  2,     "S2",    "humidity", 46.0
)

# 여러 식별자를 기준으로 넓게 만들기
sensor_wide <- sensor_long %>%
  pivot_wider(
    id_cols = c(time, sensor),
    names_from = metric,
    values_from = value
  )

print(sensor_wide)

해설

이 문제는 여러 개의 열을 식별자(id_cols)로 사용하여 pivot_wider()를 수행하는 방법을 보여줍니다.

  • id_cols = c(time, sensor): pivot_wider에게 timesensor 열의 조합이 하나의 고유한 관측 단위를 형성한다고 알려줍니다. 따라서 결과 데이터의 각 행은 특정 시간의 특정 센서에 대한 정보가 됩니다.
  • names_from = metric: metric 열의 고유한 값("temp", "humidity")들이 새로운 열의 이름이 됩니다.
  • values_from = value: value 열의 값들이 새로 생성된 temphumidity 열을 채우게 됩니다.

이 변환을 통해, 우리는 특정 시간(time=1)에 S1 센서의 온도와 습도를 같은 행에서 즉시 확인할 수 있게 되어, 여러 측정 항목을 동시에 비교 분석하기 편리한 데이터 구조를 갖게 됩니다.


191. 긴 형식 데이터를 넓게 만들기 (재도전)

문제 상황: 181번 문제에서 'R 카페'의 판매량 데이터를 '긴 형식'으로 성공적으로 변환했습니다. 이제, 반대로 '긴 형식'의 데이터를 다시 원래의 '넓은 형식'으로 되돌리는 연습을 해보겠습니다. 이는 데이터 변환 작업을 자유자재로 할 수 있는지 확인하는 좋은 훈련입니다.

# 제공된 데이터 (181번 문제의 결과)
menu_sales_long <- tibble::tribble(
  ~menu,       ~quarter, ~sales,
  "Americano", "Q1",     520,
  "Caffe Latte", "Q1",   480,
  "Green Tea",   "Q1",   150,
  "Americano", "Q2",     450,
  "Caffe Latte", "Q2",   420,
  "Green Tea",   "Q2",   180,
  "Americano", "Q3",     550,
  "Caffe Latte", "Q3",   500,
  "Green Tea",   "Q3",   140,
  "Americano", "Q4",     680,
  "Caffe Latte", "Q4",   620,
  "Green Tea",   "Q4",   110
)

과제: pivot_wider()를 사용하여 menu_sales_long 데이터를 181번 문제의 초기 데이터와 같은 '넓은 형식'으로 변환하세요. menu를 행으로, quarter를 열로, sales를 값으로 사용해야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
menu_sales_long <- tibble::tribble(
  ~menu,       ~quarter, ~sales,
  "Americano", "Q1",     520,
  "Caffe Latte", "Q1",   480,
  "Green Tea",   "Q1",   150,
  "Americano", "Q2",     450,
  "Caffe Latte", "Q2",   420,
  "Green Tea",   "Q2",   180,
  "Americano", "Q3",     550,
  "Caffe Latte", "Q3",   500,
  "Green Tea",   "Q3",   140,
  "Americano", "Q4",     680,
  "Caffe Latte", "Q4",   620,
  "Green Tea",   "Q4",   110
)

# 긴 형식을 넓은 형식으로 변환
menu_sales_wide_again <- menu_sales_long %>%
  pivot_wider(
    names_from = quarter,
    values_from = sales
  )

print(menu_sales_wide_again)

해설

이 문제는 pivot_longer()pivot_wider()가 서로 역연산 관계에 있음을 명확히 보여주는 예제입니다. 데이터의 구조를 필요에 따라 유연하게 바꿀 수 있는 능력을 기르는 데 도움이 됩니다.

  • pivot_wider()names_from(quarter)과 values_from(sales)에 지정되지 않은 menu 열을 자동으로 id_cols로 인식합니다.
  • quarter 열의 값들(Q1, Q2, Q3, Q4)이 새로운 열 이름이 됩니다.
  • sales 열의 값들이 이 새로운 열들을 채웁니다.

이 과정을 통해 데이터는 원래의 넓은 형식으로 완벽하게 복원됩니다. 데이터 분석 과정에서는 분석 목적에 따라 두 가지 형태를 오가며 작업해야 할 때가 많으므로, 양방향 변환에 모두 익숙해지는 것이 중요합니다.


192. 중복 측정값이 있는 데이터 넓게 만들기 (values_fn)

문제 상황: 한 연구실에서 약물 반응 실험을 여러 번 반복하여 데이터를 수집했습니다. 데이터에는 약물 종류(drug), 실험 대상 ID(subject), 그리고 측정된 반응값(response)이 기록되어 있습니다. 그런데 일부 drug-subject 조합에 대해 측정이 두 번씩 이루어져 중복된 행이 존재합니다. 이 데이터를 넓은 형식으로 바꾸면서, 중복된 측정값들의 '평균'을 계산하여 하나의 값으로 합쳐야 합니다.

# 제공된 데이터
lab_results_long <- tibble::tribble(
  ~drug, ~subject, ~response,
  "A",   1,        10.2,
  "A",   1,        10.6,  # 중복 측정
  "A",   2,        11.5,
  "B",   1,        15.3,
  "B",   2,        16.1,
  "B",   2,        15.9   # 중복 측정
)

과제: pivot_wider()values_fn 인수를 사용하여 lab_results_long 데이터를 넓은 형식으로 변환하세요. subject를 행으로, drug을 열로 만드세요. 만약 하나의 subject-drug 조합에 여러 response 값이 있다면, 이들의 평균값을 계산하여 셀에 채워야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
lab_results_long <- tibble::tribble(
  ~drug, ~subject, ~response,
  "A",   1,        10.2,
  "A",   1,        10.6,
  "A",   2,        11.5,
  "B",   1,        15.3,
  "B",   2,        16.1,
  "B",   2,        15.9
)

# 중복값을 평균내어 넓게 만들기
lab_results_wide <- lab_results_long %>%
  pivot_wider(
    names_from = drug,
    values_from = response,
    values_fn = mean
  )

print(lab_results_wide)

해설

이 문제는 pivot_wider()를 실행할 때 발생하는 "중복" 문제를 해결하는 핵심적인 방법인 values_fn 인수를 다룹니다. pivot_wider는 기본적으로 각 셀에 하나의 값만 들어갈 것으로 예상합니다. 하지만 (subject=1, drug=A) 조합처럼 여러 값이 존재하면 오류가 발생하거나 원치 않는 결과(리스트 열)가 나올 수 있습니다.

  • values_fn = mean: 이 인수는 중복이 발생했을 때 적용할 함수를 지정합니다. 여기서는 mean 함수를 지정했으므로, tidyr는 (subject=1, drug=A)에 해당하는 값들 c(10.2, 10.6)의 평균인 10.4를 계산하여 셀에 채워줍니다. 마찬가지로 (subject=2, drug=B)에 해당하는 c(16.1, 15.9)의 평균인 16.0을 계산합니다.
  • mean 외에도 sum, median, length (측정 횟수 확인), 또는 사용자가 직접 정의한 함수 등 다양한 집계 함수를 사용할 수 있습니다.

values_fn은 데이터를 넓은 형식으로 요약(summarize)하면서 변환할 때 매우 강력하고 필수적인 기능입니다.


193. 넓게 만들 때 결측치 채우기 (values_fill)

문제 상황: 식물 성장 실험에서 두 가지 비료(fertilizer)를 사용하여 식물의 키(height)를 측정했습니다. 데이터는 식물 ID(plant_id), 사용한 비료, 키로 구성되어 있습니다. 모든 식물이 두 종류의 비료를 모두 사용한 것은 아니기 때문에, 데이터를 넓은 형식으로 변환하면 일부 셀이 비게 됩니다. 이 빈 셀(NA)을 0으로 채워서 표를 완성하고 싶습니다.

# 제공된 데이터
plant_growth_long <- tibble::tribble(
  ~plant_id, ~fertilizer, ~height,
  1,         "A",         25.4,
  1,         "B",         28.1,
  2,         "A",         22.9,
  3,         "B",         30.5
)

식물 2번은 비료 B를, 식물 3번은 비료 A를 사용한 기록이 없습니다.

과제: pivot_wider()values_fill 인수를 사용하여 plant_growth_long 데이터를 넓은 형식으로 변환하세요. plant_id를 행으로, fertilizer를 열로 만드세요. 변환 후 발생하는 NA 값은 0으로 대체해야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
plant_growth_long <- tibble::tribble(
  ~plant_id, ~fertilizer, ~height,
  1,         "A",         25.4,
  1,         "B",         28.1,
  2,         "A",         22.9,
  3,         "B",         30.5
)

# 결측치를 0으로 채우며 넓게 만들기
plant_growth_wide <- plant_growth_long %>%
  pivot_wider(
    names_from = fertilizer,
    values_from = height,
    values_fill = 0
  )

print(plant_growth_wide)

해설

이 문제는 pivot_wider() 실행 시 구조적으로 발생할 수밖에 없는 결측치(implicit missing values)를 처리하는 values_fill 인수를 소개합니다.

  • pivot_wider()plant_idfertilizer의 모든 가능한 조합에 대한 셀을 만듭니다.
  • 원본 데이터에 (plant_id=2, fertilizer="B") 조합이 없으므로, 결과 테이블의 해당 셀은 기본적으로 NA가 됩니다. (plant_id=3, fertilizer="A")도 마찬가지입니다.
  • values_fill = 0: 이 인수는 pivot_wider에게 NA가 될 모든 셀을 지정된 값, 즉 0으로 채우라고 지시합니다.
  • values_fill은 단일 값뿐만 아니라 리스트를 사용하여 열마다 다른 값을 채울 수도 있습니다. 예를 들어 values_fill = list(height = 0) 처럼 명시적으로 지정할 수 있습니다.

이 기능은 후속 계산(예: 합계, 평균)에서 NA로 인한 문제를 피하고, 데이터가 없음을 0과 같은 특정 값으로 명확하게 표현하고 싶을 때 매우 유용합니다.


194. 열 이름에 접두사 붙여서 넓게 만들기 (names_prefix)

문제 상황: 학생들의 시험 점수 데이터가 '긴 형식'으로 있습니다. 데이터를 '넓은 형식'으로 변환하여 각 과목(subject)이 열이 되도록 만들려고 합니다. 그런데 변환 후 생성될 열 이름(Math, English)이 기존의 다른 열 이름과 겹칠 가능성이 있거나, 단순히 구분을 위해 접두사(prefix)를 붙이고 싶습니다.

# 제공된 데이터
scores_long <- tibble::tribble(
  ~student_id, ~subject,  ~score,
  "S01",       "Math",    95,
  "S01",       "English", 88,
  "S02",       "Math",    78,
  "S02",       "English", 92
)

과제: pivot_wider()를 사용하여 scores_long 데이터를 넓은 형식으로 변환하세요. student_id를 기준으로, subject를 열 이름으로 사용하되, 새로 생성되는 모든 열 이름 앞에 Score_ 라는 접두사를 붙이세요. (예: Score_Math, Score_English)

정답 코드

library(tidyverse)

# 제공된 데이터
scores_long <- tibble::tribble(
  ~student_id, ~subject,  ~score,
  "S01",       "Math",    95,
  "S01",       "English", 88,
  "S02",       "Math",    78,
  "S02",       "English", 92
)

# 열 이름에 접두사를 붙여 넓게 만들기
scores_wide <- scores_long %>%
  pivot_wider(
    names_from = subject,
    values_from = score,
    names_prefix = "Score_"
  )

print(scores_wide)

해설

이 문제는 pivot_wider()로 생성되는 열의 이름을 체계적으로 관리하는 데 도움이 되는 names_prefix 인수를 다룹니다.

  • names_from = subject: subject 열의 값("Math", "English")이 새로운 열 이름의 기반이 됩니다.
  • names_prefix = "Score_": 이 인수는 names_from으로 생성된 모든 열 이름("Math", "English")의 바로 앞에 지정된 문자열("Score_")을 추가합니다.
  • 결과적으로 열 이름은 Math가 아닌 Score_Math, English가 아닌 Score_English가 됩니다.

이 기능은 다음과 같은 경우에 유용합니다.

  1. 프로그래밍 방식으로 생성된 열임을 명확히 하고 싶을 때
  2. 숫자로 시작하는 열 이름(R에서는 권장되지 않음)이 생성되는 것을 피하고 싶을 때 (예: names_from 열에 "2022", "2023"이 있을 때 names_prefix = "Year_"를 사용)
  3. 다른 데이터와의 병합(join) 시 발생할 수 있는 열 이름 충돌을 예방하고 싶을 때

195. 여러 값 열을 동시에 넓게 만들기

문제 상황: 온라인 게임의 경기 결과 데이터가 '긴 형식'으로 저장되어 있습니다. 각 플레이어(player)의 경기별(match_id) 킬(kills) 수와 데스(deaths) 수가 기록되어 있습니다. 이 데이터를 각 플레이어가 한 행을 차지하고, 경기별 킬/데스 수가 열로 표시되는 '넓은 형식'으로 변환하여 경기별 성과를 한눈에 보고자 합니다.

# 제공된 데이터
game_stats_long <- tibble::tribble(
  ~player, ~match_id, ~kills, ~deaths,
  "Ace",   1,         10,     3,
  "Ace",   2,         15,     5,
  "Fury",  1,         8,      6,
  "Fury",  2,         12,     2
)

과제: pivot_wider()를 사용하여 game_stats_long 데이터를 넓은 형식으로 변환하세요. player를 기준으로, match_id를 새로운 열 이름의 일부로 사용하고, killsdeaths 값을 모두 넓은 형식에 포함시켜야 합니다. 최종 열 이름은 match_1_kills, match_1_deaths 와 같은 형태가 되어야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
game_stats_long <- tibble::tribble(
  ~player, ~match_id, ~kills, ~deaths,
  "Ace",   1,         10,     3,
  "Ace",   2,         15,     5,
  "Fury",  1,         8,      6,
  "Fury",  2,         12,     2
)

# 여러 값 열을 넓게 만들기
game_stats_wide <- game_stats_long %>%
  pivot_wider(
    names_from = match_id,
    values_from = c(kills, deaths),
    names_glue = "match_{match_id}_{.value}"
  )

print(game_stats_wide)

해설

이 문제는 pivot_wider의 가장 강력한 기능 중 하나로, 여러 개의 값 열(values_from)을 동시에 변환하는 방법을 보여줍니다.

  • names_from = match_id: match_id 열의 값(1, 2)이 새로운 열 이름을 구성하는 요소가 됩니다.
  • values_from = c(kills, deaths): 넓은 형식으로 변환할 값들이 포함된 열을 killsdeaths 두 개로 지정합니다.
  • names_glue = "match_{match_id}_{.value}": 이 인수가 이 문제의 핵심입니다. pivot_wider는 여러 values_from 열이 지정되면, 충돌을 피하기 위해 열 이름을 어떻게 조합할지 규칙을 필요로 합니다. names_glue는 이 규칙을 정의합니다.
    • {match_id}: names_from으로 지정된 열(match_id)의 값을 가져와 이 위치에 삽입합니다.
    • {.value}: values_from으로 지정된 열의 이름("kills" 또는 "deaths")을 가져와 이 위치에 삽입합니다.
    • 따라서 match_id가 1이고 values_fromkills인 경우, 열 이름은 "match_1_kills"가 됩니다.

이 방법을 사용하면, 구조적으로 관련된 여러 측정값(예: 최소/최대, 평균/표준편차, 킬/데스)을 매우 체계적이고 설명적인 열 이름을 가진 넓은 형식 데이터로 깔끔하게 변환할 수 있습니다.


196. 복잡한 열 이름을 가진 데이터 정돈하기 (종합)

문제 상황: 당신은 마케팅 분석가로, 2022년과 2023년의 상반기(H1), 하반기(H2) 채널별 광고비 지출 데이터를 받았습니다. 열 이름이 Cost_2022_H1, Cost_2023_H2 와 같이 복잡한 구조를 가지고 있습니다. 연도, 반기, 측정항목(Cost)을 각각 별도의 열로 분리하여 분석에 용이한 Tidy 데이터를 만들어야 합니다.

# 제공된 데이터
marketing_spend_wide <- tibble::tribble(
  ~channel,    ~Cost_2022_H1, ~Cost_2022_H2, ~Cost_2023_H1, ~Cost_2023_H2,
  "Search",    5000,          5500,          6000,          6200,
  "Social",    8000,          8200,          9000,          9500,
  "Display",   3000,          3100,          3500,          3400
)

과제: pivot_longer()를 사용하여 marketing_spend_wide 데이터를 정돈하세요. names_to에 여러 변수를 지정하고, names_sep을 활용하여 열 이름을 분리하세요. 최종 데이터는 channel, metric, year, half, spend 열을 가져야 합니다. (주의: 분리 후 metric 열은 "Cost"라는 값만 갖게 됩니다.)

정답 코드

library(tidyverse)

# 제공된 데이터
marketing_spend_wide <- tibble::tribble(
  ~channel,    ~Cost_2022_H1, ~Cost_2022_H2, ~Cost_2023_H1, ~Cost_2023_H2,
  "Search",    5000,          5500,          6000,          6200,
  "Social",    8000,          8200,          9000,          9500,
  "Display",   3000,          3100,          3500,          3400
)

# 복잡한 열 이름을 분리하며 정돈하기
marketing_spend_tidy <- marketing_spend_wide %>%
  pivot_longer(
    cols = -channel,
    names_to = c("metric", "year", "half"),
    names_sep = "_",
    values_to = "spend"
  )

print(marketing_spend_tidy)

해설

이 문제는 names_sep을 사용하여 열 이름을 세 부분으로 분리하는 종합적인 예제입니다.

  • cols = -channel: channel 열을 제외한 모든 열을 변환 대상으로 지정합니다.
  • names_to = c("metric", "year", "half"): 열 이름을 분리하여 저장할 세 개의 새로운 열 이름을 지정합니다.
  • names_sep = "_": 열 이름 Cost_2022_H1_를 기준으로 분리합니다.
    • 첫 번째 조각 "Cost"는 names_to의 첫 번째 요소인 metric 열로 들어갑니다.
    • 두 번째 조각 "2022"는 year 열로 들어갑니다.
    • 세 번째 조각 "H1"은 half 열로 들어갑니다.
  • values_to = "spend": 기존 셀의 값(비용)은 spend 열에 저장됩니다.

이 변환을 통해, 원래 열 이름에 섞여 있던 세 가지 종류의 정보(측정항목, 연도, 반기)가 각각 독립적인 변수(열)로 분리되었습니다. 이제 group_by(year, channel) %>% summarize(total_spend = sum(spend)) 와 같이 연도별, 채널별 분석을 매우 쉽게 수행할 수 있습니다.


197. 연간 실적 보고서 만들기 (pivot_wider, names_glue)

문제 상황: 당신은 영업팀의 데이터 분석가입니다. 분기별 영업사원 실적 데이터가 '긴 형식'으로 정리되어 있습니다. 연말 실적 보고서를 위해, 각 영업사원(sales_person)을 행으로 하고, "2023_Q1_Sales", "2023_Q2_Sales" 와 같이 연도와 분기, 그리고 측정 항목(Sales)을 조합한 열 이름을 가진 '넓은 형식'의 표를 만들어야 합니다.

# 제공된 데이터
sales_long <- tibble::tribble(
  ~year, ~quarter, ~sales_person, ~sales,
  2023,  "Q1",     "David",         120000,
  2023,  "Q1",     "Emily",         150000,
  2023,  "Q2",     "David",         135000,
  2023,  "Q2",     "Emily",         140000
)

과제: pivot_wider()names_glue 인수를 사용하여 sales_long 데이터를 보고서 형식으로 변환하세요. sales_person을 식별자 열로 사용하고, yearquarter 열의 정보를 조합하여 새로운 열 이름을 생성하세요.

정답 코드

library(tidyverse)

# 제공된 데이터
sales_long <- tibble::tribble(
  ~year, ~quarter, ~sales_person, ~sales,
  2023,  "Q1",     "David",         120000,
  2023,  "Q1",     "Emily",         150000,
  2023,  "Q2",     "David",         135000,
  2023,  "Q2",     "Emily",         140000
)

# names_glue를 사용하여 보고서 형식으로 변환
sales_report_wide <- sales_long %>%
  pivot_wider(
    id_cols = sales_person,
    names_from = c(year, quarter),
    values_from = sales,
    names_glue = "{year}_{quarter}_Sales"
  )

print(sales_report_wide)

해설

이 문제는 names_from에 여러 열을 지정하고 names_glue를 사용하여 이들을 조합하는 방법을 보여줍니다. 이는 매우 유연하고 설명적인 열 이름을 만드는 데 효과적입니다.

  • id_cols = sales_person: sales_person을 행을 식별하는 기준으로 명시합니다.
  • names_from = c(year, quarter): 새로운 열 이름을 만드는 데 사용할 정보를 담고 있는 열을 yearquarter 두 개로 지정합니다.
  • values_from = sales: 셀에 채울 값은 sales 열에서 가져옵니다.
  • names_glue = "{year}_{quarter}_Sales": names_from으로 지정된 각 열의 값을 {} 안에 넣어 열 이름을 동적으로 생성합니다.
    • year가 2023이고 quarter가 "Q1"인 행에 대해, names_glue는 "{2023}_{Q1}_Sales"가 되어 "2023_Q1_Sales"라는 열 이름을 생성합니다.

names_glue를 사용하면 names_sep (기본값 _)을 사용할 때보다 훨씬 더 자유롭게 열 이름의 형식(순서, 구분자, 접두/접미사)을 제어할 수 있습니다.


198. 설문조사 데이터: 다중 선택 문항 정돈하기

문제 상황: "가장 선호하는 프로그래밍 언어는? (2개까지 선택 가능)"이라는 설문조사 결과 데이터가 있습니다. 응답이 choice1, choice2 두 개의 열에 나뉘어 저장되어 있습니다. 각 언어가 총 몇 번씩 선택되었는지 집계하기 위해, 이 데이터를 각 응답이 하나의 행을 차지하는 '긴 형식'으로 만들어야 합니다.

# 제공된 데이터
survey_multi_choice <- tibble::tribble(
  ~respondent_id, ~choice1, ~choice2,
  1,              "R",      "Python",
  2,              "Python", NA,
  3,              "SQL",    "R",
  4,              "R",      "SQL"
)

응답자 2번처럼 하나만 선택한 경우, choice2NA입니다.

과제: pivot_longer()를 사용하여 survey_multi_choice 데이터를 정돈하세요. 최종 데이터는 respondent_idlanguage 두 개의 열만 가져야 하며, 언어를 선택하지 않은 경우(NA)는 결과에서 제외해야 합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
survey_multi_choice <- tibble::tribble(
  ~respondent_id, ~choice1, ~choice2,
  1,              "R",      "Python",
  2,              "Python", NA,
  3,              "SQL",    "R",
  4,              "R",      "SQL"
)

# 다중 선택 문항 데이터 정돈
survey_tidy <- survey_multi_choice %>%
  pivot_longer(
    cols = c(choice1, choice2),
    names_to = "choice_rank",
    values_to = "language",
    values_drop_na = TRUE
  ) %>%
  select(respondent_id, language) # 불필요한 choice_rank 열 제거

print(survey_tidy)

해설

이 문제는 실전에서 자주 마주치는 다중 선택 문항 데이터를 처리하는 방법을 보여줍니다.

  1. pivot_longer(...):

    • cols = c(choice1, choice2): 변환할 열을 명시적으로 지정합니다.
    • names_to = "choice_rank": 기존 열 이름(choice1, choice2)은 "choice_rank"라는 임시 열에 저장됩니다. 이 정보는 최종 분석에 필요 없지만, pivot_longer의 문법상 names_to 인수는 필요합니다.
    • values_to = "language": 응답 내용(프로그래밍 언어 이름)이 language 열에 저장됩니다.
    • values_drop_na = TRUE: 이 옵션 덕분에 응답자 2번의 choice2에 있던 NA 값이 결과에서 자동으로 제거됩니다.
  2. select(respondent_id, language):

    • pivot_longer의 결과에는 우리가 최종적으로 원하지 않는 choice_rank 열이 포함되어 있습니다.
    • dplyrselect() 함수를 파이프(%>%)로 연결하여 필요한 respondent_idlanguage 열만 선택하여 최종 결과를 만듭니다.

이제 count(language, sort = TRUE)와 같은 간단한 코드로 각 언어의 선호도 순위를 쉽게 계산할 수 있습니다.


199. 시간 경과에 따른 변화량 계산을 위한 데이터 재구조화

문제 상황: 환자들의 치료 전(pre)과 후(post)의 특정 생체 지표(biomarker) 수치를 기록한 데이터가 있습니다. 치료 효과를 분석하기 위해 각 환자별로 post 값에서 pre 값을 뺀 변화량(change)을 계산하고 싶습니다. 이를 위해 먼저 데이터를 넓은 형식으로 만들어야 합니다.

# 제공된 데이터
biomarker_long <- tibble::tribble(
  ~patient_id, ~timepoint, ~value,
  "P01",       "pre",      15.2,
  "P01",       "post",     11.8,
  "P02",       "pre",      20.1,
  "P02",       "post",     18.5,
  "P03",       "pre",      18.7,
  "P03",       "post",     15.3
)

과제:

  1. pivot_wider()를 사용하여 biomarker_long 데이터를 patient_id, pre, post 열을 가진 넓은 형식으로 변환하세요.
  2. mutate()를 사용하여 change라는 새로운 열을 추가하세요. 이 열의 값은 post - pre로 계산합니다.

정답 코드

library(tidyverse)

# 제공된 데이터
biomarker_long <- tibble::tribble(
  ~patient_id, ~timepoint, ~value,
  "P01",       "pre",      15.2,
  "P01",       "post",     11.8,
  "P02",       "pre",      20.1,
  "P02",       "post",     18.5,
  "P03",       "pre",      18.7,
  "P03",       "post",     15.3
)

# 데이터 재구조화 및 변화량 계산
biomarker_change <- biomarker_long %>%
  # 1. 넓은 형식으로 변환
  pivot_wider(
    names_from = timepoint,
    values_from = value
  ) %>%
  # 2. 변화량 계산
  mutate(change = post - pre)

print(biomarker_change)

해설

이 문제는 데이터 분석의 일반적인 패턴을 보여줍니다: 분석을 위해 데이터 구조를 바꾼다. prepost 값이 서로 다른 행에 있을 때는 두 값 사이의 연산(post - pre)을 직접 수행하기 어렵습니다.

  • pivot_wider(...): timepoint 열의 값("pre", "post")을 새로운 열 이름으로, value 열의 값을 해당 열의 값으로 사용하여 데이터를 넓게 만듭니다. 이 결과 각 환자의 pre 값과 post 값이 같은 행에 위치하게 됩니다.
  • mutate(change = post - pre): 이제 prepost가 같은 행에 있는 변수(열)가 되었으므로, dplyrmutate()를 사용하여 간단하게 새로운 열 change를 생성할 수 있습니다.

이처럼 tidyr를 사용하여 데이터의 구조를 분석 목적에 맞게 변경하는 것은 tidyverse를 사용한 데이터 분석의 핵심적인 단계입니다.


200. Tidyverse 파이프라인: 정돈, 분석, 그리고 다시 재구성 (종합 최종 문제)

문제 상황: 당신은 한 비디오 게임 회사의 데이터 분석가입니다. 두 개의 새로운 지역(region), 'Starlight Valley'와 'Dragon's Peak'에서 플레이어들이 획득한 아이템(item) 데이터를 받았습니다. 데이터가 매우 지저분한 '넓은 형식'으로 되어 있습니다.

# 제공된 데이터 (매우 지저분한 형식)
game_items_messy <- tibble::tribble(
  ~item,           ~StarlightValley_Common, ~StarlightValley_Rare, ~DragonsPeak_Common, ~DragonsPeak_Rare,
  "Healing Potion", 150,                     25,                    200,                 30,
  "Mana Potion",    120,                     40,                    180,                 55,
  "Phoenix Down",   10,                      5,                     12,                  8
)

과제: Tidyverse 파이프라인(%>%)을 사용하여 다음의 다단계 분석을 한 번에 수행하세요.

  1. 정돈 (Tidy): pivot_longer()를 사용하여 game_items_messy 데이터를 긴 형식으로 만드세요. 열 이름을 regionrarity (희귀도)로 분리해야 합니다. 최종적으로 item, region, rarity, count 열을 가져야 합니다.
  2. 분석 (Analyze): group_by()summarize()를 사용하여 각 지역(region)별로 획득한 아이템의 총 개수(total_items)를 계산하세요.
  3. 재구성 (Reshape): 마지막으로, 분석 결과를 사람이 보기 편한 보고서 형태로 만들기 위해 pivot_wider()를 사용하세요. 최종 결과는 StarlightValleyDragonsPeak이 각각 열이 되고, 그 값으로 total_items가 표시되어야 합니다. (최종적으로 단 하나의 행만 남게 됩니다.)

정답 코드

library(tidyverse)

# 제공된 데이터
game_items_messy <- tibble::tribble(
  ~item,           ~StarlightValley_Common, ~StarlightValley_Rare, ~DragonsPeak_Common, ~DragonsPeak_Rare,
  "Healing Potion", 150,                     25,                    200,                 30,
  "Mana Potion",    120,                     40,                    180,                 55,
  "Phoenix Down",   10,                      5,                     12,                  8
)

# Tidyverse 파이프라인을 이용한 다단계 분석
final_report <- game_items_messy %>%
  # 1. 정돈 (Tidy)
  pivot_longer(
    cols = -item,
    names_to = c("region", "rarity"),
    names_sep = "_",
    values_to = "count"
  ) %>%
  # 2. 분석 (Analyze)
  group_by(region) %>%
  summarize(total_items = sum(count)) %>%
  # 3. 재구성 (Reshape)
  pivot_wider(
    names_from = region,
    values_from = total_items
  )

print(final_report)

해설

이 최종 문제는 tidyrdplyr을 결합하여 실제 데이터 분석 워크플로우를 경험하게 해주는 종합 문제입니다.

  1. 정돈 (pivot_longer):

    • names_sep = "_"를 사용하여 "StarlightValley_Common"과 같은 열 이름을 "StarlightValley"와 "Common"으로 분리하여 각각 regionrarity 열에 저장합니다.
    • 이 단계의 결과로, 분석에 적합한 Tidy 데이터가 생성됩니다. 각 행은 특정 아이템이 특정 지역에서 특정 희귀도로 몇 번 나왔는지를 나타내는 단일 관측치가 됩니다.
  2. 분석 (group_by & summarize):

    • group_by(region): 정돈된 데이터를 region별로 그룹화합니다.
    • summarize(total_items = sum(count)): 각 지역 그룹 내에서 count 열의 합계를 계산하여 total_items라는 새로운 열에 저장합니다.
    • 이 단계의 결과로, 지역별 총 아이템 개수를 요약한 작은 테이블이 생성됩니다.
  3. 재구성 (pivot_wider):

    • names_from = region: 분석 결과 테이블의 region 열("StarlightValley", "Dragon's Peak")을 새로운 열 이름으로 사용합니다.
    • values_from = total_items: total_items 열의 값을 새로 생성된 열의 값으로 채웁니다.
    • 이 단계의 결과로, 각 지역의 총 아이템 수를 나란히 비교할 수 있는, 최종 보고서에 적합한 넓은 형식의 표가 완성됩니다.

이처럼 pivot_longer로 데이터를 분석하기 좋은 형태로 만들고, dplyr로 핵심적인 통찰을 계산한 뒤, pivot_wider로 그 결과를 사람이 이해하기 쉬운 형태로 제시하는 것은 데이터 분석의 매우 일반적이고 강력한 패턴입니다.

훌륭합니다! 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 사용자의 학습 여정에 큰 도움이 될 흥미롭고 깊이 있는 문제들을 생성해 드리겠습니다.


R 프로그래밍 문제 (중급 1단계: 201~215번)

주제: 데이터 시각화 1: Base Plotting 및 ggplot2 기초

201. 아이스크림 판매량과 기온 (Base Plotting 기초)

문제 상황: 당신은 작은 아이스크림 가게의 데이터 분석가입니다. 지난 10일간의 일일 최고 기온과 아이스크림 판매량 데이터를 수집했습니다. 기온이 판매량에 어떤 영향을 미치는지 시각적으로 확인하고 싶습니다.

데이터:

temperature <- c(22.1, 24.5, 26.3, 28.9, 31.2, 30.5, 27.8, 25.1, 23.9, 29.8)
sales <- c(150, 180, 210, 250, 300, 280, 230, 190, 160, 260)

과제: R의 내장 그래픽 시스템(Base Plotting)을 사용하여 기온(x축)과 아이스크림 판매량(y축) 사이의 관계를 나타내는 기본적인 산점도(scatter plot)를 생성하세요.

정답 코드

# 데이터 생성
temperature <- c(22.1, 24.5, 26.3, 28.9, 31.2, 30.5, 27.8, 25.1, 23.9, 29.8)
sales <- c(150, 180, 210, 250, 300, 280, 230, 190, 160, 260)

# Base Plotting을 이용한 산점도 생성
plot(x = temperature, y = sales)

해설

이 문제는 R의 가장 기본적인 시각화 함수인 plot()을 사용하여 두 변수 간의 관계를 탐색하는 방법을 다룹니다.

  • plot(x, y): 이 함수의 가장 기본적인 형태입니다. 첫 번째 인자 x는 x축에 해당하는 데이터를, 두 번째 인자 y는 y축에 해당하는 데이터를 벡터 형태로 받습니다. R은 두 벡터의 길이를 확인하고 각 원소를 (x, y) 좌표 쌍으로 매핑하여 점을 찍습니다.
  • 데이터 분석의 관점: 산점도는 두 연속형 변수 간의 관계(선형성, 방향성, 강도, 이상치 등)를 파악하는 가장 첫 번째 단계입니다. 이 플롯을 통해 우리는 '기온이 올라갈수록 아이스크림 판매량이 증가하는 경향이 있다'는 긍정적인 선형 관계를 직관적으로 파악할 수 있습니다.

202. 보고서용 산점도 꾸미기 (Base Plotting 커스터마이징)

문제 상황: 201번 문제에서 생성한 산점도를 매니저에게 보고하기 위해 좀 더 전문적으로 보이도록 꾸미려고 합니다. 제목, 축 레이블, 점의 색상과 모양을 변경해야 합니다.

과제: 201번 문제의 데이터를 사용하여 다음 요구사항을 만족하는 산점도를 Base Plotting으로 생성하세요.

  1. 전체 그래프의 제목: "기온과 아이스크림 판매량의 관계"
  2. x축 레이블: "최고 기온 (섭씨)"
  3. y축 레이블: "일일 판매량 (개)"
  4. 점의 색상: 파란색(blue)
  5. 점의 모양: 속이 꽉 찬 원 (pch = 19)

정답 코드

# 데이터 생성 (201번과 동일)
temperature <- c(22.1, 24.5, 26.3, 28.9, 31.2, 30.5, 27.8, 25.1, 23.9, 29.8)
sales <- c(150, 180, 210, 250, 300, 280, 230, 190, 160, 260)

# Base Plotting 커스터마이징
plot(x = temperature, y = sales,
     main = "기온과 아이스크림 판매량의 관계",
     xlab = "최고 기온 (섭씨)",
     ylab = "일일 판매량 (개)",
     col = "blue",
     pch = 19)

해설

plot() 함수는 다양한 인자(argument)를 통해 그래프의 거의 모든 요소를 제어할 수 있습니다. 이는 Base Plotting 시스템의 강력함과 유연성을 보여줍니다.

  • main: 그래프의 주 제목(main title)을 설정합니다.
  • xlab, ylab: 각각 x축과 y축의 레이블(label) 텍스트를 설정합니다. 축 레이블은 그래프를 해석하는 데 필수적인 정보이므로 항상 명확하게 기재하는 것이 좋습니다.
  • col: 점, 선, 면 등의 색상(color)을 지정합니다. "blue", "red"와 같은 이름이나 "#FF0000"과 같은 16진수 코드를 사용할 수 있습니다.
  • pch: 점의 모양(plotting character)을 지정하는 정수 값입니다. pch = 19는 속이 꽉 찬 원이며, pch = 1은 속이 빈 원, pch = 3은 '+' 모양 등 다양한 모양을 선택할 수 있습니다. ?pch를 콘솔에 입력하면 사용 가능한 모양들을 확인할 수 있습니다.

203. ggplot2와의 첫 만남: 그래픽의 문법

문제 상황: 당신은 최근 R 커뮤니티에서 ggplot2 패키지가 데이터 시각화의 표준으로 자리 잡고 있다는 것을 알게 되었습니다. 201번 문제의 아이스크림 데이터를 ggplot2를 사용해 산점도로 표현해보고자 합니다.

데이터:

icecream_df <- data.frame(
  temperature = c(22.1, 24.5, 26.3, 28.9, 31.2, 30.5, 27.8, 25.1, 23.9, 29.8),
  sales = c(150, 180, 210, 250, 300, 280, 230, 190, 160, 260)
)

과제: ggplot2 패키지를 사용하여 icecream_df 데이터프레임의 temperature를 x축, sales를 y축으로 하는 산점도를 생성하세요.

정답 코드

# ggplot2 패키지 로드
library(ggplot2)

# 데이터프레임 생성
icecream_df <- data.frame(
  temperature = c(22.1, 24.5, 26.3, 28.9, 31.2, 30.5, 27.8, 25.1, 23.9, 29.8),
  sales = c(150, 180, 210, 250, 300, 280, 230, 190, 160, 260)
)

# ggplot2를 이용한 산점도 생성
ggplot(data = icecream_df, aes(x = temperature, y = sales)) +
  geom_point()

해설

이 문제는 ggplot2의 핵심 철학인 **그래픽의 문법(Grammar of Graphics)**을 소개합니다. ggplot2는 마치 문장을 만들 듯, 여러 구성 요소를 + 기호로 연결하여 그래프를 만듭니다.

  1. ggplot(data = ..., aes(...)): 그래프의 기본 틀을 설정하는 "캔버스"와 같습니다.

    • data: 시각화에 사용할 데이터프레임을 지정합니다. ggplot2는 데이터프레임 형식의 데이터를 사용하는 것을 기본으로 합니다.
    • aes(): **미학적 매핑(Aesthetic Mapping)**을 정의합니다. 데이터의 어떤 변수(temperature, sales)를 그래프의 어떤 시각적 요소(x축 위치, y축 위치, 색상, 모양 등)에 "매핑"할지 지정합니다. 여기서는 temperature를 x축에, sales를 y축에 매핑했습니다.
  2. +: 레이어(layer)를 추가하는 연결자입니다.

  3. geom_point(): 기하 객체(Geometric Object, geom) 레이어입니다. aes()에서 매핑된 x, y 위치에 점(point)을 그리라고 R에게 지시합니다. geom_line()은 선을, geom_bar()는 막대를 그리는 등 다양한 geom이 존재합니다.

이러한 구조 덕분에 ggplot2는 직관적이고 체계적으로 복잡한 그래프를 만들 수 있습니다.

204. 게임 캐릭터 스탯 분석 (Aesthetic Mapping 심화)

문제 상황: 당신은 게임 개발사의 데이터 분석가입니다. 새로 출시할 RPG 게임의 캐릭터 밸런스를 맞추기 위해 3가지 직업(전사, 마법사, 궁수)의 공격력과 방어력 데이터를 분석하려고 합니다.

데이터:

character_stats <- data.frame(
  attack = c(120, 135, 110, 90, 85, 95, 150, 140, 160),
  defense = c(150, 140, 165, 80, 90, 75, 110, 125, 100),
  class = c("Warrior", "Warrior", "Warrior", "Mage", "Mage", "Mage", "Archer", "Archer", "Archer")
)

과제: ggplot2를 사용하여 공격력(x축)과 방어력(y축)의 관계를 나타내는 산점도를 그리되, 각 점의 **색상(color)**을 캐릭터의 직업(class)에 따라 다르게 표현하고, 점의 **크기(size)**를 공격력(attack)에 비례하도록 매핑하세요.

정답 코드

library(ggplot2)

# 데이터프레임 생성
character_stats <- data.frame(
  attack = c(120, 135, 110, 90, 85, 95, 150, 140, 160),
  defense = c(150, 140, 165, 80, 90, 75, 110, 125, 100),
  class = c("Warrior", "Warrior", "Warrior", "Mage", "Mage", "Mage", "Archer", "Archer", "Archer")
)

# Aesthetic mapping을 활용한 산점도
ggplot(data = character_stats, aes(x = attack, y = defense, color = class, size = attack)) +
  geom_point()

해설

이 문제는 aes() 함수의 강력함을 보여줍니다. aes() 내부에 x축, y축 외의 시각적 속성을 추가로 매핑하여 다차원적인 정보를 하나의 그래프에 표현할 수 있습니다.

  • aes(..., color = class): 점의 색상을 class 변수의 값에 따라 다르게 매핑하라는 의미입니다. ggplot2class 변수의 고유한 값("Warrior", "Mage", "Archer")을 자동으로 인식하고, 각각에 다른 색상을 할당한 후 범례(legend)를 생성해 줍니다. 이는 범주형 변수를 시각화하는 매우 효과적인 방법입니다.
  • aes(..., size = attack): 점의 크기를 attack 변수의 값에 비례하도록 매핑하라는 의미입니다. attack 값이 클수록 점이 커집니다. 이는 연속형 변수의 크기를 시각적으로 강조할 때 유용합니다.

이처럼 aes()를 활용하면 x, y 좌표 외에도 색상, 크기, 모양(shape), 투명도(alpha) 등 다양한 시각적 요소에 데이터를 매핑하여 풍부한 정보를 담은 그래프를 만들 수 있습니다.

205. 요일별 커피 판매량 집계 (Base R 막대그래프)

문제 상황: 당신은 카페 체인의 데이터 분석팀 소속입니다. 한 주간의 요일별 커피 판매량 데이터를 바탕으로 어떤 요일에 판매가 가장 많은지 시각적으로 보고해야 합니다.

데이터:

sales_log <- data.frame(
  day = c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Wed", "Fri", "Sat"),
  coffee_cups = c(120, 135, 150, 140, 210, 250, 230, 110, 160, 220, 280)
)

과제:

  1. sales_log 데이터프레임에서 요일(day)별로 총 커피 판매량(coffee_cups)을 계산하세요. (tapply 또는 aggregate 함수 사용)
  2. 계산된 결과를 바탕으로 Base Rbarplot() 함수를 사용하여 요일별 총 판매량을 나타내는 막대그래프를 그리세요.

정답 코드

# 데이터 생성
sales_log <- data.frame(
  day = c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Wed", "Fri", "Sat"),
  coffee_cups = c(120, 135, 150, 140, 210, 250, 230, 110, 160, 220, 280)
)

# 1. 요일별 총 판매량 계산
daily_sales <- tapply(sales_log$coffee_cups, sales_log$day, sum)

# # 대체 방법: aggregate 함수 사용
# daily_sales_df <- aggregate(coffee_cups ~ day, data = sales_log, FUN = sum)
# daily_sales <- setNames(daily_sales_df$coffee_cups, daily_sales_df$day)

# 2. Base R 막대그래프 생성
barplot(daily_sales,
        main = "요일별 커피 판매량",
        xlab = "요일",
        ylab = "총 판매량 (잔)",
        col = "skyblue")

해설

이 문제는 시각화 전 단계로 필요한 데이터 집계(aggregation) 과정을 포함하고 있습니다. 원시 데이터(raw data)를 그대로 시각화하기보다, 분석 목적에 맞게 요약하는 과정이 선행되는 경우가 많습니다.

  • tapply(X, INDEX, FUN): INDEX(그룹화할 기준이 되는 벡터, 여기서는 sales_log$day)의 각 수준(level)에 대해 X(계산할 데이터 벡터, sales_log$coffee_cups)에 FUN(적용할 함수, sum)을 적용합니다. 결과적으로 요일별 합계를 계산한 명명된 벡터(named vector)를 반환합니다.
  • barplot(height): 막대의 높이(height)를 나타내는 벡터를 첫 번째 인자로 받습니다. tapply가 반환한 명명된 벡터를 사용하면, R이 벡터의 이름(names(daily_sales))을 각 막대의 레이블로 자동으로 사용해 줍니다.
  • col 인자를 사용하여 막대의 색상을 지정할 수 있습니다.

206. 더 세련된 커피 판매량 보고 (ggplot2 막대그래프)

문제 상황: 205번 문제의 요일별 커피 판매량 분석을 ggplot2dplyr 패키지를 사용하여 더 현대적이고 세련된 방식으로 수행해보고자 합니다.

데이터: 205번 문제의 sales_log 데이터프레임을 그대로 사용합니다.

과제:

  1. dplyr 패키지의 group_by()summarise() 함수를 사용하여 요일별 총 판매량을 계산하세요.
  2. 계산된 결과를 ggplot2geom_col()을 사용하여 막대그래프로 시각화하세요.
  3. 그래프의 제목과 축 레이블을 적절하게 추가하세요.

정답 코드

library(dplyr)
library(ggplot2)

# 데이터 생성
sales_log <- data.frame(
  day = c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon", "Wed", "Fri", "Sat"),
  coffee_cups = c(120, 135, 150, 140, 210, 250, 230, 110, 160, 220, 280)
)

# 1. dplyr을 이용한 데이터 집계
daily_summary <- sales_log %>%
  group_by(day) %>%
  summarise(total_sales = sum(coffee_cups))

# 2. ggplot2 막대그래프 생성
ggplot(data = daily_summary, aes(x = day, y = total_sales)) +
  geom_col(fill = "steelblue") +
  labs(title = "요일별 커피 판매량",
       x = "요일",
       y = "총 판매량 (잔)")

해설

이 문제는 데이터 처리(dplyr)와 시각화(ggplot2)를 파이프라인(%>%)으로 연결하는 현대 R 데이터 분석의 표준적인 워크플로우를 보여줍니다.

  • dplyr 파이프라인:
    • sales_log %>%: sales_log 데이터프레임을 다음 함수의 첫 번째 인자로 넘깁니다.
    • group_by(day): day 변수를 기준으로 데이터를 그룹화합니다. 이후의 계산은 각 요일 그룹별로 독립적으로 수행됩니다.
    • summarise(total_sales = sum(coffee_cups)): 각 그룹에 대해 sum(coffee_cups)를 계산하고, 그 결과를 total_sales라는 새로운 열에 저장하여 요약된 데이터프레임을 생성합니다.
  • ggplot2 시각화:
    • geom_col() vs geom_bar(): ggplot2에는 막대그래프를 위한 두 가지 주요 geom이 있습니다.
      • geom_col(): x축 위치와 y축 높이를 직접 매핑할 때 사용합니다. 즉, 데이터에 이미 y축의 값이 존재할 때 (total_sales) 사용합니다.
      • geom_bar(): x축 위치만 지정하면, 해당 x값의 빈도(count)를 세어 자동으로 y축 높이를 결정합니다. 원시 데이터에서 각 요일이 몇 번 등장하는지 셀 때 유용합니다. 이 문제처럼 미리 집계된 값을 시각화할 때는 geom_col()이 올바른 선택입니다.
    • fill = "steelblue": geom_col() 안에서 fill 인자를 사용하면 막대의 채우기 색을 지정할 수 있습니다. aes() 바깥에서 색을 지정하면 모든 막대에 동일한 색이 적용됩니다. 만약 aes() 안에 fill = day와 같이 썼다면 요일마다 다른 색이 칠해졌을 것입니다.
    • labs(): 제목(title), 부제(subtitle), 축 레이블(x, y), 범례 제목(color, fill 등)을 한 번에 설정할 수 있는 편리한 함수입니다.

207. 학생 시험 점수 분포 확인 (Base R 히스토그램)

문제 상황: 당신은 한 학급의 수학 시험 점수 데이터를 가지고 있습니다. 학생들의 점수가 전반적으로 어떻게 분포되어 있는지, 특정 점수대에 학생들이 얼마나 몰려 있는지 확인하고 싶습니다.

데이터:

scores <- c(88, 92, 80, 75, 68, 72, 85, 95, 98, 79, 71, 65, 82, 89, 77, 74, 91, 86, 78, 69)

과제: Base Rhist() 함수를 사용하여 scores 데이터의 히스토그램을 생성하세요. 계급(bin)의 개수를 10개로 지정하고, 그래프의 제목과 x축 레이블을 적절하게 설정하세요.

정답 코드

# 데이터 생성
scores <- c(88, 92, 80, 75, 68, 72, 85, 95, 98, 79, 71, 65, 82, 89, 77, 74, 91, 86, 78, 69)

# Base R 히스토그램 생성
hist(scores,
     breaks = 10,
     main = "수학 시험 점수 분포",
     xlab = "점수",
     ylab = "빈도 (학생 수)",
     col = "darkseagreen")

해설

히스토그램은 연속형 변수의 분포를 시각화하는 데 사용되는 기본적인 도구입니다. 데이터의 전체 범위를 여러 개의 동일한 너비의 구간(계급 또는 bin)으로 나눈 뒤, 각 구간에 속하는 데이터 포인트의 개수(빈도)를 막대 높이로 표현합니다.

  • hist(x): 히스토그램을 그리는 기본 함수입니다. x는 숫자형 벡터입니다.
  • breaks: 계급의 개수 또는 계급의 경계점을 지정하는 인자입니다. 정수 값(예: 10)을 주면 R이 데이터를 기반으로 대략 그 개수에 맞게 계급을 나눕니다. 계급의 개수를 어떻게 설정하느냐에 따라 분포의 해석이 달라질 수 있으므로, 여러 값을 시도해보는 것이 좋습니다.
  • main, xlab, ylab: 각각 제목, x축, y축 레이블을 설정합니다. 히스토그램에서 y축은 기본적으로 빈도(Frequency)를 의미합니다.

208. 시험 점수 분포와 밀도 곡선 (ggplot2 히스토그램)

문제 상황: 207번의 학생 시험 점수 데이터를 ggplot2를 사용하여 시각화하고, 분포를 더 부드럽게 표현하는 밀도 곡선(density curve)을 히스토그램 위에 겹쳐서 그리고 싶습니다.

데이터:

scores_df <- data.frame(
  score = c(88, 92, 80, 75, 68, 72, 85, 95, 98, 79, 71, 65, 82, 89, 77, 74, 91, 86, 78, 69)
)

과제:

  1. ggplot2를 사용하여 scores_df 데이터의 히스토그램(geom_histogram)을 생성하세요. 계급의 너비(binwidth)는 5로 설정합니다.
  2. 히스토그램 위에 밀도 곡선(geom_density)을 겹쳐서 그리세요. 밀도 곡선은 빨간색으로 표시합니다.
  3. 히스토그램 막대의 투명도(alpha)를 0.7로 설정하여 뒤의 격자선이 보이도록 하세요.

정답 코드

library(ggplot2)

# 데이터프레임 생성
scores_df <- data.frame(
  score = c(88, 92, 80, 75, 68, 72, 85, 95, 98, 79, 71, 65, 82, 89, 77, 74, 91, 86, 78, 69)
)

# ggplot2 히스토그램 및 밀도 곡선
ggplot(data = scores_df, aes(x = score)) +
  geom_histogram(binwidth = 5, fill = "lightblue", color = "black", alpha = 0.7) +
  geom_density(color = "red", size = 1) +
  labs(title = "수학 시험 점수 분포",
       x = "점수",
       y = "빈도")

해설

이 문제는 ggplot2의 레이어(layer) 개념을 활용하여 여러 geom을 한 그래프에 겹쳐 그리는 방법을 보여줍니다.

  • ggplot(data = scores_df, aes(x = score)): 히스토그램과 밀도 곡선은 하나의 변수에 대한 분포를 보여주므로, aes()에는 x축에 매핑할 변수만 지정합니다.
  • geom_histogram(...): 히스토그램 레이어를 추가합니다.
    • binwidth = 5: 계급의 너비를 5점으로 설정합니다. 65-70, 70-75, ... 와 같은 구간으로 나뉩니다. breaks 대신 binwidth를 사용하는 것이 더 직관적일 때가 많습니다.
    • fill, color: 각각 막대의 채우기 색과 테두리 색을 지정합니다. aes() 바깥에서 지정했으므로 모든 막대에 동일하게 적용됩니다.
    • alpha = 0.7: 투명도를 조절합니다. 1은 완전 불투명, 0은 완전 투명입니다.
  • geom_density(...): 밀도 곡선 레이어를 추가합니다. 밀도 곡선은 히스토그램을 부드러운 곡선 형태로 나타낸 것으로, 데이터의 잠재적인 확률 분포를 추정합니다.
    • color = "red", size = 1: 선의 색상과 두께를 지정합니다.
  • 주의사항: 히스토그램의 y축은 '빈도(count)'이고 밀도 곡선의 y축은 '밀도(density)'로 단위가 다릅니다. 하지만 ggplot2는 이를 자동으로 조정하여 함께 보기 좋게 그려줍니다. 만약 y축을 밀도로 통일하고 싶다면 geom_histogram(aes(y = ..density..))와 같이 내부 변수를 사용할 수 있습니다.

209. 주가 변동 추이 시각화 (Base R 꺾은선 그래프)

문제 상황: 당신은 한 달간의 특정 기술주(TechStock)의 일일 종가를 기록한 데이터를 가지고 있습니다. 시간의 흐름에 따른 주가의 변동 추이를 파악하고 싶습니다.

데이터:

days <- 1:20
closing_price <- c(150, 152, 155, 153, 158, 162, 160, 165, 163, 170, 168, 172, 175, 173, 178, 180, 177, 182, 185, 183)

과제: Base Rplot() 함수를 사용하여 시간(x축: days)에 따른 주가(y축: closing_price)의 변동을 나타내는 꺾은선 그래프(line chart)를 그리세요.

정답 코드

# 데이터 생성
days <- 1:20
closing_price <- c(150, 152, 155, 153, 158, 162, 160, 165, 163, 170, 168, 172, 175, 173, 178, 180, 177, 182, 185, 183)

# Base R 꺾은선 그래프 생성
plot(x = days, y = closing_price,
     type = "l", # "l"은 line을 의미
     main = "TechStock 월간 주가 변동 추이",
     xlab = "거래일",
     ylab = "종가 ($)",
     col = "darkgreen",
     lwd = 2) # 선의 두께(line width)

해설

plot() 함수는 type 인자를 통해 다양한 형태의 그래프를 그릴 수 있습니다.

  • type = "l": 점들을 선(line)으로 연결하여 꺾은선 그래프를 만듭니다.
  • type 인자의 다른 옵션들:
    • "p": 점 (기본값)
    • "b": 점과 선을 모두 그림 (both)
    • "o": 점과 선을 모두 그리되, 선이 점을 덮음 (overplotted)
    • "h": 각 점에서 x축까지 수직선 (histogram-like)
    • "s": 계단 형태의 선 (steps)
  • lwd: 선의 두께(line width)를 조절하는 인자입니다. 기본값은 1입니다.

꺾은선 그래프는 주가, 기온 변화, 웹사이트 트래픽 등과 같이 시간의 흐름에 따른 연속적인 데이터의 추세를 보여주는 데 매우 효과적입니다.

210. 경쟁사 주가 비교 분석 (ggplot2 꺾은선 그래프)

문제 상황: 209번 문제에 더해, 경쟁사인 FutureCorp의 주가 데이터도 확보했습니다. 두 회사의 주가 추이를 하나의 그래프에서 비교하여 분석하고 싶습니다.

데이터:

stock_data <- data.frame(
  day = rep(1:20, 2),
  price = c(150, 152, 155, 153, 158, 162, 160, 165, 163, 170, 168, 172, 175, 173, 178, 180, 177, 182, 185, 183, # TechStock
            140, 141, 145, 143, 142, 148, 150, 151, 155, 153, 158, 160, 159, 162, 165, 163, 168, 170, 172, 171), # FutureCorp
  company = rep(c("TechStock", "FutureCorp"), each = 20)
)

과제: ggplot2를 사용하여 두 회사의 주가 추이를 하나의 꺾은선 그래프로 그리세요. 각 회사의 선을 다른 색상으로 구분하고, 범례가 자동으로 생성되도록 하세요.

정답 코드

library(ggplot2)

# 데이터프레임 생성
stock_data <- data.frame(
  day = rep(1:20, 2),
  price = c(150, 152, 155, 153, 158, 162, 160, 165, 163, 170, 168, 172, 175, 173, 178, 180, 177, 182, 185, 183,
            140, 141, 145, 143, 142, 148, 150, 151, 155, 153, 158, 160, 159, 162, 165, 163, 168, 170, 172, 171),
  company = rep(c("TechStock", "FutureCorp"), each = 20)
)

# ggplot2 다중 꺾은선 그래프
ggplot(data = stock_data, aes(x = day, y = price, color = company)) +
  geom_line(size = 1) +
  labs(title = "경쟁사 주가 비교",
       x = "거래일",
       y = "종가 ($)",
       color = "회사명") # 범례 제목 변경

해설

이 문제는 ggplot2의 미학적 매핑(aes)을 활용하여 여러 그룹의 데이터를 하나의 그래프에 효과적으로 표현하는 방법을 보여줍니다.

  • aes(x = day, y = price, color = company): 이 코드의 핵심입니다. color 속성에 company 변수를 매핑함으로써, ggplot2company의 각 고유 값("TechStock", "FutureCorp")에 대해 별도의 선을 그리고 다른 색상을 할당합니다. 또한, 어떤 색이 어떤 회사를 나타내는지 보여주는 범례를 자동으로 생성합니다.
  • geom_line(): 꺾은선 그래프를 그리는 geom입니다. size 인자로 선의 두께를 조절할 수 있습니다.
  • 데이터 형식: ggplot2로 여러 그룹을 한 번에 그리려면 이 문제의 stock_data와 같은 long format(tidy format) 데이터가 매우 효율적입니다. 각 관측치가 하나의 행을 구성하고, 변수들이 열을 구성하는 형태입니다. 만약 TechStock과 FutureCorp의 주가가 별도의 열에 있었다면(wide format), tidyr::pivot_longer()와 같은 함수로 long format으로 변환한 후 시각화하는 것이 일반적입니다.

211. 우주선 엔진 효율 비교 (Base R 상자 그림)

문제 상황: 당신은 우주 항공국의 데이터 과학자입니다. 3개의 다른 제조사(A, B, C)에서 납품한 로켓 엔진의 연소 효율 데이터를 분석하여, 어떤 제조사의 엔진 성능이 가장 안정적이고 우수한지 비교하고자 합니다.

데이터:

engine_data <- data.frame(
  efficiency = c(98.5, 99.1, 98.2, 98.8, 98.4, 97.5, 97.8, 96.9, 97.2, 97.1, 99.2, 99.5, 99.0, 99.3, 99.6),
  manufacturer = rep(c("A", "B", "C"), each = 5)
)

과제: Base Rboxplot() 함수를 사용하여 제조사(manufacturer)별 엔진 효율(efficiency)의 분포를 비교하는 상자 그림(boxplot)을 그리세요.

정답 코드

# 데이터 생성
engine_data <- data.frame(
  efficiency = c(98.5, 99.1, 98.2, 98.8, 98.4, 97.5, 97.8, 96.9, 97.2, 97.1, 99.2, 99.5, 99.0, 99.3, 99.6),
  manufacturer = rep(c("A", "B", "C"), each = 5)
)

# Base R 상자 그림 생성
boxplot(efficiency ~ manufacturer, data = engine_data,
        main = "제조사별 엔진 효율 분포",
        xlab = "제조사",
        ylab = "연소 효율 (%)",
        col = c("gold", "lightblue", "lightgreen"))

해설

상자 그림(Boxplot)은 데이터의 분포를 5가지 요약 수치(최솟값, 1사분위수, 중앙값, 3사분위수, 최댓값)로 시각화하여 여러 그룹 간의 분포를 비교하는 데 매우 유용한 도구입니다.

  • boxplot(formula, data = ...): boxplot() 함수는 y ~ x 형태의 포뮬러(formula)를 지원합니다.
    • efficiency ~ manufacturer: "manufacturer에 따라 efficiency의 분포를 그려라"는 의미입니다. ~ 기호의 왼쪽이 y축 변수(숫자형), 오른쪽이 x축 변수(범주형)가 됩니다.
    • data = engine_data: 포뮬러에 사용된 변수들이 포함된 데이터프레임을 지정합니다.
  • 상자 그림 해석:
    • 상자의 하단, 중앙선, 상단: 각각 1사분위수($Q_1$, 25%), 중앙값(median, 50%), 3사분위수($Q_3$, 75%)를 나타냅니다. 상자의 길이는 사분위수 범위(IQR, Interquartile Range) 즉, $IQR = Q_3 - Q_1$ 입니다.
    • 수염(Whisker): 보통 상자 끝에서부터 $1.5 \times IQR$ 범위 내에 있는 최댓값과 최솟값을 나타냅니다.
    • 이상치(Outlier): 수염의 범위를 벗어나는 점들은 개별 점으로 표시됩니다.
  • 분석적 통찰: 이 그래프를 통해 C 제조사 엔진이 중앙값과 전체적인 효율 수치가 가장 높고, B 제조사 엔진은 효율의 변동성(상자의 길이)이 상대적으로 크다는 것을 한눈에 파악할 수 있습니다.

212. 엔진 효율 분포 심층 분석 (ggplot2 상자 그림 + 점)

문제 상황: 211번의 상자 그림 분석을 ggplot2로 수행하면서, 각 데이터 포인트의 실제 위치도 함께 보고 싶습니다. 상자 그림 위에 실제 데이터 포인트를 함께 표시하면 분포를 더 정확하게 이해할 수 있습니다.

데이터: 211번의 engine_data 데이터프레임을 그대로 사용합니다.

과제: ggplot2를 사용하여 다음 요구사항을 만족하는 그래프를 그리세요.

  1. 제조사(manufacturer)별 엔진 효율(efficiency)을 나타내는 상자 그림(geom_boxplot)을 그리세요.
  2. 상자 그림 위에 실제 데이터 포인트를 겹쳐서 그리세요. 점들이 서로 겹치지 않도록 약간의 노이즈를 추가하는 geom_jitter를 사용하세요.
  3. 제조사별로 상자 그림과 점의 색상을 다르게 표현하세요.

정답 코드

library(ggplot2)

# 데이터 생성
engine_data <- data.frame(
  efficiency = c(98.5, 99.1, 98.2, 98.8, 98.4, 97.5, 97.8, 96.9, 97.2, 97.1, 99.2, 99.5, 99.0, 99.3, 99.6),
  manufacturer = rep(c("A", "B", "C"), each = 5)
)

# ggplot2 상자 그림 + Jitter plot
ggplot(data = engine_data, aes(x = manufacturer, y = efficiency, fill = manufacturer)) +
  geom_boxplot(alpha = 0.6) +
  geom_jitter(width = 0.2, alpha = 0.8) +
  labs(title = "제조사별 엔진 효율 분포 (실제 데이터 포함)",
       x = "제조사",
       y = "연소 효율 (%)") +
  theme_minimal() + 
  guides(fill = "none") # 범례 숨기기 (x축 레이블과 정보 중복)

해설

이 문제는 ggplot2의 레이어링 기능을 활용하여 두 가지 geom을 결합하는 강력한 시각화 기법을 보여줍니다.

  • ggplot(..., aes(..., fill = manufacturer)): fill 미학을 manufacturer에 매핑하여 상자 그림의 채우기 색을 제조사별로 다르게 지정합니다.
  • geom_boxplot(alpha = 0.6): 상자 그림 레이어를 추가합니다. alpha를 조절하여 반투명하게 만들어 뒤에 올 점들이 잘 보이도록 합니다.
  • geom_jitter(...): 산점도를 그리되, 점들이 정확히 같은 x좌표에 겹쳐서 보이지 않는 문제를 해결하기 위해 x축 방향으로 약간의 무작위 노이즈를 추가합니다.
    • width = 0.2: 점들이 흩어지는 폭을 조절합니다.
  • 결합의 장점: 상자 그림은 데이터의 요약 통계량을 보여주지만, 실제 데이터 포인트의 개수나 밀집도를 숨기는 단점이 있습니다. Jitter plot은 실제 데이터의 위치를 보여줍니다. 이 둘을 결합하면 데이터의 전반적인 분포(상자 그림)와 개별 데이터 포인트의 위치(jitter)를 동시에 파악할 수 있어 훨씬 풍부한 분석이 가능합니다.
  • theme_minimal(): 그래프의 배경, 격자 등 전반적인 테마를 깔끔한 미니멀 스타일로 변경합니다.
  • guides(fill = "none"): fill 미학에 의해 생성된 범례를 제거합니다. x축에 이미 제조사 정보가 있으므로 범례는 불필요한 중복이기 때문입니다.

213. 자동차 실린더별 연비 분석 (ggplot2 Faceting)

문제 상황: 당신은 자동차 성능 분석가입니다. R에 내장된 mtcars 데이터셋을 사용하여, 자동차의 무게(wt)와 연비(mpg) 사이의 관계를 분석하고자 합니다. 그런데 이 관계가 자동차 실린더 개수(cyl)에 따라 다를 것이라고 예상됩니다.

과제: ggplot2패시팅(Faceting) 기능을 사용하여, 실린더 개수(4, 6, 8)별로 무게(wt)와 연비(mpg)의 관계를 나타내는 산점도를 별도의 하위 그래프(subplot)로 나누어 그리세요.

정답 코드

library(ggplot2)

# mtcars 데이터셋은 R에 내장되어 있으므로 바로 사용 가능

# Faceting을 이용한 산점도
ggplot(data = mtcars, aes(x = wt, y = mpg)) +
  geom_point() +
  facet_wrap(~ cyl) +
  labs(title = "실린더 개수별 자동차 무게와 연비의 관계",
       x = "무게 (1000 lbs)",
       y = "연비 (Miles/Gallon)")

해설

패시팅은 ggplot2의 가장 강력한 기능 중 하나로, 특정 범주형 변수의 수준(level)에 따라 데이터를 하위 그룹으로 나누고, 각 그룹에 대해 동일한 형태의 그래프를 그려 나란히 배열하는 기능입니다.

  • facet_wrap(~ cyl): 이 코드가 패시팅을 수행하는 부분입니다.
    • ~ 기호 뒤에 그룹을 나눌 기준이 되는 변수(cyl)를 지정합니다.
    • facet_wrap()은 가능한 한 정사각형에 가깝게 하위 그래프들을 배열합니다.
    • 만약 두 개의 변수로 그룹을 나누고 싶다면 facet_grid(row_var ~ col_var)를 사용할 수 있습니다.
  • 분석적 가치: 전체 데이터를 하나의 산점도로 그리면 '무게가 무거울수록 연비가 낮아진다'는 일반적인 경향만 보입니다. 하지만 패시팅을 통해 실린더 개수별로 나누어 보면, 각 그룹 내에서의 관계를 더 명확히 볼 수 있습니다. 예를 들어, 4기통, 6기통, 8기통 그룹 각각에서 무게와 연비의 음의 상관관계가 뚜렷하게 나타나며, 그룹 간의 연비와 무게 분포 차이도 명확하게 비교할 수 있습니다. 이는 복잡한 데이터의 패턴을 발견하는 데 매우 효과적인 탐색적 데이터 분석(EDA) 기법입니다.

214. 종합 분석: 판타지 캐릭터 능력치 시각화

문제 상황: 당신은 판타지 게임의 밸런스 디자이너입니다. 다양한 직업(전사, 마법사, 도적)의 캐릭터들의 공격력(attack)과 체력(health) 데이터를 분석하여 직업별 특성과 전체적인 밸런스를 확인하고자 합니다.

데이터:

fantasy_rpg <- data.frame(
  attack = c(150, 160, 140, 70, 60, 80, 110, 120, 100, 155, 65, 115),
  health = c(200, 220, 190, 120, 110, 130, 150, 160, 140, 210, 115, 155),
  class = rep(c("Warrior", "Mage", "Thief"), each = 4)
)

과제: ggplot2를 사용하여 다음의 모든 요구사항을 만족하는 종합적인 그래프를 만드세요.

  1. 공격력(attack)을 x축, 체력(health)을 y축으로 하는 산점도를 그리세요.
  2. 점의 색상을 직업(class)에 따라 다르게 표현하세요.
  3. 각 직업 그룹별로 공격력과 체력의 관계를 나타내는 추세선(회귀선)을 추가하세요. (geom_smooth 사용)
  4. 그래프의 제목을 "직업별 공격력과 체력 분포"로, x축과 y축 레이블을 각각 "공격력", "체력"으로 설정하세요.
  5. 배경이 깔끔한 theme_bw() 테마를 적용하세요.

정답 코드

library(ggplot2)

# 데이터 생성
fantasy_rpg <- data.frame(
  attack = c(150, 160, 140, 70, 60, 80, 110, 120, 100, 155, 65, 115),
  health = c(200, 220, 190, 120, 110, 130, 150, 160, 140, 210, 115, 155),
  class = rep(c("Warrior", "Mage", "Thief"), each = 4)
)

# 종합 분석 그래프
ggplot(data = fantasy_rpg, aes(x = attack, y = health, color = class)) +
  geom_point(size = 3, alpha = 0.8) +
  geom_smooth(method = "lm", se = FALSE, size = 1) +
  labs(title = "직업별 공격력과 체력 분포",
       x = "공격력",
       y = "체력",
       color = "직업") +
  theme_bw()

해설

이 문제는 여러 geom과 설정들을 조합하여 정보가 풍부하고 시각적으로 뛰어난 그래프를 만드는 과정을 보여줍니다.

  • aes(x = attack, y = health, color = class): 기본 ggplot 객체에 미학적 매핑을 정의합니다. color = class 매핑은 이후에 추가되는 geom_pointgeom_smooth에 모두 상속되어, 점과 선의 색상이 직업별로 구분됩니다.
  • geom_point(...): 산점도 레이어입니다. 점의 크기와 투명도를 조절하여 가독성을 높였습니다.
  • geom_smooth(method = "lm", se = FALSE, ...): 데이터의 추세를 보여주는 평활 곡선(smoothed conditional mean)을 추가하는 레이어입니다.
    • method = "lm": 평활 방법으로 선형 모델(Linear Model), 즉 직선 회귀선을 사용하도록 지정합니다.
    • se = FALSE: 추세선 주변의 신뢰 구간(confidence interval) 음영을 표시하지 않도록 설정합니다.
  • theme_bw(): 흑백(Black & White) 테마를 적용하여 흰 배경에 검은 격자선을 가진 깔끔한 모양의 그래프를 만듭니다.

이 그래프를 통해 각 직업군의 능력치가 어느 영역에 분포하는지(전사는 공격력과 체력이 모두 높음), 그리고 각 직업 내에서 공격력과 체력 사이에 어떤 관계가 있는지(모든 직업에서 공격력이 높을수록 체력도 높은 경향)를 한 번에 파악할 수 있습니다.

215. 분석 결과 저장하기 (ggsave)

문제 상황: 214번 문제에서 만든 멋진 그래프를 보고서에 삽입하거나 동료에게 공유하기 위해 이미지 파일로 저장해야 합니다.

과제:

  1. 214번 문제에서 생성한 ggplot 객체를 p라는 변수에 저장하세요.
  2. ggsave() 함수를 사용하여 p 객체를 "character_analysis.png"라는 이름의 PNG 파일로 저장하세요.
  3. 저장될 이미지의 너비(width)는 8인치, 높이(height)는 6인치, 해상도(dpi)는 300으로 설정하세요.

정답 코드

library(ggplot2)

# 데이터 생성 (214번과 동일)
fantasy_rpg <- data.frame(
  attack = c(150, 160, 140, 70, 60, 80, 110, 120, 100, 155, 65, 115),
  health = c(200, 220, 190, 120, 110, 130, 150, 160, 140, 210, 115, 155),
  class = rep(c("Warrior", "Mage", "Thief"), each = 4)
)

# 1. ggplot 객체를 변수에 저장
p <- ggplot(data = fantasy_rpg, aes(x = attack, y = health, color = class)) +
  geom_point(size = 3, alpha = 0.8) +
  geom_smooth(method = "lm", se = FALSE, size = 1) +
  labs(title = "직업별 공격력과 체력 분포",
       x = "공격력",
       y = "체력",
       color = "직업") +
  theme_bw()

# 2 & 3. ggsave() 함수로 파일 저장
ggsave(filename = "character_analysis.png", 
       plot = p, 
       width = 8, 
       height = 6, 
       units = "in", 
       dpi = 300)

# (참고) Base R 플롯을 저장하는 방법
# png("base_plot_example.png", width = 800, height = 600) # 파일 열기
# boxplot(efficiency ~ manufacturer, data = engine_data, main = "엔진 효율") # 플롯 그리기
# dev.off() # 파일 닫기

해설

시각화의 마지막 단계는 결과를 공유하고 활용하기 위해 파일로 저장하는 것입니다. ggplot2는 이를 위한 매우 편리한 ggsave() 함수를 제공합니다.

  • p <- ggplot(...): ggplot으로 생성된 그래프는 그 자체로 하나의 R 객체(list)입니다. 이를 변수에 할당하면 나중에 수정하거나 저장하는 등의 작업을 쉽게 할 수 있습니다.
  • ggsave(filename, plot, ...): ggplot 객체를 파일로 저장하는 함수입니다.
    • filename: 저장할 파일의 이름과 확장자를 지정합니다. 확장자(.png, .pdf, .jpg 등)에 따라 파일 형식이 자동으로 결정됩니다.
    • plot: 저장할 ggplot 객체를 지정합니다. 생략하면 가장 마지막에 생성된 ggplot 그래프가 자동으로 저장됩니다. 하지만 명시적으로 지정하는 것이 더 안전한 코딩 습관입니다.
    • width, height, units: 이미지의 너비, 높이, 그리고 그 단위를 ("in", "cm", "mm") 지정합니다.
    • dpi: Dots Per Inch, 즉 해상도를 의미합니다. 인쇄용이나 고화질 이미지가 필요할 경우 300 이상의 값을 사용하는 것이 일반적입니다.
  • Base R 과의 비교: 해설 코드의 참고 부분에 있듯이, Base R 플롯을 저장하려면 png()(또는 jpeg(), pdf()) 같은 그래픽 장치(device) 함수로 파일을 열고, 플롯 명령을 실행한 뒤, dev.off()로 장치를 닫아주어야 합니다. 이에 비해 ggsave()는 단일 함수로 모든 것을 처리하므로 훨씬 직관적이고 편리합니다.

네, 알겠습니다. 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 사용자의 학습 여정에 큰 도움이 될 흥미롭고 깊이 있는 문제들을 생성해 드리겠습니다.

주제: 데이터 시각화 2: ggplot2 심화 (레이아웃, 테마, 그룹핑, geom 혼합) 범위: 중급 2단계 (216~230번)


216. 분기별 게임 아이템 판매량 대시보드 만들기 (facet_wrap 기초)

문제 상황: 당신은 인기 MMORPG 게임 'R-Chronicles'의 데이터 분석가입니다. 운영팀에서 게임 내 상점의 아이템 타입(무기, 방어구, 포션)별로 분기별 판매량 추이를 한눈에 보고 싶어 합니다. 각 아이템 타입을 별도의 패널(panel)에 그려서 비교하기 쉬운 시각화 자료를 만들어야 합니다.

과제 지시:

  1. 아래 데이터를 사용하여 tibble을 생성하세요.
  2. ggplot2를 사용하여 quarter를 x축, sales를 y축으로 하는 꺾은선 그래프(geom_line)와 점 그래프(geom_point)를 그리세요.
  3. facet_wrap() 함수를 사용하여 item_type 별로 그래프를 나누어 3개의 패널로 구성된 대시보드를 만드세요.
  4. 각 패널의 상단에 아이템 타입이 명확히 표시되도록 하세요.
# 데이터 생성
library(tibble)
sales_data <- tribble(
  ~quarter, ~item_type, ~sales,
  "Q1", "Weapon",   120,
  "Q2", "Weapon",   150,
  "Q3", "Weapon",   130,
  "Q4", "Weapon",   180,
  "Q1", "Armor",    200,
  "Q2", "Armor",    220,
  "Q3", "Armor",    250,
  "Q4", "Armor",    230,
  "Q1", "Potion",   500,
  "Q2", "Potion",   450,
  "Q3", "Potion",   600,
  "Q4", "Potion",   580
)

정답 코드

library(ggplot2)
library(tibble)

# 데이터 생성
sales_data <- tribble(
  ~quarter, ~item_type, ~sales,
  "Q1", "Weapon",   120,
  "Q2", "Weapon",   150,
  "Q3", "Weapon",   130,
  "Q4", "Weapon",   180,
  "Q1", "Armor",    200,
  "Q2", "Armor",    220,
  "Q3", "Armor",    250,
  "Q4", "Armor",    230,
  "Q1", "Potion",   500,
  "Q2", "Potion",   450,
  "Q3", "Potion",   600,
  "Q4", "Potion",   580
)

# 분기 순서 지정을 위한 factor 변환
sales_data$quarter <- factor(sales_data$quarter, levels = c("Q1", "Q2", "Q3", "Q4"))

# 시각화
ggplot(sales_data, aes(x = quarter, y = sales, group = 1)) +
  geom_line(color = "steelblue") +
  geom_point(color = "darkred", size = 3) +
  facet_wrap(~ item_type) +
  labs(
    title = "R-Chronicles: 분기별 아이템 타입 판매량",
    x = "분기",
    y = "판매량 (개)"
  ) +
  theme_bw()

해설

이 문제는 facet_wrap()을 사용하여 범주형 변수(여기서는 item_type)의 각 레벨에 대해 별도의 서브플롯(subplot)을 생성하는 방법을 다룹니다. 이를 "small multiples" 기법이라고도 부르며, 여러 그룹 간의 패턴을 비교하는 데 매우 효과적입니다.

  • facet_wrap(~ item_type): item_type 컬럼의 고유한 값("Weapon", "Armor", "Potion") 각각에 대해 별도의 패널을 만들라는 명령어입니다. ~ 기호 뒤에 패널을 나눌 기준 변수를 지정합니다.
  • aes(group = 1): facet_wrap을 사용할 때, 각 패널 내에서 데이터를 어떻게 연결할지 명시해야 할 때가 있습니다. x축이 범주형(quarter)일 때, ggplot은 각 점을 어떻게 선으로 연결해야 할지 모를 수 있습니다. group = 1은 각 패널 내의 모든 데이터를 하나의 그룹으로 취급하여 선으로 연결하라는 의미입니다. 만약 이 옵션이 없으면 geom_line이 제대로 그려지지 않을 수 있습니다.
  • factor()levels: quarter는 문자열이므로 "Q1", "Q10", "Q2" 순으로 정렬될 수 있습니다. factor() 함수와 levels 인자를 사용하여 "Q1", "Q2", "Q3", "Q4"의 명확한 순서를 지정해주면, x축이 우리가 의도한 시간 순서대로 표시됩니다.

217. 서버 및 클래스별 캐릭터 스탯 비교 (facet_grid)

문제 상황: 당신은 'R-Chronicles' 게임의 밸런스 담당자입니다. 두 개의 서버(Asgard, Vanaheim)에 존재하는 세 가지 클래스(Warrior, Mage, Archer)의 평균 공격력(ATK)과 방어력(DEF) 분포를 비교하여 서버 간, 클래스 간 밸런스가 적절한지 확인해야 합니다.

과제 지시:

  1. 아래 데이터를 사용하여 tibble을 생성하세요.
  2. ggplot2를 사용하여 ATK를 x축, DEF를 y축으로 하는 산점도(geom_point)를 그리세요.
  3. facet_grid() 함수를 사용하여 행(row)은 server 별로, 열(column)은 class 별로 나뉘는 격자 형태의 시각화를 만드세요.
  4. 각 점의 색상은 server에 따라 다르게 표시하세요.
# 데이터 생성
set.seed(42)
library(tibble)
player_stats <- tibble(
  server = rep(c("Asgard", "Vanaheim"), each = 150),
  class = rep(rep(c("Warrior", "Mage", "Archer"), each = 50), 2),
  ATK = c(rnorm(50, 150, 20), rnorm(50, 100, 15), rnorm(50, 120, 25),
          rnorm(50, 155, 20), rnorm(50, 95, 15), rnorm(50, 125, 25)),
  DEF = c(rnorm(50, 200, 30), rnorm(50, 80, 10), rnorm(50, 100, 15),
          rnorm(50, 205, 30), rnorm(50, 85, 10), rnorm(50, 95, 15))
)

정답 코드

library(ggplot2)
library(tibble)

# 데이터 생성
set.seed(42)
player_stats <- tibble(
  server = rep(c("Asgard", "Vanaheim"), each = 150),
  class = rep(rep(c("Warrior", "Mage", "Archer"), each = 50), 2),
  ATK = c(rnorm(50, 150, 20), rnorm(50, 100, 15), rnorm(50, 120, 25),
          rnorm(50, 155, 20), rnorm(50, 95, 15), rnorm(50, 125, 25)),
  DEF = c(rnorm(50, 200, 30), rnorm(50, 80, 10), rnorm(50, 100, 15),
          rnorm(50, 205, 30), rnorm(50, 85, 10), rnorm(50, 95, 15))
)

# 시각화
ggplot(player_stats, aes(x = ATK, y = DEF, color = server)) +
  geom_point(alpha = 0.7) +
  facet_grid(server ~ class) +
  labs(
    title = "서버 및 클래스별 캐릭터 스탯 분포",
    subtitle = "행: 서버, 열: 클래스",
    x = "공격력 (ATK)",
    y = "방어력 (DEF)",
    color = "서버"
  ) +
  theme_minimal() +
  theme(legend.position = "none") # 색상이 facet 레이블과 중복되므로 범례 제거

해설

이 문제는 두 개의 변수를 기준으로 데이터를 격자 형태로 나누는 facet_grid()의 사용법을 보여줍니다. 이는 두 요인의 모든 조합에 대한 데이터 패턴을 체계적으로 비교할 때 매우 유용합니다.

  • facet_grid(server ~ class): facet_grid()의 문법은 행에 올 변수 ~ 열에 올 변수 형식입니다. 이 코드는 server의 고유값("Asgard", "Vanaheim")을 기준으로 행을 나누고, class의 고유값("Warrior", "Mage", "Archer")을 기준으로 열을 나눕니다. 결과적으로 2x3=6개의 패널이 생성됩니다.
  • facet_wrap vs facet_grid:
    • facet_wrap(~ var): 한 변수를 기준으로 패널을 만들고, 공간이 차면 다음 줄로 넘어갑니다. 1차원적인 비교에 적합합니다.
    • facet_grid(var1 ~ var2): 두 변수의 조합으로 격자를 만듭니다. 모든 조합을 보여주므로 2차원적인 비교에 적합하며, 같은 행이나 열에 있는 플롯들은 각각 x축, y축을 공유하여 비교가 더 용이합니다.
  • theme(legend.position = "none"): color = server로 인해 범례가 생성되지만, facet_grid의 행 레이블이 이미 서버 정보를 명확히 보여주고 있으므로 정보가 중복됩니다. 이 경우 범례를 제거하여 플롯을 더 깔끔하게 만들 수 있습니다.

218. 추세선과 신뢰구간 추가하기 (geom_smooth 혼합)

문제 상황: 한 스타트업의 마케팅 분석가인 당신은 지난 1년간의 광고비 지출과 그에 따른 웹사이트 방문자 수 데이터를 분석하고 있습니다. 두 변수 간의 관계를 시각화하고, 그 관계의 전반적인 추세와 불확실성을 함께 표현해야 합니다.

과제 지시:

  1. 아래 데이터를 사용하여 tibble을 생성하세요.
  2. ggplot2를 사용하여 ad_spending을 x축, visitors를 y축으로 하는 산점도(geom_point)를 그리세요.
  3. 산점도 위에 데이터의 추세를 나타내는 선형 회귀선(geom_smooth 사용, method = "lm")을 추가하세요.
  4. 회귀선 주변에 95% 신뢰구간(confidence interval)을 음영으로 표시하세요. (geom_smooth의 기본값)
  5. 추세선은 파란색, 신뢰구간의 채우기 색은 회색 계열로, 점들은 검은색으로 설정하여 가독성을 높이세요.
# 데이터 생성
set.seed(123)
marketing_data <- tibble(
  day = 1:365,
  ad_spending = runif(365, 50, 500),
  visitors = 100 + 2.5 * ad_spending + rnorm(365, 0, 200)
)

정답 코드

library(ggplot2)
library(tibble)

# 데이터 생성
set.seed(123)
marketing_data <- tibble(
  day = 1:365,
  ad_spending = runif(365, 50, 500),
  visitors = 100 + 2.5 * ad_spending + rnorm(365, 0, 200)
)

# 시각화
ggplot(marketing_data, aes(x = ad_spending, y = visitors)) +
  geom_point(color = "black", alpha = 0.5) +
  geom_smooth(method = "lm", color = "blue", fill = "lightgray", se = TRUE) +
  labs(
    title = "광고비 지출에 따른 웹사이트 방문자 수",
    subtitle = "선형 회귀 추세선과 95% 신뢰구간",
    x = "일일 광고비 ($)",
    y = "일일 방문자 수"
  ) +
  theme_light()

해설

이 문제는 서로 다른 geom을 겹쳐서 더 풍부한 정보를 전달하는 방법을 보여줍니다. geom_point로 원본 데이터를 보여주고, geom_smooth로 데이터의 잠재적인 패턴이나 모델을 시각화하는 것은 데이터 탐색의 매우 일반적인 단계입니다.

  • geom_smooth(method = "lm", se = TRUE): 이 함수는 데이터에 평활기(smoother)를 적합시켜 추세선을 그립니다.
    • method = "lm": 사용할 평활 방법을 지정합니다. "lm"은 선형 모델(Linear Model), 즉 직선 회귀선을 의미합니다. 데이터가 1,000개 미만일 때 기본값은 loess (국소 회귀)이므로, 명확한 직선을 원할 경우 lm으로 지정해야 합니다.
    • se = TRUE: se는 표준 오차(Standard Error)를 의미하며, TRUE로 설정하면 추세선 주변에 신뢰구간(confidence interval)을 음영으로 표시합니다. 이것이 추정된 추세선이 얼마나 불확실한지를 시각적으로 보여주는 중요한 정보입니다. 기본값은 TRUE입니다.
  • 계층(Layers) 구조: ggplot2는 마치 포토샵의 레이어처럼 작동합니다. ggplot()으로 기본 캔버스와 좌표계를 설정한 뒤, + 기호를 사용하여 geom_point(), geom_smooth() 같은 그래픽 레이어를 차례로 쌓아 올리는 방식입니다. 이 구조 덕분에 복잡한 시각화를 논리적으로 구성할 수 있습니다.
  • 수학적 배경: geom_smooth(method = "lm")은 최소제곱법(Ordinary Least Squares)을 사용하여 잔차(실제값과 예측값의 차이)의 제곱합을 최소화하는 직선 $y = \beta_0 + \beta_1 x$을 찾습니다. 신뢰구간은 각 x값에 대한 y의 평균 예측값이 특정 확률(기본 95%)로 포함될 범위를 나타냅니다. 즉, 우리가 수집한 데이터와 비슷한 데이터를 다시 수집하여 회귀선을 그릴 경우, 100번 중 95번은 이 음영 영역 내에 회귀선이 그려질 것이라는 의미입니다.

219. 박스플롯 위에 실제 데이터 분포 함께 보기 (geom_jitter 혼합)

문제 상황: 신약 개발 연구팀에서 세 가지 다른 약물(A, B, C)이 환자의 특정 효소 수치에 미치는 영향을 분석하고 있습니다. 각 약물 그룹의 데이터 분포를 요약해서 보여주는 동시에, 개별 데이터 포인트의 실제 분포와 샘플 크기도 함께 보여주고 싶습니다.

과제 지시:

  1. 아래 데이터를 사용하여 tibble을 생성하세요.
  2. ggplot2를 사용하여 drug를 x축, enzyme_level을 y축으로 하는 박스플롯(geom_boxplot)을 그리세요. 각 약물 그룹에 대해 다른 색상으로 채우세요.
  3. 박스플롯 위에 각 데이터 포인트를 나타내는 geom_jitter를 추가하세요. geom_jitter는 점들이 서로 겹치지 않도록 약간의 무작위 노이즈를 추가합니다.
  4. Jitter된 점들이 박스플롯을 너무 가리지 않도록 투명도(alpha)와 크기(size)를 조절하고, 색상은 검은색으로 설정하세요.
# 데이터 생성
set.seed(999)
drug_test_data <- tibble(
  drug = rep(c("Drug A", "Drug B", "Drug C"), each = 40),
  enzyme_level = c(rnorm(40, 10, 2), rnorm(40, 15, 3), rnorm(40, 12, 2.5))
)

정답 코드

library(ggplot2)
library(tibble)

# 데이터 생성
set.seed(999)
drug_test_data <- tibble(
  drug = rep(c("Drug A", "Drug B", "Drug C"), each = 40),
  enzyme_level = c(rnorm(40, 10, 2), rnorm(40, 15, 3), rnorm(40, 12, 2.5))
)

# 시각화
ggplot(drug_test_data, aes(x = drug, y = enzyme_level, fill = drug)) +
  geom_boxplot(alpha = 0.7, outlier.shape = NA) + # 원본 데이터 포인트를 그릴 것이므로, 박스플롯의 이상치는 그리지 않음
  geom_jitter(width = 0.2, height = 0, color = "black", size = 1.5, alpha = 0.5) +
  labs(
    title = "약물 종류에 따른 효소 수치 변화",
    subtitle = "박스플롯과 실제 데이터 분포",
    x = "약물 종류",
    y = "효소 수치"
  ) +
  theme_classic() +
  theme(legend.position = "none") # x축 레이블과 fill 색상이 중복되므로 범례 제거

해설

이 문제는 요약 통계(박스플롯)와 원본 데이터(지터 플롯)를 결합하여 데이터에 대한 더 깊은 통찰을 얻는 방법을 보여줍니다. 이러한 시각화는 "하이브리드 플롯"의 한 종류입니다.

  • geom_boxplot(): 데이터의 5가지 요약 수치(최소값, 1사분위수(Q1), 중앙값(Q2), 3사분위수(Q3), 최대값)를 시각적으로 보여줍니다. 데이터의 중심 경향성, 퍼짐, 비대칭성을 빠르게 파악할 수 있습니다.
    • outlier.shape = NA: 박스플롯은 기본적으로 IQR(사분위수 범위)의 1.5배를 벗어나는 점들을 '이상치'로 간주하여 따로 표시합니다. 하지만 우리는 모든 점을 geom_jitter로 그릴 것이므로, 중복을 피하기 위해 박스플롯의 이상치 표시는 제거합니다.
  • geom_jitter(): geom_point()와 유사하지만, 점들의 위치에 약간의 무작위 변동(jitter)을 가하여 점들이 서로 겹쳐 보이지 않게 합니다.
    • width = 0.2: x축 방향으로 얼마나 넓게 점을 퍼뜨릴지 지정합니다.
    • height = 0: y축 방향으로는 퍼뜨리지 않도록 설정합니다. (y값은 실제 데이터이므로 변경하면 안 됨)
  • 왜 이 조합이 강력한가?: 박스플롯만으로는 데이터의 실제 분포 형태(예: 데이터가 두 개의 군집으로 나뉘어 있는지, 데이터 포인트가 몇 개인지 등)를 알 수 없습니다. geom_jitter를 추가하면 박스플롯이 요약하는 정보의 이면에 있는 실제 데이터의 분포와 밀도를 직접 눈으로 확인할 수 있어, 데이터에 대한 오해를 줄이고 더 정확한 해석을 가능하게 합니다.

220. 막대 그래프에 오차 막대 추가하기 (geom_bar, geom_errorbar)

문제 상황: 당신은 농업 과학자로, 세 가지 다른 비료(Standard, SuperGrow, EcoBoost)가 옥수수 수확량에 미치는 영향을 비교하는 실험을 진행했습니다. 각 비료 그룹의 평균 수확량과 함께, 측정값의 변동성(표준 편차)을 나타내는 오차 막대(error bar)를 포함한 막대 그래프를 작성해야 합니다.

과제 지시:

  1. 아래 원본 데이터를 사용하여 tibble을 생성하세요.
  2. dplyr을 사용하여 비료 종류(fertilizer)별로 평균 수확량(mean_yield)과 표준 편차(sd_yield)를 계산하여 요약 데이터프레임을 만드세요.
  3. 요약 데이터프레임을 사용하여 ggplot2로 막대 그래프(geom_bar)를 그리세요. x축은 fertilizer, y축은 mean_yield 입니다.
  4. geom_errorbar를 사용하여 각 막대 위에 오차 막대를 추가하세요. 오차 막대의 범위는 평균 - 표준편차 에서 평균 + 표준편차 까지입니다.
  5. 막대의 너비와 오차 막대의 너비를 적절히 조절하여 보기 좋게 만드세요.
# 원본 데이터 생성
set.seed(2024)
corn_yield_data <- tibble(
  fertilizer = rep(c("Standard", "SuperGrow", "EcoBoost"), each = 30),
  yield = c(rnorm(30, 100, 15), rnorm(30, 130, 20), rnorm(30, 115, 12))
)

정답 코드

library(ggplot2)
library(tibble)
library(dplyr)

# 원본 데이터 생성
set.seed(2024)
corn_yield_data <- tibble(
  fertilizer = rep(c("Standard", "SuperGrow", "EcoBoost"), each = 30),
  yield = c(rnorm(30, 100, 15), rnorm(30, 130, 20), rnorm(30, 115, 12))
)

# 데이터 요약
yield_summary <- corn_yield_data %>%
  group_by(fertilizer) %>%
  summarise(
    mean_yield = mean(yield),
    sd_yield = sd(yield)
  )

# 시각화
ggplot(yield_summary, aes(x = fertilizer, y = mean_yield, fill = fertilizer)) +
  geom_bar(stat = "identity", position = "dodge", width = 0.7) +
  geom_errorbar(
    aes(ymin = mean_yield - sd_yield, ymax = mean_yield + sd_yield),
    width = 0.2,
    position = position_dodge(0.9)
  ) +
  labs(
    title = "비료 종류별 옥수수 평균 수확량",
    subtitle = "오차 막대는 표준 편차(±SD)를 나타냄",
    x = "비료 종류",
    y = "평균 수확량 (kg/acre)"
  ) +
  theme_minimal() +
  theme(legend.position = "none")

해설

이 문제는 평균값만을 보여주는 막대 그래프의 한계를 극복하기 위해, 데이터의 변동성을 나타내는 오차 막대를 추가하는 방법을 다룹니다. 이는 통계적 유의성을 시각적으로 가늠하는 데 도움을 줍니다.

  • 데이터 요약: ggplot2로 요약 통계를 시각화하기 전에, dplyrgroup_by()summarise()를 사용해 필요한 값(평균, 표준편차)을 미리 계산하는 것이 일반적이고 효율적인 방법입니다.
  • geom_bar(stat = "identity"): ggplot2geom_bar는 기본적으로 데이터의 개수를 세어 막대 높이를 결정합니다(stat = "count"). 하지만 우리는 이미 계산된 평균값(mean_yield)을 막대 높이로 사용하고 싶으므로, stat = "identity" 옵션을 주어 y축 값이 막대 높이가 되도록 설정해야 합니다.
  • geom_errorbar(aes(ymin = ..., ymax = ...)): 오차 막대를 그리는 geom입니다. aes() 안에 오차 막대의 시작점(ymin)과 끝점(ymax)을 매핑해야 합니다. 여기서는 평균 - 표준편차평균 + 표준편차를 각각 지정했습니다.
    • width = 0.2: 오차 막대 위아래 끝에 있는 수평선의 너비를 조절합니다.
  • 통계적 의미: 오차 막대는 그룹 간 평균의 차이가 통계적으로 의미가 있는지 직관적으로 판단하는 데 도움을 줍니다. 만약 두 그룹의 오차 막대가 많이 겹친다면, 두 그룹의 평균 차이는 우연에 의한 것일 가능성이 높습니다. 반대로 오차 막대가 전혀 겹치지 않는다면, 두 그룹 간에 유의미한 차이가 있을 가능성이 크다고 해석할 수 있습니다. (정확한 판단은 t-test나 ANOVA 같은 통계 검정이 필요합니다.)

221. A/B 테스트 결과 시각화: 분포와 평균 한번에 보기

문제 상황: 한 이커머스 웹사이트에서 구매 버튼의 색상을 녹색(A, 대조군)에서 파란색(B, 실험군)으로 변경하는 A/B 테스트를 진행했습니다. 두 그룹의 일일 전환율(conversion rate) 데이터를 확보했고, 어느 그룹이 더 우수한지 시각적으로 명확하게 비교 분석해야 합니다.

과제 지시:

  1. 아래 데이터를 사용하여 tibble을 생성하세요.
  2. 두 그룹(group)의 전환율(conversion_rate) 분포를 겹쳐서 볼 수 있는 밀도 플롯(geom_density)을 그리세요.
  3. 각 그룹의 분포를 쉽게 구분할 수 있도록 fill 미학을 사용하고, 투명도(alpha)를 조절하여 겹치는 부분을 볼 수 있게 하세요.
  4. 각 그룹의 평균 전환율을 계산하고, 이 평균값을 나타내는 수직선(geom_vline)을 밀도 플롯 위에 추가하세요.
  5. 수직선은 점선(linetype = "dashed")으로 표시하고, 각 그룹의 fill 색상과 동일한 color를 사용하도록 설정하세요.
# 데이터 생성
set.seed(101)
ab_data <- tibble(
  group = rep(c("A (Control)", "B (Treatment)"), each = 100),
  conversion_rate = c(rnorm(100, 0.12, 0.03), rnorm(100, 0.15, 0.035))
)

정답 코드

library(ggplot2)
library(tibble)
library(dplyr)

# 데이터 생성
set.seed(101)
ab_data <- tibble(
  group = rep(c("A (Control)", "B (Treatment)"), each = 100),
  conversion_rate = c(rnorm(100, 0.12, 0.03), rnorm(100, 0.15, 0.035))
)

# 그룹별 평균 계산
mean_data <- ab_data %>%
  group_by(group) %>%
  summarise(mean_rate = mean(conversion_rate))

# 시각화
ggplot(ab_data, aes(x = conversion_rate, fill = group)) +
  geom_density(alpha = 0.6) +
  geom_vline(
    data = mean_data,
    aes(xintercept = mean_rate, color = group),
    linetype = "dashed",
    size = 1
  ) +
  scale_fill_manual(values = c("A (Control)" = "#F8766D", "B (Treatment)" = "#00BFC4")) +
  scale_color_manual(values = c("A (Control)" = "#F8766D", "B (Treatment)" = "#00BFC4")) +
  labs(
    title = "A/B 테스트: 버튼 색상에 따른 전환율 분포",
    subtitle = "점선은 각 그룹의 평균 전환율을 나타냄",
    x = "전환율",
    y = "밀도",
    fill = "그룹",
    color = "그룹"
  ) +
  theme_minimal()

해설

이 문제는 A/B 테스트 결과를 분석하는 매우 실용적인 예제입니다. 단순히 평균만 비교하는 것을 넘어, 전체 데이터 분포를 함께 보여줌으로써 더 깊이 있는 해석을 가능하게 합니다.

  • geom_density(alpha = 0.6): 데이터의 분포를 부드러운 곡선으로 표현합니다. alpha를 0.6으로 설정하여 두 분포가 겹치는 영역을 시각적으로 확인할 수 있게 해줍니다.
  • geom_vline()에 다른 데이터 사용하기: ggplot2의 강력한 기능 중 하나는 각 geom 레이어마다 다른 데이터를 지정할 수 있다는 것입니다.
    • 기본 플롯은 ab_data를 사용하지만, geom_vline에서는 data = mean_data를 지정하여 미리 계산해 둔 평균값 데이터를 사용합니다.
    • aes(xintercept = mean_rate): geom_vline은 수직선을 그리기 위해 xintercept라는 고유한 미학을 사용합니다. 여기에 mean_rate 값을 매핑하여 평균 위치에 선을 그립니다.
  • scale_*_manual(): ggplot2가 자동으로 할당하는 색상이 마음에 들지 않거나, fillcolor의 색상을 일치시키고 싶을 때 scale_fill_manual()scale_color_manual()을 사용합니다. 그룹 이름과 색상 코드를 직접 매핑하여 시각화의 일관성과 가독성을 높일 수 있습니다.
  • 분석적 통찰: 이 플롯을 통해 우리는 B 그룹의 평균 전환율이 A 그룹보다 높다는 것뿐만 아니라, B 그룹의 분포가 전반적으로 오른쪽으로 이동해 있음을 알 수 있습니다. 또한 두 분포가 얼마나 겹치는지 확인함으로써 두 그룹 간의 차이가 얼마나 명확한지 직관적으로 파악할 수 있습니다.

222. 주식 데이터 시각화: 시간의 흐름과 그룹핑

문제 상황: 당신은 금융 데이터 분석가입니다. 두 경쟁 기술 회사인 'AlphaTech'와 'BetaCorp'의 지난 100일간의 주가를 시각화하여 두 회사의 주가 추이를 비교 분석하려고 합니다.

과제 지시:

  1. 아래 데이터를 사용하여 tibble을 생성하세요.
  2. ggplot2를 사용하여 day를 x축, price를 y축으로 하는 꺾은선 그래프(geom_line)를 그리세요.
  3. 두 회사의 주가 추이가 별도의 선으로 그려지도록 해야 합니다. color 미학을 company 변수에 매핑하여 이를 구현하세요.
  4. 그래프의 제목, 축 레이블, 범례 제목을 적절하게 설정하세요.
# 데이터 생성
set.seed(321)
library(tibble)
library(dplyr)
tech_stocks <- tibble(
  day = 1:100,
  alpha_return = rnorm(100, 0.001, 0.02),
  beta_return = rnorm(100, 0.0005, 0.015)
) %>%
  mutate(
    alpha_price = 100 * cumprod(1 + alpha_return),
    beta_price = 120 * cumprod(1 + beta_return)
  ) %>%
  select(day, alpha_price, beta_price) %>%
  tidyr::pivot_longer(
    cols = c(alpha_price, beta_price),
    names_to = "company",
    values_to = "price",
    names_prefix = "alpha_|beta_",
    names_transform = list(company = ~ if_else(. == "price", "AlphaTech", "BetaCorp"))
  )

정답 코드

library(ggplot2)
library(tibble)
library(dplyr)
library(tidyr)

# 데이터 생성 및 전처리
set.seed(321)
tech_stocks <- tibble(
  day = 1:100,
  alpha_return = rnorm(100, 0.001, 0.02),
  beta_return = rnorm(100, 0.0005, 0.015)
) %>%
  mutate(
    alpha_price = 100 * cumprod(1 + alpha_return),
    beta_price = 120 * cumprod(1 + beta_return)
  ) %>%
  select(day, alpha_price, beta_price) %>%
  tidyr::pivot_longer(
    cols = c(alpha_price, beta_price),
    names_to = "company",
    values_to = "price",
    names_pattern = "(.*)_price",
    names_transform = list(company = ~ case_when(. == "alpha" ~ "AlphaTech", . == "beta" ~ "BetaCorp"))
  )

# 시각화
ggplot(tech_stocks, aes(x = day, y = price, color = company)) +
  geom_line(size = 1) +
  labs(
    title = "AlphaTech vs BetaCorp 주가 추이 (100일)",
    x = "거래일",
    y = "주가 ($)",
    color = "회사명"
  ) +
  scale_color_brewer(palette = "Set1") +
  theme_classic()

해설

이 문제는 ggplot2가 어떻게 데이터를 그룹화하여 시각화하는지에 대한 핵심 원리를 다룹니다.

  • 암시적 그룹핑 (Implicit Grouping): aes() 안에서 color, fill, linetype, shape 등 이산적인(discrete) 미학에 변수를 매핑하면, ggplot2는 해당 변수의 고유한 값들을 기준으로 데이터를 자동으로 그룹화합니다. 이 문제에서는 aes(color = company)를 사용했기 때문에, ggplot2는 'AlphaTech' 데이터와 'BetaCorp' 데이터를 별개의 그룹으로 인식하고 각각에 대해 따로 geom_line을 그립니다. 이것이 ggplot2의 매우 강력하고 직관적인 특징입니다.
  • 데이터 형식 (Tidy Data): 시각화를 위해 'long' 포맷의 데이터(tidy data)를 사용하는 것이 중요합니다. 제공된 데이터 생성 코드의 tidyr::pivot_longer 부분이 바로 이 역할을 합니다. 원래 'wide' 포맷(각 회사 주가가 별도의 컬럼에 있는 형태)의 데이터를, 회사 이름이 담긴 company 컬럼과 주가 정보가 담긴 price 컬럼으로 구성된 'long' 포맷으로 변환합니다. 이 구조가 aes(color = company) 매핑을 가능하게 합니다.
  • scale_color_brewer(palette = "Set1"): ggplot2의 기본 색상 팔레트 대신, RColorBrewer 패키지에서 제공하는 사전 정의된 색상 팔레트를 사용하고 싶을 때 쓰는 함수입니다. "Set1"은 구분이 명확한 색상들로 구성된 인기 있는 팔레트 중 하나입니다.

223. 명시적 그룹핑: group 미학의 이해

문제 상황: 우주 탐사선 'Odyssey-R'이 5개의 다른 소행성(A, B, C, D, E)을 근접 비행하며 각 소행성 주변에서 10번씩 궤도 속도를 측정했습니다. 각 소행성별 측정 데이터의 추이를 선으로 연결하여 보고 싶지만, 모든 선을 동일한 색상으로 그려서 전체적인 패턴의 변동성에 집중하고 싶습니다.

과제 지시:

  1. 아래 데이터를 사용하여 tibble을 생성하세요.
  2. ggplot2를 사용하여 measurement_id를 x축, velocity를 y축으로 하는 꺾은선 그래프(geom_line)를 그리세요.
  3. 각 소행성(asteroid_id)별로 데이터가 별도의 선으로 연결되도록 해야 합니다. 하지만 color 미학을 사용하지 말고, 모든 선은 회색("grey")으로 그리세요. 이를 위해 group 미학을 사용해야 합니다.
  4. 전체적인 추세를 보여주기 위해 모든 데이터를 포함하는 부드러운 추세선(geom_smooth)을 빨간색으로 추가하세요.
# 데이터 생성
set.seed(555)
library(tibble)
library(purrr)
asteroid_data <- tibble(
  asteroid_id = rep(LETTERS[1:5], each = 10),
  measurement_id = rep(1:10, 5),
  velocity = map_dbl(rep(c(50, 70, 60, 80, 55), each = 10), ~ .x + rnorm(1, 0, 5)) + (rep(1:10, 5) * rnorm(50, 1, 0.5))
)

정답 코드

library(ggplot2)
library(tibble)
library(purrr)

# 데이터 생성
set.seed(555)
asteroid_data <- tibble(
  asteroid_id = rep(LETTERS[1:5], each = 10),
  measurement_id = rep(1:10, 5),
  velocity = map_dbl(rep(c(50, 70, 60, 80, 55), each = 10), ~ .x + rnorm(1, 0, 5)) + (rep(1:10, 5) * rnorm(50, 1, 0.5))
)

# 시각화
ggplot(asteroid_data, aes(x = measurement_id, y = velocity)) +
  geom_line(aes(group = asteroid_id), color = "grey", alpha = 0.8) +
  geom_smooth(method = "loess", color = "red", se = FALSE, size = 1.2) +
  labs(
    title = "소행성별 궤도 속도 측정치",
    subtitle = "회색 선: 개별 소행성, 빨간 선: 전체 추세",
    x = "측정 순서",
    y = "궤도 속도 (km/s)"
  ) +
  theme_bw()

해설

이 문제는 colorlinetype 같은 다른 미학을 변경하지 않으면서 데이터를 그룹화해야 할 때 사용하는 group 미학의 중요성을 보여줍니다.

  • 명시적 그룹핑 (Explicit Grouping)과 group 미학: 이전 문제에서는 color = company를 통해 암시적으로 그룹을 나누었습니다. 하지만 모든 선의 색상을 동일하게 유지하면서 그룹을 나누고 싶을 때는 aes(group = ...)을 명시적으로 사용해야 합니다.
    • geom_line(aes(group = asteroid_id), color = "grey"): 이 코드는 ggplot2에게 asteroid_id의 고유값('A', 'B', 'C', 'D', 'E')을 기준으로 데이터를 5개의 그룹으로 나누고, 각 그룹 내에서 점들을 선으로 연결하라고 지시합니다. color = "grey"aes() 밖에 있으므로, 모든 선에 동일하게 회색을 적용합니다. 만약 group 미학이 없다면, ggplot2는 모든 데이터를 하나의 그룹으로 간주하고 1번부터 10번까지의 점들을 연결하는 과정을 5번 반복하여 지저분한 스파게티 플롯을 그리게 됩니다.
  • 전체 추세선 추가: geom_smooth() 레이어에는 group 미학을 지정하지 않았습니다. 따라서 이 레이어는 ggplot()에 정의된 기본 매핑(aes(x = measurement_id, y = velocity))을 상속받아 전체 데이터셋에 대한 하나의 추세선을 그립니다. 이처럼 각 레이어에 다른 그룹핑 규칙을 적용할 수 있는 유연성은 ggplot2의 큰 장점입니다.

224. 출판 품질의 그래프 만들기: theme() 상세 설정

문제 상황: 당신은 저명한 과학 저널에 제출할 논문을 작성 중입니다. 연구 결과를 담은 그래프를 저널의 가이드라인에 맞춰 매우 전문적이고 깔끔하게 만들어야 합니다. ggplot2의 기본 테마에서 벗어나 폰트, 배경, 축, 제목 등 다양한 요소를 직접 제어해야 합니다.

과제 지시:

  1. iris 데이터셋을 사용합니다.
  2. Sepal.Length를 x축, Sepal.Width를 y축으로 하고, Species에 따라 색상과 모양(shape)이 다른 산점도를 그리세요.
  3. theme_classic()을 기본 테마로 설정하세요.
  4. theme() 함수를 사용하여 다음 사항들을 상세하게 수정하세요.
    • 그래프 제목(plot.title): 크기 20, 볼드체, 가운데 정렬(hjust = 0.5).
    • x축 제목(axis.title.x), y축 제목(axis.title.y): 크기 14.
    • x축 텍스트(axis.text.x), y축 텍스트(axis.text.y): 크기 12, 검은색.
    • 범례 제목(legend.title): 크기 12, 볼드체.
    • 범례 위치(legend.position): 그래프 내부의 우측 상단. c(0.85, 0.85) 좌표를 사용하세요.
    • 범례 배경(legend.background): 투명하게 만드세요. (element_blank())
    • 패널 테두리(panel.border): 검은색 실선 테두리를 추가하세요.

정답 코드

library(ggplot2)

# 시각화
ggplot(iris, aes(x = Sepal.Length, y = Sepal.Width, color = Species, shape = Species)) +
  geom_point(size = 3, alpha = 0.8) +
  labs(
    title = "Iris Species by Sepal Dimensions",
    x = "Sepal Length (cm)",
    y = "Sepal Width (cm)",
    color = "Species",
    shape = "Species"
  ) +
  theme_classic() +
  theme(
    plot.title = element_text(size = 20, face = "bold", hjust = 0.5),
    axis.title.x = element_text(size = 14),
    axis.title.y = element_text(size = 14),
    axis.text.x = element_text(size = 12, color = "black"),
    axis.text.y = element_text(size = 12, color = "black"),
    legend.title = element_text(size = 12, face = "bold"),
    legend.position = c(0.85, 0.85),
    legend.background = element_blank(),
    panel.border = element_rect(color = "black", fill = NA, size = 1)
  )

해설

이 문제는 ggplot2 시각화의 최종 완성도를 높이는 데 필수적인 theme() 함수의 사용법을 심도 있게 다룹니다. theme()은 데이터와 무관한 모든 시각적 요소를 제어합니다.

  • element_text(): 텍스트 관련 요소(제목, 축 레이블 등)의 속성을 지정합니다. 주요 인자는 다음과 같습니다.
    • size: 글자 크기
    • face: 글꼴 스타일 ("plain", "bold", "italic", "bold.italic")
    • color: 글자 색상
    • hjust, vjust: 수평 및 수직 위치 조정 (0=왼쪽/아래, 0.5=가운데, 1=오른쪽/위)
  • element_rect(): 사각형 요소(배경, 테두리 등)의 속성을 지정합니다.
    • fill: 채우기 색상
    • color: 테두리 색상
    • size: 테두리 두께
  • element_blank(): 해당 요소를 아예 그리지 않도록 하여 투명하게 만듭니다. 범례 배경이나 패널 배경/격자선을 없앨 때 유용합니다.
  • legend.position: 범례의 위치를 지정합니다. "top", "bottom", "left", "right" 같은 문자열을 사용하거나, c(x, y) 형태의 숫자 벡터를 사용하여 플롯 내부의 상대적 위치(x, y 각각 0~1 사이)를 지정할 수 있습니다.
  • 체계적인 접근: theme() 안에는 수십 개의 인자가 있습니다. 모든 것을 외울 필요는 없습니다. ?theme 도움말을 참조하거나, "ggplot2 theme cheatsheet"을 검색하여 필요한 요소를 찾아 적용하는 것이 효율적입니다. 이처럼 theme()을 잘 활용하면 ggplot2로 만든 그래프를 어떤 목적(논문, 보고서, 대시보드 등)에든 맞게 커스터마이징할 수 있습니다.

225. 나만의 ggplot 테마 함수 만들기

문제 상황: 당신은 특정 회사의 데이터 분석팀에 속해 있습니다. 팀에서는 보고서에 들어가는 모든 그래프가 일관된 스타일(회사 브랜딩에 맞는 색상, 폰트 등)을 갖도록 하는 가이드라인을 만들기로 했습니다. 매번 theme() 코드를 복사-붙여넣기 하는 대신, 재사용 가능한 나만의 테마 함수를 만들려고 합니다.

과제 지시:

  1. "theme_corporate_dark"라는 이름의 새로운 함수를 만드세요. 이 함수는 인자를 받지 않습니다.
  2. 함수 내부에서는 theme_dark()를 기본 테마로 사용하고, 그 위에 다음과 같은 theme() 수정을 추가하여 반환(return)하도록 코드를 작성하세요.
    • 전체 배경(plot.background): "#2D3748"
    • 패널 배경(panel.background): "#1A202C"
    • 텍스트 색상: 모든 텍스트(text) 요소의 기본 색상을 "white"로 설정.
    • 제목(plot.title): 크기 18, 볼드체, 색상 "#A0AEC0", 왼쪽 정렬(hjust = 0).
    • 부제목(plot.subtitle): 크기 12, 색상 "#718096", 왼쪽 정렬.
    • 패널 격자선(panel.grid): 주 격자선은 "#4A5568" 색상의 점선(linetype = "dotted"), 부 격자선은 그리지 않음.
  3. mpg 데이터셋을 사용하여 displ(배기량)과 hwy(고속도로 연비)의 관계를 보여주는 산점도를 그리고, 마지막에 + theme_corporate_dark()를 추가하여 여러분이 만든 테마가 잘 적용되는지 확인하세요.

정답 코드

library(ggplot2)

# 나만의 테마 함수 정의
theme_corporate_dark <- function() {
  theme_dark() +
  theme(
    plot.background = element_rect(fill = "#2D3748", color = NA),
    panel.background = element_rect(fill = "#1A202C", color = NA),
    text = element_text(color = "white", family = "sans"),
    plot.title = element_text(size = 18, face = "bold", color = "#A0AEC0", hjust = 0),
    plot.subtitle = element_text(size = 12, color = "#718096", hjust = 0),
    axis.title = element_text(color = "#A0AEC0", size = 12),
    axis.text = element_text(color = "#718096"),
    legend.background = element_rect(fill = "#2D3748", color = NA),
    legend.key = element_rect(fill = "#1A202C", color = NA),
    panel.grid.major = element_line(color = "#4A5568", linetype = "dotted"),
    panel.grid.minor = element_blank()
  )
}

# 새로 만든 테마 함수 적용
ggplot(mpg, aes(x = displ, y = hwy, color = class)) +
  geom_point(alpha = 0.8) +
  labs(
    title = "Engine Displacement vs. Highway MPG",
    subtitle = "Data from the 'mpg' dataset",
    x = "Displacement (Liters)",
    y = "Highway Miles Per Gallon"
  ) +
  theme_corporate_dark()

해설

이 문제는 반복 작업을 줄이고 코드의 재사용성을 높이는 프로그래밍의 핵심 원칙을 ggplot2에 적용하는 방법을 보여줍니다.

  • 함수화의 장점:
    • 재사용성: 여러 그래프에 동일한 스타일을 일관되게 적용할 수 있습니다.
    • 유지보수: 스타일 가이드라인이 변경되면, 이 함수 하나만 수정하면 모든 그래프에 변경 사항이 반영됩니다.
    • 가독성: 시각화 코드 본문이 길고 복잡한 theme() 설정으로 어지럽혀지지 않고, + theme_corporate_dark() 한 줄로 깔끔하게 유지됩니다.
  • 함수 구조:
    • my_theme_name <- function() { ... }: 새로운 함수를 정의하는 기본 R 문법입니다.
    • theme_dark() + theme(...): ggplot2의 테마는 + 연산자로 결합될 수 있습니다. 이 코드에서는 theme_dark()라는 기존 테마를 기반으로, 우리가 원하는 부분만 theme() 함수로 덮어쓰거나 추가하는 방식을 사용합니다. 이를 "테마 상속"이라고 생각할 수 있습니다.
  • 실무 적용: 많은 기업이나 연구 기관에서는 ggthemes 같은 패키지처럼 자체 ggplot2 테마 패키지를 만들어 사용합니다. 이는 조직의 브랜딩을 강화하고 데이터 시각화 결과물의 일관성을 유지하는 데 매우 효과적입니다. 이 문제는 그러한 패키지를 만드는 첫걸음이라 할 수 있습니다.

226. 다중 플롯 레이아웃: patchwork 패키지 활용

문제 상황: 당신은 기상 데이터 분석가로, 특정 도시의 월별 평균 기온, 강수량, 그리고 기온과 강수량의 관계를 한 번에 보여주는 요약 보고서를 만들어야 합니다. 이 세 가지 정보를 각각의 플롯으로 만든 뒤, 이를 하나의 이미지로 깔끔하게 조합해야 합니다.

과제 지시:

  1. patchwork 패키지를 설치하고 로드하세요.
  2. 아래 데이터를 사용하여 tibble을 생성하세요.
  3. 세 개의 개별 ggplot 객체를 만드세요.
    • p1: 월(month)에 따른 평균 기온(avg_temp)을 보여주는 꺾은선 그래프.
    • p2: 월(month)에 따른 총 강수량(precipitation)을 보여주는 막대 그래프.
    • p3: 평균 기온(avg_temp)과 강수량(precipitation)의 관계를 보여주는 산점도.
  4. patchwork 패키지의 연산자(+, /)를 사용하여 세 플롯을 다음과 같이 배치하세요.
    • 상단에는 p1p2를 나란히 배치합니다.
    • 하단에는 p3를 넓게 배치합니다.
# 데이터 생성
weather_data <- tibble(
  month = factor(month.abb, levels = month.abb),
  avg_temp = c(-5, -3, 2, 9, 15, 20, 23, 22, 17, 10, 3, -3),
  precipitation = c(30, 25, 40, 50, 80, 100, 120, 110, 90, 60, 45, 35)
)

정답 코드

# install.packages("patchwork") # 최초 1회 설치
library(patchwork)
library(ggplot2)
library(tibble)

# 데이터 생성
weather_data <- tibble(
  month = factor(month.abb, levels = month.abb),
  avg_temp = c(-5, -3, 2, 9, 15, 20, 23, 22, 17, 10, 3, -3),
  precipitation = c(30, 25, 40, 50, 80, 100, 120, 110, 90, 60, 45, 35)
)

# 플롯 1: 기온 꺾은선 그래프
p1 <- ggplot(weather_data, aes(x = month, y = avg_temp, group = 1)) +
  geom_line(color = "red", size = 1) +
  geom_point(color = "darkred") +
  labs(title = "월별 평균 기온", x = NULL, y = "기온 (°C)")

# 플롯 2: 강수량 막대 그래프
p2 <- ggplot(weather_data, aes(x = month, y = precipitation)) +
  geom_bar(stat = "identity", fill = "skyblue") +
  labs(title = "월별 총 강수량", x = NULL, y = "강수량 (mm)")

# 플롯 3: 기온과 강수량 산점도
p3 <- ggplot(weather_data, aes(x = avg_temp, y = precipitation)) +
  geom_point(size = 3, color = "purple") +
  geom_smooth(method = "loess", se = FALSE) +
  labs(title = "기온과 강수량의 관계", x = "기온 (°C)", y = "강수량 (mm)")

# patchwork를 이용한 플롯 조합
(p1 + p2) / p3 + plot_annotation(title = "도시 기상 데이터 요약 보고서")

해설

이 문제는 여러 ggplot 객체를 조합하여 복잡한 레이아웃을 만드는 강력하고 직관적인 patchwork 패키지의 사용법을 소개합니다.

  • patchwork의 기본 연산자:
    • +: 플롯을 옆으로 나란히 배치합니다. p1 + p2는 p1과 p2를 가로로 붙입니다.
    • /: 플롯을 위아래로 배치합니다. p1 / p2는 p1 위에 p2를 세로로 붙입니다.
  • 연산자 조합: 이 연산자들은 괄호와 함께 사용하여 더 복잡한 레이아웃을 만들 수 있습니다.
    • (p1 + p2) / p3:
      1. () 안의 p1 + p2가 먼저 실행되어 두 플롯이 가로로 합쳐진 하나의 단위가 됩니다.
      2. 그 다음 / p3가 실행되어, 위에서 만들어진 단위 아래에 p3를 배치합니다.
  • plot_annotation(): 조합된 전체 플롯에 대한 제목, 부제목, 캡션 등을 추가할 때 사용합니다.
  • patchwork인가?: grid.arrange()(from gridExtra package) 같은 기존의 방법에 비해 patchwork는 문법이 매우 직관적이고 코드가 간결하며, 플롯 간의 정렬을 자동으로 맞춰주는 등 사용 편의성이 매우 뛰어납니다. 복잡한 대시보드 형태의 시각화를 만들 때 필수적인 도구입니다.

227. 특정 데이터 강조하기: gghighlight 활용

문제 상황: 당신은 전 세계 국가들의 기대수명과 1인당 GDP 데이터를 분석하고 있습니다. 수많은 국가들 중에서 아시아 대륙에 속한 국가들만 강조하여 시각적으로 돋보이게 만들고 싶습니다. 다른 대륙의 국가들은 배경처럼 흐리게 처리하여 비교를 용이하게 해야 합니다.

과제 지시:

  1. gghighlightgapminder 패키지를 설치하고 로드하세요.
  2. gapminder 데이터셋에서 가장 최근 연도인 2007년 데이터만 필터링하여 사용하세요.
  3. ggplot2를 사용하여 gdpPercap(1인당 GDP)을 x축, lifeExp(기대수명)을 y축으로 하는 산점도를 그리세요. x축은 로그 스케일(scale_x_log10)을 적용하세요.
  4. gghighlight 패키지의 gghighlight() 함수를 사용하여 continent가 "Asia"인 데이터 포인트만 강조하세요.
  5. 강조되지 않은 점들은 회색으로, 강조된 점들은 원래 색상(이 경우 기본값)으로 표시되도록 하세요.
  6. 강조된 데이터 포인트 옆에 국가명(country)을 라벨로 표시하되, 서로 겹치지 않도록 ggrepel 패키지의 기능을 활용하세요. (gghighlight는 내부적으로 ggrepel을 지원합니다.)

정답 코드

# install.packages("gghighlight")
# install.packages("gapminder")
library(gghighlight)
library(gapminder)
library(ggplot2)
library(dplyr)

# 2007년 데이터 필터링
gapminder_2007 <- gapminder %>% filter(year == 2007)

# 시각화
ggplot(gapminder_2007, aes(x = gdpPercap, y = lifeExp)) +
  geom_point(aes(color = continent, size = pop)) +
  scale_x_log10(labels = scales::dollar) +
  scale_size_continuous(range = c(1, 12), guide = "none") +
  gghighlight(continent == "Asia", label_key = country) +
  labs(
    title = "2007년 국가별 1인당 GDP와 기대수명",
    subtitle = "아시아 국가 강조",
    x = "1인당 GDP (로그 스케일)",
    y = "기대수명"
  ) +
  theme_minimal()

해설

이 문제는 특정 조건을 만족하는 데이터만 시각적으로 강조하여 메시지를 효과적으로 전달하는 gghighlight 패키지의 사용법을 다룹니다.

  • gghighlight(condition, ...): ggplot 객체에 +로 추가하는 함수입니다.
    • condition: 강조할 데이터를 선택하는 논리 표현식을 전달합니다. 여기서는 continent == "Asia"를 사용하여 아시아 대륙 국가들을 선택했습니다. 이 조건에 해당하지 않는 데이터는 자동으로 흐리게 처리됩니다.
    • label_key = country: 강조된 데이터 포인트에 어떤 라벨을 붙일지 지정합니다. country 컬럼의 값을 사용하도록 설정했습니다. gghighlight는 내부적으로 ggrepel을 사용하여 라벨이 겹치지 않도록 지능적으로 배치해 줍니다.
  • 작동 원리: gghighlightggplot의 레이어 정보를 분석하여, 주어진 조건을 만족하는 데이터와 그렇지 않은 데이터를 분리합니다. 그리고 조건을 만족하지 않는 데이터의 미학(색상, 투명도 등)을 지정된 'unhighlighted' 스타일(기본값: 회색, 낮은 알파)로 변경하여 새로운 레이어를 생성합니다. 이 모든 과정이 자동으로 이루어지기 때문에 사용자는 복잡한 데이터 필터링이나 여러 개의 geom을 사용할 필요 없이 간단하게 원하는 결과를 얻을 수 있습니다.
  • scale_x_log10(labels = scales::dollar): 1인당 GDP처럼 값의 범위가 매우 넓은 데이터는 로그 스케일을 적용하면 데이터 분포를 더 잘 파악할 수 있습니다. scales::dollar 함수를 labels 인자에 전달하면 축의 눈금을 달러($) 형식으로 예쁘게 표시해 줍니다.

228. 인터랙티브 플롯 만들기: plotly 패키지 활용

문제 상황: 당신은 자동차 리뷰 사이트의 데이터 분석가입니다. 자동차의 무게(wt)와 연비(mpg) 간의 관계를 시각화한 보고서를 작성해야 합니다. 정적인 이미지 대신, 사용자가 마우스를 올리면 각 점에 대한 상세 정보(차종, 실린더 수 등)를 볼 수 있는 인터랙티브(interactive)한 플롯을 제공하여 사용자 경험을 향상시키고 싶습니다.

과제 지시:

  1. plotly 패키지를 설치하고 로드하세요.
  2. R에 내장된 mtcars 데이터셋을 사용합니다. rownames_to_column() 함수를 사용하여 차종 이름이 담긴 행 이름을 "car_model"이라는 새로운 열로 만드세요.
  3. ggplot2를 사용하여 wt를 x축, mpg를 y축으로 하는 산점도를 만드세요. 점의 색상은 실린더 수(cyl)에 따라 다르게 표시하고, 크기는 마력(hp)에 비례하도록 설정하세요.
  4. ggplotly() 함수를 사용하여 3번에서 만든 ggplot 객체를 인터랙티브한 plotly 객체로 변환하세요.
  5. 마우스를 올렸을 때(hover) 차종("car_model") 정보가 추가로 표시되도록 ggplotly()tooltip 인자를 설정하세요.
# mtcars 데이터 준비
library(tibble)
mtcars_data <- mtcars %>%
  rownames_to_column(var = "car_model") %>%
  mutate(cyl = as.factor(cyl)) # 실린더 수를 범주형으로 변환

정답 코드

# install.packages("plotly")
library(plotly)
library(ggplot2)
library(tibble)
library(dplyr)

# 데이터 준비
mtcars_data <- mtcars %>%
  rownames_to_column(var = "car_model") %>%
  mutate(cyl = as.factor(cyl))

# 1. ggplot 객체 생성
p <- ggplot(mtcars_data, aes(x = wt, y = mpg, color = cyl, size = hp, 
                             text = paste("Model:", car_model))) +
  geom_point(alpha = 0.7) +
  labs(
    title = "자동차 무게와 연비의 관계",
    x = "무게 (1000 lbs)",
    y = "연비 (Miles/Gallon)",
    color = "실린더 수",
    size = "마력"
  ) +
  theme_bw()

# 2. ggplotly로 변환
ggplotly(p, tooltip = "text")

해설

이 문제는 정적인 ggplot 시각화를 단 한 줄의 코드로 동적인 인터랙티브 플롯으로 변환하는 plotly 패키지의 강력함을 보여줍니다.

  • ggplotly(p, ...): 이 함수는 ggplot 객체(p)를 입력받아 HTML 기반의 인터랙티브 위젯으로 변환합니다. 변환된 플롯은 확대/축소, 패닝, 특정 시리즈 숨기기/보이기, 데이터 값 확인(hover) 등의 기능을 제공합니다.
  • 툴팁(Tooltip) 커스터마이징:
    • ggplotly는 기본적으로 aes()에 매핑된 변수들(x, y, color, size 등)을 툴팁에 보여줍니다.
    • 더 많은 정보를 보여주거나 툴팁의 형식을 제어하고 싶을 때, aes() 안에 text라는 특수한 미학을 사용합니다. text = paste("Model:", car_model)는 각 점에 대한 툴팁 텍스트를 미리 생성하여 매핑합니다.
    • ggplotly(p, tooltip = "text")plotly에게 툴팁을 표시할 때, 기본 정보 대신 우리가 text 미학에 지정한 내용만을 사용하라고 지시합니다. tooltip = "all"을 사용하면 기본 정보와 text 미학의 내용을 모두 보여줍니다.
  • 활용: 이렇게 만들어진 인터랙티브 플롯은 RMarkdown 보고서, Shiny 웹 애플리케이션 등에 쉽게 삽입할 수 있어, 사용자가 데이터를 직접 탐색하며 더 깊은 인사이트를 얻도록 유도할 수 있습니다. 정적 보고서의 한계를 뛰어넘는 훌륭한 방법입니다.

229. 지리 정보 시각화: 지도 위에 데이터 표현하기

문제 상황: 당신은 미국의 항공 운항 데이터를 분석하고 있습니다. 각 공항의 위치를 지도 위에 표시하고, 각 공항의 운항 지연 건수를 점의 크기로 표현하여 어느 공항에서 지연이 많이 발생하는지 시각적으로 파악하고자 합니다.

과제 지시:

  1. ggplot2, dplyr, maps 패키지를 로드하세요. nycflights13 패키지도 데이터 소스로 사용합니다.
  2. nycflights13::flights 데이터에서 출발 공항(origin)별 총 출발 지연 시간(dep_delay)의 합계를 계산하세요. 결측값(NA)은 0으로 처리하세요.
  3. nycflights13::airports 데이터에서 각 공항의 위도(lat)와 경도(lon) 정보를 가져와 2번에서 계산한 지연 데이터와 합치세요. (airports 데이터의 faa 컬럼이 flights 데이터의 origin 컬럼과 일치합니다.)
  4. maps 패키지의 map_data("state")를 사용하여 미국 본토의 지도 데이터를 준비하세요.
  5. ggplot2를 사용하여 다음 레이어를 순서대로 쌓아 지도를 만드세요.
    • 배경으로 미국 주(state) 경계 지도(geom_polygon)를 회색으로 그립니다.
    • 그 위에 공항의 위치를 나타내는 산점도(geom_point)를 그립니다.
    • 점의 x, y 위치는 경도(lon)와 위도(lat)를 사용합니다.
    • 점의 크기(size)는 총 지연 시간 합계에 비례하도록, 색상(color)은 파란색 계열로 설정하고 투명하게 처리하세요.
    • 좌표계를 coord_map()으로 설정하여 지도의 왜곡을 보정하세요.

정답 코드

library(ggplot2)
library(dplyr)
library(maps)
library(nycflights13)

# 1. 출발 공항별 총 지연 시간 계산
delay_by_origin <- flights %>%
  filter(!is.na(dep_delay)) %>%
  group_by(origin) %>%
  summarise(total_delay = sum(dep_delay[dep_delay > 0], na.rm = TRUE)) # 양수 지연만 합산

# 2. 공항 위치 정보와 결합
airport_locations <- airports %>%
  select(faa, lat, lon)

delay_map_data <- delay_by_origin %>%
  left_join(airport_locations, by = c("origin" = "faa")) %>%
  filter(!is.na(lat) & !is.na(lon))

# 3. 미국 주 지도 데이터 준비
us_states <- map_data("state")

# 4. 시각화
ggplot() +
  # 배경 지도 레이어
  geom_polygon(data = us_states, aes(x = long, y = lat, group = group), fill = "gray90", color = "white") +
  # 공항 데이터 레이어
  geom_point(data = delay_map_data, aes(x = lon, y = lat, size = total_delay), color = "blue", alpha = 0.5) +
  # 좌표계 설정
  coord_map(projection = "albers", lat0 = 39, lat1 = 45) +
  scale_size_continuous(range = c(2, 15), name = "Total Departure Delay (min)") +
  labs(
    title = "미국 공항별 총 출발 지연 시간",
    x = "경도 (Longitude)",
    y = "위도 (Latitude)"
  ) +
  theme_void() + # 축, 배경 등 불필요한 요소 제거
  theme(legend.position = "bottom")

해설

이 문제는 ggplot2를 사용하여 데이터를 실제 지리적 위치 위에 표현하는 방법을 다룹니다. 이는 공간 데이터 분석의 기본 단계입니다.

  • 지도 데이터: maps 패키지의 map_data() 함수는 ggplot2geom_polygon이나 geom_path로 그릴 수 있는 형태의 지도 데이터를 데이터프레임으로 제공합니다. group 미학은 각 폴리곤(여기서는 주)의 꼭짓점들을 올바르게 연결하는 데 필수적입니다.
  • 레이어 순서: 지도 시각화에서는 레이어를 쌓는 순서가 매우 중요합니다. 배경이 되는 지도(geom_polygon)를 먼저 그리고, 그 위에 데이터 포인트(geom_point)를 그려야 점들이 지도에 가려지지 않습니다.
  • coord_map(): 지구는 둥글기 때문에 2D 평면에 지도를 그리면 왜곡이 발생합니다. coord_map()은 메르카토르(Mercator), 알버스(Albers) 등 다양한 투영법(projection)을 적용하여 이러한 왜곡을 보정하고 더 정확한 형태의 지도를 그려줍니다. coord_fixed()를 사용할 수도 있지만, coord_map()이 지리 정보 시각화에 더 적합합니다.
  • theme_void(): 지도 시각화에서는 위도/경도 축이나 배경 격자선이 불필요한 경우가 많습니다. theme_void()는 이러한 모든 테마 요소를 제거하여 지도 자체에만 집중할 수 있게 해주는 편리한 테마입니다.

230. 종합 분석 프로젝트: 커피숍 체인 매출 분석 대시보드

문제 상황: 당신은 'R'spresso' 커피 체인의 데이터 분석 총괄입니다. CEO에게 세 개 지점(A, B, C)의 성과를 요약하여 보고해야 합니다. 요일별 매출 패턴, 메뉴별 판매 비중, 시간대별 주문량 추이를 각각 시각화하고, 이를 하나의 대시보드로 통합하여 제출해야 합니다.

과제 지시:

  1. 아래 데이터를 사용하여 tibble을 생성하세요.
  2. patchwork 패키지를 사용하여 2x2 그리드 레이아웃의 대시보드를 만드세요.
  3. 첫 번째 플롯 (좌측 상단): 지점(store)별, 요일(day)별 평균 매출(revenue)을 보여주는 꺾은선 그래프를 만드세요. 각 지점을 다른 색상(color)과 점 모양(shape)으로 구분하세요.
  4. 두 번째 플롯 (우측 상단): 전체 지점의 메뉴(menu)별 판매량(quantity)을 합산하여, 상위 5개 메뉴의 판매 비중을 보여주는 원 그래프(파이 차트)를 만드세요. (geom_bar + coord_polar)
  5. 세 번째 플롯 (하단 전체): 시간대(hour)별 총 주문 건수(데이터 행의 수)를 보여주는 막대 그래프를 만드세요. 지점별로 색상이 다른 누적 막대 그래프(geom_bar(position = "stack"))로 표현하세요.
  6. patchworkplot_layout()plot_annotation()을 사용하여 최종 레이아웃과 전체 제목을 설정하세요.
# 데이터 생성
set.seed(2025)
library(tibble)
library(dplyr)
coffee_sales <- tibble(
  store = sample(c("A", "B", "C"), 1000, replace = TRUE, prob = c(0.4, 0.35, 0.25)),
  day = factor(sample(c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"), 1000, replace = TRUE),
               levels = c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")),
  hour = sample(8:21, 1000, replace = TRUE),
  menu = sample(c("Americano", "Latte", "Espresso", "Tea", "Sandwich", "Cake", "Juice"), 1000, replace = TRUE,
                prob = c(0.3, 0.25, 0.1, 0.1, 0.1, 0.1, 0.05)),
  quantity = sample(1:3, 1000, replace = TRUE),
) %>%
  mutate(revenue = quantity * case_when(
    menu == "Americano" ~ 4.0, menu == "Latte" ~ 4.5, menu == "Espresso" ~ 3.0,
    menu == "Tea" ~ 3.5, menu == "Sandwich" ~ 6.0, menu == "Cake" ~ 5.5, TRUE ~ 5.0
  ))

정답 코드

library(ggplot2)
library(dplyr)
library(tibble)
library(patchwork)

# 데이터 생성
set.seed(2025)
# (문제에 제시된 데이터 생성 코드와 동일)
coffee_sales <- tibble(
  store = sample(c("A", "B", "C"), 1000, replace = TRUE, prob = c(0.4, 0.35, 0.25)),
  day = factor(sample(c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"), 1000, replace = TRUE),
               levels = c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")),
  hour = sample(8:21, 1000, replace = TRUE),
  menu = sample(c("Americano", "Latte", "Espresso", "Tea", "Sandwich", "Cake", "Juice"), 1000, replace = TRUE,
                prob = c(0.3, 0.25, 0.1, 0.1, 0.1, 0.1, 0.05)),
  quantity = sample(1:3, 1000, replace = TRUE),
) %>%
  mutate(revenue = quantity * case_when(
    menu == "Americano" ~ 4.0, menu == "Latte" ~ 4.5, menu == "Espresso" ~ 3.0,
    menu == "Tea" ~ 3.5, menu == "Sandwich" ~ 6.0, menu == "Cake" ~ 5.5, TRUE ~ 5.0
  ))


# --- 플롯 1: 요일별 평균 매출 ---
p1_data <- coffee_sales %>%
  group_by(store, day) %>%
  summarise(avg_revenue = mean(revenue), .groups = 'drop')

p1 <- ggplot(p1_data, aes(x = day, y = avg_revenue, group = store, color = store, shape = store)) +
  geom_line(size = 1) +
  geom_point(size = 3) +
  labs(title = "요일별 지점 평균 매출", x = "요일", y = "평균 매출 ($)") +
  theme_bw() + theme(legend.position = "bottom")

# --- 플롯 2: 메뉴별 판매 비중 (상위 5) ---
p2_data <- coffee_sales %>%
  group_by(menu) %>%
  summarise(total_quantity = sum(quantity)) %>%
  arrange(desc(total_quantity)) %>%
  slice_head(n = 5) %>%
  mutate(proportion = total_quantity / sum(total_quantity))

p2 <- ggplot(p2_data, aes(x = "", y = proportion, fill = menu)) +
  geom_bar(stat = "identity", width = 1) +
  coord_polar(theta = "y") +
  geom_text(aes(label = scales::percent(proportion, accuracy = 1)), 
            position = position_stack(vjust = 0.5)) +
  labs(title = "상위 5개 메뉴 판매 비중", fill = "메뉴", x = NULL, y = NULL) +
  theme_void()

# --- 플롯 3: 시간대별 누적 주문 건수 ---
p3 <- ggplot(coffee_sales, aes(x = hour, fill = store)) +
  geom_bar(position = "stack") +
  labs(title = "시간대별 지점별 주문 건수", x = "시간 (Hour)", y = "주문 건수") +
  scale_x_continuous(breaks = seq(8, 21, 2)) +
  theme_minimal()

# --- 대시보드 조합 ---
(p1 + p2) / p3 + 
  plot_layout(heights = c(1, 1.2)) +
  plot_annotation(
    title = "R'spresso 커피 체인 성과 대시보드",
    subtitle = "지점별, 메뉴별, 시간대별 성과 분석",
    theme = theme(plot.title = element_text(size = 20, face = "bold", hjust = 0.5),
                  plot.subtitle = element_text(size = 14, hjust = 0.5))
  )

해설

이 문제는 지금까지 배운 여러 ggplot2 심화 기술(그룹핑, geom 혼합, 테마, 레이아웃)을 총동원하여 하나의 완성된 분석 결과물(대시보드)을 만드는 종합 프로젝트입니다.

  • 플롯 1 (꺾은선 그래프): group = store를 통해 각 지점별로 선을 따로 그리도록 명시합니다. colorshape 미학을 함께 사용하여 흑백으로 인쇄하더라도 지점 구분이 가능하도록 가독성을 높였습니다.
  • 플롯 2 (원 그래프): 원 그래프는 ggplot2에 별도의 geom이 없으며, 막대 그래프를 그린 뒤 coord_polar(theta = "y")를 적용하여 만듭니다. y축을 기준으로 좌표계를 원형으로 구부리는 원리입니다. geom_textposition_stack을 사용하여 각 조각의 중앙에 비율을 표시하는 기법도 유용합니다.
  • 플롯 3 (누적 막대 그래프): geom_bar()position = "stack" (기본값)을 사용하면 각 x축 위치(시간대)에서 fill로 지정된 그룹(지점)의 값을 아래부터 차례로 쌓아 올립니다. 이를 통해 전체적인 시간대별 주문량 추이와 함께 각 시간대에서 지점별 기여도를 동시에 파악할 수 있습니다.
  • plot_layout(): patchwork에서 레이아웃을 더 세밀하게 제어할 때 사용합니다. heights = c(1, 1.2)는 위쪽 행과 아래쪽 행의 높이 비율을 1:1.2로 설정하여 아래쪽 플롯에 더 많은 공간을 할당하는 역할을 합니다.
  • 종합적 사고: 이 문제의 핵심은 단순히 그래프를 하나씩 그리는 것이 아니라, 전달하고자 하는 핵심 메시지(지점별 성과 비교)를 효과적으로 보여주기 위해 어떤 시각화 방법을 선택하고 어떻게 배치할지 종합적으로 기획하는 데이터 스토리텔링 능력입니다.

알겠습니다. 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 사용자를 위한 흥미롭고 심도 있는 R 프로그래밍 문제를 생성해 드리겠습니다.

주제: 기초 통계 기초 (확률분포, 기술통계, T-검정, ANOVA 등 가설검정) 범위: 중급 3단계 (231번 ~ 245번)


231. MMORPG 신규 아이템 드랍률 검증

문제 상황: 당신은 인기 MMORPG '아르카디아 연대기'의 데이터 분석가입니다. 최근 업데이트로 '심연의 드래곤'이라는 보스 몬스터가 추가되었고, 이 몬스터를 처치하면 2% 확률로 전설 등급 아이템 '드래곤의 숨결'을 얻을 수 있다고 공지했습니다. 하지만 유저 커뮤니티에서는 실제 드랍률이 이보다 낮다는 불만이 제기되고 있습니다. 당신은 데이터를 통해 이 주장이 맞는지 확인해야 합니다.

과제: 한 길드에서 '심연의 드래곤'을 100번 사냥하여 '드래곤의 숨결'을 단 1번 획득했다고 보고했습니다. 이항분포(Binomial Distribution)를 이용하여 다음 질문에 답하세요.

  1. 드랍률이 정말 2%일 때, 100번의 시도에서 아이템을 정확히 1번 획득할 확률은 얼마인지 계산하세요.
  2. 드랍률이 2%일 때, 100번의 시도에서 아이템을 1번 이하(0번 또는 1번)로 획득할 누적 확률은 얼마인지 계산하세요. 이 확률(p-value)을 통해 "드랍률이 2%보다 낮지 않다"는 귀무가설을 기각할 수 있는지 유의수준 5% (α = 0.05) 하에서 판단하세요.
  3. 100번의 시도에서 발생할 수 있는 아이템 획득 횟수(0~10회)에 대한 확률 질량 함수(PMF)를 시각화하여 결과를 직관적으로 보여주세요.

정답 코드

# 필요한 라이브러리 로드
library(ggplot2)

# 1. 100번 시도에서 정확히 1번 성공할 확률 계산
n_trials <- 100
p_success <- 0.02
k_success <- 1

prob_exact_one <- dbinom(x = k_success, size = n_trials, prob = p_success)
print(paste("정확히 1번 획득할 확률:", round(prob_exact_one, 4)))

# 2. 100번 시도에서 1번 이하로 성공할 누적 확률 (p-value) 계산
prob_one_or_less <- pbinom(q = k_success, size = n_trials, prob = p_success)
print(paste("1번 이하로 획득할 누적 확률 (p-value):", round(prob_one_or_less, 4)))

# 가설 검정 결론
alpha <- 0.05
if (prob_one_or_less < alpha) {
  print("유의수준 5% 하에서 귀무가설을 기각합니다. 드랍률이 2%보다 낮다고 볼 수 있습니다.")
} else {
  print("유의수준 5% 하에서 귀무가설을 기각할 수 없습니다. 드랍률이 2%보다 낮다는 충분한 증거가 없습니다.")
}

# 3. 확률 질량 함수(PMF) 시각화
possible_successes <- 0:10
probabilities <- dbinom(x = possible_successes, size = n_trials, prob = p_success)
pmf_data <- data.frame(
  Successes = possible_successes,
  Probability = probabilities
)

ggplot(pmf_data, aes(x = factor(Successes), y = Probability)) +
  geom_bar(stat = "identity", fill = "skyblue", color = "black") +
  geom_text(aes(label = round(Probability, 3)), vjust = -0.5, size = 3.5) +
  labs(
    title = "드래곤의 숨결 드랍 횟수 확률 분포 (n=100, p=0.02)",
    x = "획득 횟수",
    y = "확률"
  ) +
  theme_minimal()

해설

이 문제는 이항분포의 개념을 실제 게임 데이터 분석에 적용하는 예제입니다.

  1. 핵심 R 개념:

    • dbinom(x, size, prob): 이항분포의 확률 질량 함수(Probability Mass Function, PMF) 값을 계산합니다. x는 성공 횟수, size는 시도 횟수, prob는 1회 시도 시 성공 확률입니다. 이 문제에서는 100번 시도(size=100)해서 정확히 1번 성공(x=1)할 확률을 계산했습니다.
    • pbinom(q, size, prob): 이항분포의 누적 분포 함수(Cumulative Distribution Function, CDF) 값을 계산합니다. q번 이하로 성공할 확률의 총합을 구합니다. 여기서는 1번 이하(q=1)로 성공할 확률을 계산하여 p-value로 사용했습니다.
    • ggplot2: R의 대표적인 데이터 시각화 패키지입니다. geom_bar를 이용해 각 성공 횟수에 대한 확률을 막대그래프로 표현하여 분포를 직관적으로 이해할 수 있게 합니다.
  2. 통계적 근거:

    • 이항분포: 이항분포는 서로 독립적인 베르누이 시행을 n번 반복했을 때 성공 횟수 k의 확률분포를 나타냅니다. 이 문제의 상황(성공=아이템 획득, 실패=획득 못함)은 이항분포의 가정에 완벽하게 부합합니다.
    • 확률 질량 함수 (PMF): 특정 성공 횟수가 나타날 확률을 계산합니다. 공식은 다음과 같습니다. $$ P(X=k) = \binom{n}{k} p^k (1-p)^{n-k} $$ 여기서 $n=100$, $p=0.02$, $k=1$ 입니다. dbinom 함수가 이 계산을 수행합니다.
    • 가설검정:
      • 귀무가설 ($H_0$): 드랍률은 2%이다 ($p = 0.02$).
      • 대립가설 ($H_1$): 드랍률은 2%보다 낮다 ($p &lt; 0.02$). (단측 검정)
    • p-value: 귀무가설이 사실이라고 가정할 때, 우리가 관측한 결과(1번 획득) 또는 그보다 더 극단적인 결과(0번 획득)가 나타날 확률의 합입니다. pbinom(1, 100, 0.02)로 계산한 값이 바로 이 p-value입니다. 계산된 p-value(약 0.4033)가 유의수준 $\alpha=0.05$보다 크므로, 귀무가설을 기각할 수 없습니다. 즉, "100번 중 1번 나온 것이 우연히 발생할 수 있는 충분히 가능성 있는 일이며, 이것만으로는 드랍률이 2%보다 낮다고 단정할 수 없다"는 결론을 내립니다.

232. 스마트 카페의 시간당 고객 도착률 분석

문제 상황: 당신은 최신 기술을 도입한 '데이터 콩'이라는 스마트 카페의 매니저입니다. 피크 타임(오후 12시~2시) 동안의 고객 도착 패턴을 분석하여 효율적인 직원 배치를 계획하고자 합니다. 과거 데이터에 따르면 이 시간대에는 시간당 평균 10명의 고객이 도착하는 것으로 알려져 있습니다.

과제: 고객 도착과 같은 단위 시간/공간 내에서 발생하는 사건의 빈도는 푸아송 분포(Poisson Distribution)를 따릅니다. 푸아송 분포를 이용하여 다음을 분석하세요.

  1. 특정 1시간 동안 고객이 정확히 8명 도착할 확률을 계산하세요.
  2. 특정 1시간 동안 고객이 5명 미만으로 도착할 확률을 계산하세요. (직원을 줄여도 되는 상황)
  3. 특정 1시간 동안 고객이 15명 이상 도착할 확률을 계산하세요. (추가 인력이 필요한 비상 상황)
  4. 시간당 고객 도착 수(0~20명)에 대한 확률 분포를 시각화하세요.

정답 코드

# 필요한 라이브러리 로드
library(ggplot2)

# 시간당 평균 도착 고객 수 (lambda)
lambda <- 10

# 1. 정확히 8명 도착할 확률
prob_exact_eight <- dpois(x = 8, lambda = lambda)
print(paste("정확히 8명 도착할 확률:", round(prob_exact_eight, 4)))

# 2. 5명 미만(0~4명)으로 도착할 확률
# ppois는 q 이하일 확률이므로 q=4를 사용
prob_less_than_five <- ppois(q = 4, lambda = lambda)
print(paste("5명 미만으로 도착할 확률:", round(prob_less_than_five, 4)))

# 3. 15명 이상 도착할 확률
# P(X >= 15) = 1 - P(X <= 14)
prob_fifteen_or_more <- 1 - ppois(q = 14, lambda = lambda)
# 또는 ppois(q=14, lambda=lambda, lower.tail = FALSE) 사용 가능
print(paste("15명 이상 도착할 확률:", round(prob_fifteen_or_more, 4)))

# 4. 확률 분포 시각화
n_customers <- 0:20
probabilities <- dpois(x = n_customers, lambda = lambda)
poisson_data <- data.frame(
  Customers = n_customers,
  Probability = probabilities
)

ggplot(poisson_data, aes(x = factor(Customers), y = Probability)) +
  geom_bar(stat = "identity", fill = "coral", color = "black") +
  geom_text(aes(label = round(Probability, 3)), vjust = -0.5, size = 3, angle = 45) +
  labs(
    title = "시간당 고객 도착 수의 푸아송 분포 (λ=10)",
    x = "시간당 도착 고객 수",
    y = "확률"
  ) +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

해설

이 문제는 푸아송 분포를 이용해 실제 비즈니스 운영(인력 관리) 문제를 해결하는 과정을 보여줍니다.

  1. 핵심 R 개념:

    • dpois(x, lambda): 푸아송 분포의 확률 질량 함수(PMF) 값을 계산합니다. x는 발생 횟수, lambda는 단위 시간/공간 당 평균 발생 횟수($\lambda$)입니다.
    • ppois(q, lambda, lower.tail = TRUE): 푸아송 분포의 누적 분포 함수(CDF) 값을 계산합니다. q번 이하로 발생할 확률을 구합니다. lower.tail = FALSE 옵션을 주면 q번을 초과하여 발생할 확률($P(X>q)$)을 계산합니다. $P(X \ge q)$를 구하려면 1 - ppois(q-1, lambda) 또는 ppois(q-1, lambda, lower.tail=FALSE)를 사용해야 합니다.
  2. 통계적 근거:

    • 푸아송 분포: 단위 시간, 단위 공간 안에서 어떤 사건이 무작위로 발생할 때, 그 발생 횟수에 대한 확률분포입니다. 고객 도착, 전화 수신, 서버 에러 발생 등의 모델링에 널리 사용됩니다.
    • 확률 질량 함수 (PMF): 특정 횟수 k가 발생할 확률을 계산합니다. 공식은 다음과 같습니다. $$ P(X=k) = \frac{\lambda^k e^{-\lambda}}{k!} $$ 여기서 $\lambda=10$ (시간당 평균 10명)이고, dpois 함수가 이 계산을 수행합니다.
    • 결과 해석: 계산된 확률을 통해 의사결정을 할 수 있습니다. 예를 들어, 15명 이상 도착할 확률이 약 8.3%로 나왔다면, 이는 12시간 운영 중 약 1시간은 이런 상황이 발생할 수 있음을 의미합니다. 이 빈도를 고려하여 예비 인력 계획을 세울 수 있습니다. 5명 미만으로 도착할 확률은 약 2.9%로 매우 낮으므로, 피크 타임에 인력을 줄이는 것은 위험한 결정임을 알 수 있습니다.

233. 우주선 엔진 추력의 정규분포 분석

문제 상황: 당신은 차세대 우주 발사체 '스타게이저' 프로젝트의 데이터 과학자입니다. 새로 개발된 '랩터-V' 엔진의 추력(thrust)은 평균 1800 kN, 표준편차 50 kN의 정규분포를 따른다고 알려져 있습니다. 발사 성공을 위해서는 엔진 추력이 특정 범위를 유지하는 것이 매우 중요합니다.

과제: 엔진 추력이 정규분포를 따른다고 가정하고 다음을 계산하고 시각화하세요.

  1. 엔진 1개를 무작위로 테스트했을 때, 추력이 1700 kN 미만일 확률을 계산하세요. (추력 부족으로 인한 실패 위험)
  2. 추력이 1900 kN을 초과할 확률을 계산하세요. (과도한 추력으로 인한 기체 손상 위험)
  3. 추력이 발사 성공 범위인 1750 kN에서 1850 kN 사이에 위치할 확률을 계산하세요.
  4. 이 정규분포의 확률 밀도 함수(PDF)를 그리고, 위에서 계산한 3가지 경우의 영역을 그래프에 표시하세요.

정답 코드

# 필요한 라이브러리 로드
library(ggplot2)
library(dplyr)

# 정규분포의 파라미터 설정
mu <- 1800
sigma <- 50

# 1. 추력이 1700 kN 미만일 확률
prob_less_1700 <- pnorm(q = 1700, mean = mu, sd = sigma)
print(paste("추력이 1700 kN 미만일 확률:", round(prob_less_1700, 4)))

# 2. 추력이 1900 kN 초과할 확률
prob_over_1900 <- pnorm(q = 1900, mean = mu, sd = sigma, lower.tail = FALSE)
# 또는 1 - pnorm(1900, mean = mu, sd = sigma)
print(paste("추력이 1900 kN을 초과할 확률:", round(prob_over_1900, 4)))

# 3. 추력이 1750 kN ~ 1850 kN 사이일 확률
prob_between <- pnorm(1850, mu, sigma) - pnorm(1750, mu, sigma)
print(paste("추력이 1750 kN ~ 1850 kN 사이일 확률:", round(prob_between, 4)))

# 4. 확률 밀도 함수(PDF) 시각화
x_vals <- seq(mu - 4*sigma, mu + 4*sigma, length.out = 1000)
pdf_data <- data.frame(Thrust = x_vals, Density = dnorm(x_vals, mu, sigma))

ggplot(pdf_data, aes(x = Thrust, y = Density)) +
  geom_line(color = "blue", size = 1) +
  # 1700 미만 영역 채우기
  geom_area(data = filter(pdf_data, Thrust < 1700), aes(x = Thrust, y = Density), fill = "red", alpha = 0.5) +
  # 1900 초과 영역 채우기
  geom_area(data = filter(pdf_data, Thrust > 1900), aes(x = Thrust, y = Density), fill = "orange", alpha = 0.5) +
  # 1750 ~ 1850 사이 영역 채우기
  geom_area(data = filter(pdf_data, Thrust >= 1750 & Thrust <= 1850), aes(x = Thrust, y = Density), fill = "green", alpha = 0.5) +
  geom_vline(xintercept = mu, linetype = "dashed", color = "black") +
  annotate("text", x = mu, y = 0.007, label = paste("Mean =", mu), color = "black") +
  labs(
    title = "랩터-V 엔진 추력의 정규분포 (μ=1800, σ=50)",
    x = "추력 (kN)",
    y = "확률 밀도"
  ) +
  theme_minimal()

해설

이 문제는 공학 및 품질 관리 분야에서 정규분포가 어떻게 활용되는지 보여주는 예제입니다.

  1. 핵심 R 개념:

    • dnorm(x, mean, sd): 정규분포의 확률 밀도 함수(Probability Density Function, PDF) 값을 계산합니다. 특정 x 값에서의 확률 밀도를 나타내며, 그래프를 그릴 때 사용됩니다.
    • pnorm(q, mean, sd): 정규분포의 누적 분포 함수(CDF) 값을 계산합니다. 특정 값 q보다 작거나 같을 확률($P(X \le q)$)을 구합니다.
    • lower.tail = FALSE: pnorm 함수에서 이 옵션을 사용하면 $P(X &gt; q)$를 직접 계산할 수 있습니다.
    • dplyr::filter(): ggplot2와 함께 사용하여 특정 조건에 맞는 데이터 부분만 선택적으로 시각화할 때 매우 유용합니다. 여기서는 각 확률 영역을 다른 색으로 칠하기 위해 사용했습니다.
  2. 통계적 근거:

    • 정규분포: 자연 현상 및 사회 현상에서 가장 흔하게 나타나는 연속 확률분포로, 평균($\mu$)을 중심으로 좌우대칭인 종 모양을 가집니다. 표준편차($\sigma$)는 분포의 퍼진 정도를 나타냅니다.
    • 표준화 (Z-score): 특정 값 X가 평균으로부터 얼마나 떨어져 있는지를 표준편차 단위로 나타낸 값입니다. 공식은 $Z = (X - \mu) / \sigma$ 입니다. pnorm 함수는 내부적으로 이 표준화 과정을 거쳐 표준정규분포표(Z-table)에서 확률을 찾습니다.
      • $P(X &lt; 1700) = P(Z &lt; (1700-1800)/50) = P(Z &lt; -2)$
      • $P(X &gt; 1900) = P(Z &gt; (1900-1800)/50) = P(Z &gt; 2)$
      • $P(1750 &lt; X &lt; 1850)$$Z$값이 -1과 1 사이일 확률을 계산하는 것과 같습니다.
    • 시각화의 중요성: PDF 그래프에 확률 영역을 색으로 표시하면, 계산된 수치가 전체 분포에서 어떤 의미를 갖는지 직관적으로 파악할 수 있어 분석 결과를 보고하거나 설명할 때 매우 효과적입니다.

234. 고객센터 시스템 장애 발생 간격 분석

문제 상황: 당신은 대규모 온라인 쇼핑몰의 IT 인프라팀 소속 데이터 분석가입니다. 최근 고객센터 시스템에 장애가 잦다는 보고가 있었습니다. 과거 데이터를 분석한 결과, 시스템 장애는 평균적으로 48시간에 한 번씩 발생하는 것으로 나타났습니다. 다음 장애 발생 시점을 예측하여 대비하고자 합니다.

과제: 단위 시간당 평균 발생 횟수가 $\lambda$인 푸아송 과정을 따르는 사건에서, 한 사건이 발생한 후 다음 사건이 발생하기까지 걸리는 대기 시간은 지수분포(Exponential Distribution)를 따릅니다. 이를 이용하여 다음을 분석하세요. (시간 단위는 '시간'으로 통일합니다.)

  1. 장애 발생률($\lambda$, 시간당 평균 장애 발생 횟수)을 계산하세요.
  2. 마지막 장애 발생 후 24시간 이내에 다음 장애가 발생할 확률을 계산하세요.
  3. 마지막 장애 발생 후 72시간(3일) 이후에야 다음 장애가 발생할 확률을 계산하세요.
  4. 이 지수분포의 확률 밀도 함수(PDF)를 시각화하고, 24시간 이내 발생 확률 영역을 표시하세요.

정답 코드

# 필요한 라이브러리 로드
library(ggplot2)
library(dplyr)

# 1. 장애 발생률(lambda) 계산
# 평균 48시간에 1번 발생 -> 1시간에 1/48번 발생
rate_lambda <- 1 / 48
print(paste("시간당 평균 장애 발생률 (λ):", rate_lambda))

# 2. 24시간 이내에 다음 장애가 발생할 확률 P(T <= 24)
prob_within_24h <- pexp(q = 24, rate = rate_lambda)
print(paste("24시간 이내 장애 발생 확률:", round(prob_within_24h, 4)))

# 3. 72시간 이후에 다음 장애가 발생할 확률 P(T > 72)
prob_after_72h <- pexp(q = 72, rate = rate_lambda, lower.tail = FALSE)
# 또는 1 - pexp(72, rate = rate_lambda)
print(paste("72시간 이후 장애 발생 확률:", round(prob_after_72h, 4)))

# 4. 확률 밀도 함수(PDF) 시각화
time_vals <- seq(0, 200, length.out = 1000)
pdf_data <- data.frame(Time = time_vals, Density = dexp(time_vals, rate = rate_lambda))

ggplot(pdf_data, aes(x = Time, y = Density)) +
  geom_line(color = "darkgreen", size = 1) +
  geom_area(data = filter(pdf_data, Time <= 24), aes(x = Time, y = Density), fill = "orange", alpha = 0.6) +
  labs(
    title = "시스템 장애 발생 간격의 지수분포 (λ=1/48)",
    x = "마지막 장애 후 경과 시간 (시간)",
    y = "확률 밀도"
  ) +
  annotate("text", x = 50, y = 0.015, label = paste("P(T <= 24) =", round(prob_within_24h, 3)), color = "black") +
  theme_minimal()

해설

이 문제는 지수분포를 사용하여 시스템의 신뢰성이나 안정성을 평가하는 방법을 다룹니다.

  1. 핵심 R 개념:

    • dexp(x, rate): 지수분포의 확률 밀도 함수(PDF) 값을 계산합니다. rate는 비율 모수($\lambda$)입니다.
    • pexp(q, rate): 지수분포의 누적 분포 함수(CDF) 값을 계산합니다. 대기 시간이 q 이하일 확률($P(T \le q)$)을 구합니다.
    • 비율 모수(rate, $\lambda$): 푸아송 분포의 $\lambda$와 같은 의미로, 단위 시간당 평균 발생 횟수를 의미합니다. 평균 대기 시간이 $\beta$이면, 비율 모수는 $\lambda = 1/\beta$ 관계가 성립합니다. 이 문제에서는 평균 대기 시간이 48시간이므로, $\lambda = 1/48$ 입니다.
  2. 통계적 근거:

    • 지수분포: 어떤 사건이 발생할 때까지의 대기 시간에 대한 연속 확률분포입니다. 푸아송 분포와 밀접한 관련이 있으며, 특히 '무기억성(Memoryless Property)'이라는 중요한 특징을 가집니다.
    • 무기억성: 과거에 얼마나 기다렸는지가 미래에 더 기다릴 시간에 영향을 주지 않는다는 성질입니다. 즉, $P(T &gt; s+t | T &gt; s) = P(T &gt; t)$ 입니다. 예를 들어, 이미 20시간 동안 장애가 없었다고 해서 앞으로 1시간 내에 장애가 날 확률이 변하지 않는다는 의미입니다.
    • 누적 분포 함수 (CDF): 지수분포의 CDF는 다음과 같습니다. $$ F(t) = P(T \le t) = 1 - e^{-\lambda t} $$ pexp(24, 1/48)$1 - e^{-(1/48) \times 24} = 1 - e^{-0.5} \approx 0.3935$ 를 계산합니다.
    • 결과 해석: 마지막 장애 후 24시간 내에 또 장애가 발생할 확률이 약 39%로 꽤 높음을 알 수 있습니다. 이는 시스템 안정성이 낮다는 것을 의미하며, 개선이 시급하다는 데이터 기반의 근거가 됩니다.

235. 판타지 RPG 캐릭터 스탯 기술통계 분석

문제 상황: 당신은 신작 판타지 RPG 게임 '영혼의 조각'의 밸런스 디자이너입니다. 출시 전 베타 테스트에 참여한 1000명의 '전사' 클래스 캐릭터 레벨 50 기준의 스탯 데이터를 확보했습니다. 캐릭터 밸런스가 적절한지 평가하기 위해 주요 스탯(힘, 민첩, 체력)의 분포를 분석해야 합니다.

과제: 주어진 warrior_stats 데이터프레임에 대해 다음 기술통계 분석을 수행하세요.

  1. 각 스탯(힘, 민첩, 체력)의 평균, 중앙값, 표준편차, 최솟값, 최댓값, 사분위수(IQR 포함)를 포함한 요약 통계량을 구하세요.
  2. 각 스탯의 분포를 히스토그램과 밀도 곡선을 함께 그려 시각적으로 확인하세요.
  3. 각 스탯의 왜도(skewness)와 첨도(kurtosis)를 계산하여 분포의 비대칭성과 뾰족한 정도를 수치로 평가하세요.

정답 코드

# 필요한 라이브러리 로드
library(dplyr)
library(ggplot2)
library(tidyr)
library(moments) # 왜도, 첨도 계산을 위한 패키지

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(42)
n_chars <- 1000
warrior_stats <- data.frame(
  Strength = round(rnorm(n_chars, mean = 150, sd = 20)),
  Agility = round(rnorm(n_chars, mean = 80, sd = 15)),
  Stamina = round(rlnorm(n_chars, meanlog = 4.5, sdlog = 0.2)) # 체력은 로그-정규분포로 생성
)

# 1. 요약 통계량 계산
summary_stats <- summary(warrior_stats)
print(summary_stats)

# IQR을 명시적으로 계산
iqr_stats <- warrior_stats %>%
  summarise(across(everything(), list(IQR = IQR)))
print(iqr_stats)

# 2. 히스토그램 및 밀도 곡선 시각화
# 데이터를 긴 형식(long format)으로 변환하여 한번에 그리기
stats_long <- warrior_stats %>%
  pivot_longer(cols = everything(), names_to = "Stat", values_to = "Value")

ggplot(stats_long, aes(x = Value, fill = Stat)) +
  geom_histogram(aes(y = ..density..), bins = 30, alpha = 0.7, color = "black") +
  geom_density(alpha = 0.5) +
  facet_wrap(~ Stat, scales = "free") +
  labs(
    title = "전사 캐릭터 스탯 분포",
    x = "스탯 값",
    y = "밀도"
  ) +
  theme_bw() +
  theme(legend.position = "none")

# 3. 왜도(Skewness)와 첨도(Kurtosis) 계산
skewness_vals <- apply(warrior_stats, 2, skewness)
kurtosis_vals <- apply(warrior_stats, 2, kurtosis)

stat_analysis <- data.frame(
  Skewness = skewness_vals,
  Kurtosis = kurtosis_vals
)
print(stat_analysis)

해설

이 문제는 데이터 분석의 가장 기본이 되는 기술통계(Descriptive Statistics)를 R로 수행하는 방법을 다룹니다.

  1. 핵심 R 개념:

    • summary(): 데이터프레임의 각 열에 대한 기본적인 요약 통계량(Min, 1st Qu., Median, Mean, 3rd Qu., Max)을 한 번에 보여주는 매우 유용한 함수입니다.
    • dplyr::summarise()across(): dplyr을 사용하여 특정 통계량(예: IQR)을 유연하게 계산할 수 있습니다. across(everything(), ...)는 모든 열에 대해 지정된 함수를 적용하라는 의미입니다.
    • tidyr::pivot_longer(): 데이터를 '넓은 형식(wide format)'에서 '긴 형식(long format)'으로 변환합니다. 이는 ggplot2로 여러 그룹(여기서는 스탯 종류)의 데이터를 한 번에 시각화할 때 매우 효율적인 방법입니다.
    • facet_wrap(~ Stat, scales = "free"): ggplot2에서 Stat 변수의 각 수준별로 별도의 패널(그래프)을 생성합니다. scales = "free"는 각 패널의 x, y축 범위를 데이터에 맞게 자동으로 조절해줍니다.
    • moments 패키지: 왜도(skewness())와 첨도(kurtosis())를 쉽게 계산할 수 있는 함수를 제공합니다.
  2. 통계적 근거:

    • 중심 경향성(Central Tendency): 데이터가 어디에 집중되어 있는지를 나타냅니다.
      • 평균(Mean): 모든 값을 더해 개수로 나눈 값. 이상치(outlier)에 민감합니다.
      • 중앙값(Median): 데이터를 순서대로 나열했을 때 중앙에 위치하는 값. 이상치에 덜 민감합니다.
    • 산포도(Dispersion): 데이터가 얼마나 흩어져 있는지를 나타냅니다.
      • 표준편차(Standard Deviation): 데이터가 평균으로부터 얼마나 떨어져 있는지의 평균적인 거리.
      • 사분위수 범위(IQR, Interquartile Range): 3사분위수(Q3) - 1사분위수(Q1). 데이터의 중앙 50%가 포함되는 범위로, 이상치에 강건(robust)한 산포도 척도입니다.
    • 분포의 모양(Shape):
      • 왜도(Skewness): 분포의 비대칭성을 나타냅니다.
        • 왜도 > 0: 오른쪽으로 긴 꼬리 (Right-skewed, Positive skew). 예: Stamina 스탯.
        • 왜도 ≈ 0: 대칭에 가까움. 예: Strength, Agility 스탯.
        • 왜도 < 0: 왼쪽으로 긴 꼬리 (Left-skewed, Negative skew).
      • 첨도(Kurtosis): 분포의 뾰족한 정도를 나타냅니다. 정규분포의 첨도는 3입니다. (R의 moments 패키지는 초과 첨도(Excess Kurtosis = Kurtosis - 3)를 계산하므로, 0을 기준으로 판단합니다.)
        • 첨도 > 3 (초과 첨도 > 0): 정규분포보다 더 뾰족함 (Leptokurtic).
        • 첨도 ≈ 3 (초과 첨도 ≈ 0): 정규분포와 유사한 뾰족함 (Mesokurtic).
        • 첨도 < 3 (초과 첨도 < 0): 정규분포보다 더 완만함 (Platykurtic).

    결과 해석: 이 분석을 통해 '힘'과 '민첩'은 정규분포에 가까운 대칭적인 분포를 보이지만, '체력'은 오른쪽으로 꼬리가 긴 분포(소수의 고체력 캐릭터 존재)를 가짐을 파악할 수 있습니다. 이는 게임 밸런스에 의도된 것일 수도, 아닐 수도 있으며, 디자이너에게 중요한 통찰을 제공합니다.


236. 저지방 스낵의 영양성분 표시 검증 (단일표본 T-검정)

문제 상황: 당신은 식품회사 '헬시푸드'의 품질관리팀 데이터 분석가입니다. 새로 출시한 '라이트 크런치' 스낵의 포장지에는 지방 함량이 10g이라고 표시되어 있습니다. 소비자 단체에서 실제 지방 함량이 이보다 많다는 의혹을 제기했습니다. 당신은 생산된 스낵 중 30개를 무작위로 추출하여 지방 함량을 측정했습니다.

과제: 측정된 지방 함량 데이터(fat_content)를 이용하여, 이 스낵의 평균 지방 함량이 정말 10g이라고 할 수 있는지 단일표본 T-검정(One-sample t-test)을 통해 유의수준 5% (α = 0.05) 하에서 검증하세요.

  1. 데이터의 기술통계를 확인하고, 정규성 가정을 만족하는지 Shapiro-Wilk 검정을 수행하세요.
  2. 단일표본 T-검정을 수행하여 검정통계량(t-statistic), p-value, 신뢰구간을 구하세요.
  3. 검정 결과를 해석하여 "평균 지방 함량은 10g이다"라는 귀무가설을 채택할지 기각할지 결론을 내리세요.

정답 코드

# 필요한 라이브러리 로드
library(ggplot2)

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(123)
fat_content <- rnorm(30, mean = 10.5, sd = 1.2)

# 1. 기술통계 확인 및 정규성 검정
summary(fat_content)
sd(fat_content)

# 시각적 확인
hist(fat_content, breaks=10, col="lightblue", main="지방 함량 분포")
qqnorm(fat_content)
qqline(fat_content, col="red")

# Shapiro-Wilk 정규성 검정
shapiro_test <- shapiro.test(fat_content)
print(shapiro_test)
if (shapiro_test$p.value > 0.05) {
  print("p-value > 0.05 이므로, 데이터는 정규분포를 따른다는 귀무가설을 기각할 수 없습니다. (정규성 만족)")
} else {
  print("p-value <= 0.05 이므로, 데이터는 정규분포를 따르지 않습니다.")
}

# 2. 단일표본 T-검정 수행
mu0 <- 10 # 귀무가설의 평균
t_test_result <- t.test(fat_content, mu = mu0, alternative = "two.sided")
print(t_test_result)

# 3. 결과 해석
alpha <- 0.05
p_value <- t_test_result$p.value

cat("\n--- 최종 결론 ---\n")
cat("검정통계량 t-value:", round(t_test_result$statistic, 4), "\n")
cat("p-value:", round(p_value, 4), "\n")
cat("95% 신뢰구간:", round(t_test_result$conf.int[1], 4), "에서", round(t_test_result$conf.int[2], 4), "사이\n")

if (p_value < alpha) {
  cat("결론: p-value가 유의수준 0.05보다 작으므로 귀무가설을 기각합니다.\n")
  cat("즉, 스낵의 평균 지방 함량은 10g과 통계적으로 유의미한 차이가 있습니다.\n")
} else {
  cat("결론: p-value가 유의수준 0.05보다 크므로 귀무가설을 기각할 수 없습니다.\n")
  cat("즉, 스낵의 평균 지방 함량이 10g과 다르다는 충분한 증거를 찾지 못했습니다.\n")
}

해설

이 문제는 가장 기본적인 가설검정 방법 중 하나인 단일표본 T-검정을 실제 품질관리 시나리오에 적용하는 예제입니다.

  1. 핵심 R 개념:

    • shapiro.test(): 데이터의 정규성을 검정하는 Shapiro-Wilk 검정을 수행합니다. 이 검정의 귀무가설은 "데이터가 정규분포를 따른다"입니다. 따라서 p-value가 유의수준(보통 0.05)보다 크면 정규성 가정을 만족한다고 봅니다.
    • t.test(x, mu, alternative): T-검정을 수행하는 핵심 함수입니다.
      • x: 검정할 데이터 벡터.
      • mu: 귀무가설에서의 모평균 값.
      • alternative: 대립가설의 종류를 지정합니다.
        • "two.sided" (양측검정): 모평균이 mu와 같지 않다 ($H_1: \mu \neq \mu_0$).
        • "less" (단측검정): 모평균이 mu보다 작다 ($H_1: \mu &lt; \mu_0$).
        • "greater" (단측검정): 모평균이 mu보다 크다 ($H_1: \mu &gt; \mu_0$).
  2. 통계적 근거:

    • 단일표본 T-검정 (One-sample t-test): 표본 데이터의 평균이 우리가 알고 있거나 주장하는 특정 모평균($\mu_0$)과 통계적으로 유의미하게 다른지를 검정하는 방법입니다. 모분산을 모를 때, 표본분산으로 대체하여 검정통계량 t를 계산합니다.
    • 가설 설정:
      • 귀무가설 ($H_0$): 스낵의 평균 지방 함량은 10g이다 ($\mu = 10$).
      • 대립가설 ($H_1$): 스낵의 평균 지방 함량은 10g이 아니다 ($\mu \neq 10$).
    • 검정통계량 t: $$ t = \frac{\bar{x} - \mu_0}{s / \sqrt{n}} $$ 여기서 $\bar{x}$는 표본평균, $\mu_0$는 귀무가설의 평균(10), $s$는 표본표준편차, $n$은 표본 크기(30)입니다. 이 t값은 자유도(degree of freedom)가 $n-1$인 t-분포를 따릅니다.
    • p-value: 귀무가설이 맞다고 가정했을 때, 우리가 계산한 t값보다 더 극단적인 값이 관찰될 확률입니다. p-value가 유의수준 $\alpha$보다 작다는 것은, 우리가 관찰한 표본평균이 우연히 나타나기에는 너무 드문 값이라는 의미이므로, 귀무가설이 틀렸을 것이라는 강력한 증거가 됩니다.
    • 신뢰구간: 모평균이 존재할 것으로 추정되는 범위입니다. 95% 신뢰구간이 귀무가설의 평균값(10)을 포함하지 않으면, 귀무가설을 기각하는 것과 같은 결론을 내릴 수 있습니다. 이 예제에서는 신뢰구간(약 10.02 ~ 10.92)이 10을 포함하지 않으므로 귀무가설을 기각합니다.

237. 보드게임 주사위의 공정성 검증 (카이제곱 적합도 검정)

문제 상황: 당신은 보드게임 개발사 '다이스 팩토리'의 게임 디자이너입니다. 신작 게임에 사용될 6면체 주사위의 공정성을 테스트해야 합니다. 주사위를 300번 던져서 각 면이 나온 횟수를 기록했습니다. 이 주사위가 공정한지, 즉 각 면이 나올 확률이 1/6로 동일한지 검증하고 싶습니다.

과제: 관찰된 주사위 눈금 빈도 데이터(observed_counts)를 이용하여, 이 주사위가 공정하다고 할 수 있는지 카이제곱 적합도 검정(Chi-squared Goodness-of-Fit Test)을 유의수준 5% (α = 0.05) 하에서 수행하세요.

  1. 공정한 주사위라면 300번 던졌을 때 각 눈금이 나올 기대빈도를 계산하세요.
  2. 카이제곱 적합도 검정을 수행하여 검정통계량($\chi^2$), p-value를 구하세요.
  3. 검정 결과를 해석하여 "주사위는 공정하다"는 귀무가설을 채택할지 기각할지 결론을 내리세요.

정답 코드

# 관찰 빈도 데이터
observed_counts <- c(42, 55, 47, 58, 41, 57)
names(observed_counts) <- 1:6
print("관찰 빈도:")
print(observed_counts)

# 총 시도 횟수
total_rolls <- sum(observed_counts)

# 1. 기대 빈도 계산
# 공정한 주사위라면 각 면이 나올 확률은 1/6
expected_prob <- rep(1/6, 6)
expected_counts <- total_rolls * expected_prob
print("기대 빈도:")
print(expected_counts)

# 2. 카이제곱 적합도 검정 수행
# H0: 관찰된 빈도 분포가 기대 빈도 분포와 같다 (주사위가 공정하다).
# H1: 관찰된 빈도 분포가 기대 빈도 분포와 다르다 (주사위가 공정하지 않다).
chisq_test_result <- chisq.test(x = observed_counts, p = expected_prob)
print(chisq_test_result)

# 3. 결과 해석
alpha <- 0.05
p_value <- chisq_test_result$p.value

cat("\n--- 최종 결론 ---\n")
cat("카이제곱 검정통계량 (X-squared):", round(chisq_test_result$statistic, 4), "\n")
cat("p-value:", round(p_value, 4), "\n")

if (p_value < alpha) {
  cat("결론: p-value가 유의수준 0.05보다 작으므로 귀무가설을 기각합니다.\n")
  cat("즉, 이 주사위는 공정하지 않다고 볼 수 있는 통계적 근거가 있습니다.\n")
} else {
  cat("결론: p-value가 유의수준 0.05보다 크므로 귀무가설을 기각할 수 없습니다.\n")
  cat("즉, 주사위가 공정하지 않다는 충분한 증거를 찾지 못했습니다. 공정하다고 볼 수 있습니다.\n")
}

해설

이 문제는 범주형 데이터에 대한 가설검정 방법인 카이제곱 적합도 검정을 다룹니다.

  1. 핵심 R 개념:

    • chisq.test(x, p): 카이제곱 검정을 수행하는 함수입니다.
      • x: 관찰 빈도를 담은 숫자 벡터.
      • p: 기대 확률을 담은 숫자 벡터. p를 지정하면 적합도 검정을 수행합니다. 만약 p를 생략하고 x에 행렬(matrix)을 전달하면 독립성/동질성 검정을 수행합니다.
  2. 통계적 근거:

    • 카이제곱 적합도 검정 (Chi-squared Goodness-of-Fit Test): 관찰된 빈도 분포가 이론적으로 기대되는 빈도 분포와 얼마나 잘 맞는지를 검정하는 방법입니다.
    • 가설 설정:
      • 귀무가설 ($H_0$): 관찰 빈도는 기대 빈도와 같다. (즉, 주사위의 각 면이 나올 확률은 1/6이다.)
      • 대립가설 ($H_1$): 관찰 빈도는 기대 빈도와 다르다. (즉, 주사위의 각 면이 나올 확률이 1/6이 아니다.)
    • 기대 빈도 (Expected Frequency): 귀무가설이 사실일 때 각 범주에서 기대되는 빈도입니다. 계산은 (총 관측 수) $\times$ (기대 확률) 입니다. 이 문제에서는 $300 \times (1/6) = 50$ 입니다.
    • 카이제곱 검정통계량 ($\chi^2$): 관찰 빈도와 기대 빈도의 차이를 측정하는 값입니다. $$ \chi^2 = \sum_{i=1}^{k} \frac{(O_i - E_i)^2}{E_i} $$ 여기서 $O_i$는 i번째 범주의 관찰 빈도, $E_i$는 i번째 범주의 기대 빈도, $k$는 범주의 수(6)입니다. 이 통계량은 자유도가 $k-1$인 카이제곱 분포를 따릅니다.
    • 결과 해석: 계산된 $\chi^2$ 값이 클수록 관찰값과 기대값의 차이가 크다는 의미이며, 이는 귀무가설에 반하는 증거가 됩니다. chisq.test가 계산해주는 p-value를 보고 최종 결론을 내립니다. 이 예제에서는 p-value(0.5524)가 0.05보다 크므로, 관찰된 빈도와 기대 빈도의 차이가 통계적으로 유의미하지 않다고 판단합니다. 즉, 이 정도의 차이는 우연히 발생할 수 있는 수준이므로, 주사위가 공정하다는 귀무가설을 기각할 수 없습니다.

238. 웹사이트 A/B 테스트 효과 분석 (독립표본 T-검정)

문제 상황: 당신은 이커머스 플랫폼 '샵나우'의 마케터입니다. 구매 전환율을 높이기 위해 상품 페이지의 '구매하기' 버튼 색상을 기존의 파란색(A그룹)에서 초록색(B그룹)으로 바꾸는 A/B 테스트를 진행했습니다. 100명의 사용자에게는 기존 페이지를, 다른 100명의 사용자에게는 새로운 페이지를 보여주고 각 사용자의 세션 당 페이지에 머문 시간(초)을 측정했습니다.

과제: 두 그룹(A그룹: 파란 버튼, B그룹: 초록 버튼)의 평균 체류 시간에 통계적으로 유의미한 차이가 있는지 독립표본 T-검정(Independent two-sample t-test)을 유의수준 5% (α = 0.05) 하에서 검증하세요.

  1. 두 그룹 데이터의 정규성을 각각 Shapiro-Wilk 검정으로 확인하세요.
  2. 두 그룹의 분산이 동일한지 Levene 검정(또는 F-검정)으로 등분산성 가정을 확인하세요.
  3. 독립표본 T-검정을 수행하고 결과를 해석하여 "두 그룹의 평균 체류 시간은 차이가 없다"는 귀무가설을 검토하세요.

정답 코드

# 필요한 라이브러리 로드
library(car) # Levene 검사를 위해

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(2024)
group_A_time <- rnorm(100, mean = 120, sd = 30) # 기존 파란 버튼
group_B_time <- rnorm(100, mean = 135, sd = 32) # 새로운 초록 버튼

# 1. 정규성 검정
shapiro_A <- shapiro.test(group_A_time)
shapiro_B <- shapiro.test(group_B_time)
print("A 그룹 정규성 검정:")
print(shapiro_A)
print("B 그룹 정규성 검정:")
print(shapiro_B)

# 2. 등분산성 검정
# 데이터를 하나의 데이터프레임으로 합치기
session_data <- data.frame(
  time = c(group_A_time, group_B_time),
  group = factor(rep(c("A", "B"), each = 100))
)

# Levene 검정 (car 패키지)
levene_test_result <- leveneTest(time ~ group, data = session_data)
print("Levene 등분산성 검정:")
print(levene_test_result)

# 3. 독립표본 T-검정 수행
# Levene 검정의 p-value를 보고 var.equal 옵션 결정
# p-value > 0.05 이면 등분산 가정 (var.equal = TRUE)
# p-value <= 0.05 이면 이분산 가정 (var.equal = FALSE, 기본값)
is_equal_variance <- levene_test_result$`Pr(>F)`[1] > 0.05

t_test_result <- t.test(group_A_time, group_B_time, var.equal = is_equal_variance)
# 또는 t.test(time ~ group, data = session_data, var.equal = is_equal_variance)
print(t_test_result)

# 결과 해석
cat("\n--- 최종 결론 ---\n")
if (t_test_result$p.value < 0.05) {
  cat("결론: p-value가 유의수준 0.05보다 작으므로 귀무가설을 기각합니다.\n")
  cat("즉, 버튼 색상에 따른 평균 페이지 체류 시간에는 통계적으로 유의미한 차이가 있습니다.\n")
  if (t_test_result$estimate[1] < t_test_result$estimate[2]) {
    cat("초록색 버튼(B그룹)의 평균 체류 시간이 더 깁니다.\n")
  } else {
    cat("파란색 버튼(A그룹)의 평균 체류 시간이 더 깁니다.\n")
  }
} else {
  cat("결론: p-value가 유의수준 0.05보다 크므로 귀무가설을 기각할 수 없습니다.\n")
  cat("즉, 버튼 색상에 따른 평균 페이지 체류 시간의 차이가 통계적으로 유의미하지 않습니다.\n")
}

해설

이 문제는 A/B 테스트 결과를 분석하는 가장 표준적인 방법인 독립표본 T-검정을 다룹니다.

  1. 핵심 R 개념:

    • car::leveneTest(): 두 개 이상의 집단의 분산이 동일한지(등분산성)를 검정합니다. 귀무가설은 "집단들의 분산은 동일하다"입니다. p-value가 0.05보다 크면 등분산 가정을 만족한다고 봅니다. T-검정보다 정규성 가정에 덜 민감하여 더 선호됩니다.
    • t.test(x, y, var.equal): 두 독립표본에 대한 T-검정을 수행합니다.
      • x, y: 두 그룹의 데이터 벡터. 또는 formula 형식(y ~ group)으로도 사용 가능합니다.
      • var.equal: 등분산성 가정 여부를 지정하는 논리값. TRUE이면 전통적인 Student's t-test를, FALSE(기본값)이면 Welch's t-test를 수행합니다. Welch's t-test는 등분산 가정이 깨졌을 때 더 정확하므로, 일반적으로 leveneTest 결과에 따라 이 옵션을 설정하는 것이 좋습니다.
  2. 통계적 근거:

    • 독립표본 T-검정 (Independent two-sample t-test): 서로 독립적인 두 집단의 평균에 차이가 있는지를 검정하는 방법입니다.
    • 가설 설정:
      • 귀무가설 ($H_0$): 두 그룹의 평균 체류 시간은 같다 ($\mu_A = \mu_B$).
      • 대립가설 ($H_1$): 두 그룹의 평균 체류 시간은 다르다 ($\mu_A \neq \mu_B$).
    • 검정의 가정 (Assumptions):
      1. 독립성: 두 그룹은 서로 독립적이어야 합니다 (A/B 테스트 설계상 만족).
      2. 정규성: 각 그룹의 데이터는 정규분포를 따라야 합니다. (Shapiro-Wilk 검정으로 확인). 표본 크기가 충분히 크면(보통 30 이상) 중심극한정리에 의해 이 가정이 다소 완화될 수 있습니다.
      3. 등분산성: 두 그룹의 분산이 같아야 합니다. (Levene 검정으로 확인). 이 가정이 깨지면 Welch's t-test를 사용합니다.
    • 결과 해석: p-value가 유의수준 0.05보다 작으므로 귀무가설을 기각합니다. 이는 두 그룹의 평균 체류 시간 차이가 우연이라고 보기에는 너무 크다는 것을 의미합니다. 따라서 버튼 색상을 초록색으로 바꾼 것이 사용자의 체류 시간에 유의미한 영향을 미쳤다고 결론 내릴 수 있습니다. t.test 결과의 estimate 부분을 보면 각 그룹의 평균을 확인할 수 있어, 어느 쪽이 더 효과적인지 판단할 수 있습니다.

239. 신규 다이어트 보조제의 효과 검증 (대응표본 T-검정)

문제 상황: 당신은 제약회사 '바이오헬스'의 임상 데이터 분석가입니다. 새로 개발한 다이어트 보조제의 체중 감량 효과를 검증하기 위해 20명의 참가자를 모집하여 8주간 보조제를 복용하게 했습니다. 복용 전과 후의 체중 데이터를 수집했습니다.

과제: 이 다이어트 보조제가 체중 감량에 효과가 있었는지 대응표본 T-검정(Paired t-test)을 유의수준 5% (α = 0.05) 하에서 검증하세요.

  1. 복용 전과 후의 체중 차이(before - after)를 계산하고, 이 '차이' 데이터가 정규분포를 따르는지 확인하세요.
  2. 대응표본 T-검정을 수행하여 p-value와 신뢰구간을 구하세요.
  3. 검정 결과를 해석하여 "보조제 복용 전후 체중에 차이가 없다"는 귀무가설을 검토하고, 보조제의 효과에 대해 결론을 내리세요.

정답 코드

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(999)
participants <- 20
weight_before <- round(rnorm(participants, mean = 75, sd = 8), 1)
# 효과가 있도록 before보다 평균이 낮은 after 데이터 생성
weight_after <- round(weight_before - rnorm(participants, mean = 3, sd = 1.5), 1)

weight_data <- data.frame(
  ID = 1:participants,
  Before = weight_before,
  After = weight_after
)

# 1. 체중 차이 계산 및 정규성 검정
weight_data$Difference <- weight_data$Before - weight_data$After
print("복용 전후 체중 차이:")
print(weight_data$Difference)

shapiro_diff_test <- shapiro.test(weight_data$Difference)
print("체중 차이 데이터의 정규성 검정:")
print(shapiro_diff_test)

# 2. 대응표본 T-검정 수행
# H0: 복용 전후 체중 차이의 평균은 0이다 (mu_diff = 0)
# H1: 복용 전후 체중 차이의 평균은 0보다 크다 (mu_diff > 0, 체중이 감소했다)
paired_t_test_result <- t.test(weight_data$Before, weight_data$After, 
                               paired = TRUE, 
                               alternative = "greater")
print(paired_t_test_result)

# 3. 결과 해석
cat("\n--- 최종 결론 ---\n")
if (paired_t_test_result$p.value < 0.05) {
  cat("결론: p-value가 유의수준 0.05보다 작으므로 귀무가설을 기각합니다.\n")
  cat("즉, 다이어트 보조제 복용 후 체중이 통계적으로 유의미하게 감소했습니다.\n")
  mean_diff <- paired_t_test_result$estimate
  cat("평균 체중 감소량:", round(mean_diff, 2), "kg\n")
} else {
  cat("결론: p-value가 유의수준 0.05보다 크므로 귀무가설을 기각할 수 없습니다.\n")
  cat("즉, 보조제가 체중 감량에 유의미한 효과가 있다는 충분한 증거를 찾지 못했습니다.\n")
}

해설

이 문제는 동일한 대상에 대해 어떤 처치(treatment) 전후의 변화를 분석할 때 사용하는 대응표본 T-검정을 다룹니다.

  1. 핵심 R 개념:

    • t.test(x, y, paired = TRUE): 대응표본 T-검정을 수행합니다. paired = TRUE 옵션이 핵심입니다. 이 옵션을 주면 R은 xy의 차이(x-y)를 계산하여 이 차이값에 대한 단일표본 T-검정(귀무가설 평균=0)을 내부적으로 수행합니다.
    • alternative = "greater": 이 문제에서는 '체중 감소' 효과를 보고 싶으므로, 대립가설을 "복용 후 체중이 전보다 작다" 즉, "차이(Before - After)의 평균이 0보다 크다"로 설정합니다. 이러한 단측 검정은 우리가 기대하는 변화의 방향이 명확할 때 사용하며, 검정력을 높일 수 있습니다.
  2. 통계적 근거:

    • 대응표본 T-검정 (Paired t-test): 동일한 표본(또는 서로 짝지어진 표본)에 대해 두 가지 다른 조건 하에서 측정된 값의 평균을 비교하는 검정입니다. 예를 들어, 약물 투여 전/후, 교육 프로그램 참가 전/후 등의 효과를 분석하는 데 사용됩니다.
    • 검정 원리: 두 번의 측정값(Before, After)을 직접 비교하는 것이 아니라, 각 개체별 '차이'(Difference = Before - After)를 하나의 새로운 변수로 만듭니다. 그리고 이 '차이' 변수의 평균이 0과 유의미하게 다른지를 단일표본 T-검정으로 분석합니다.
    • 가설 설정:
      • 귀무가설 ($H_0$): 평균 차이는 0이다 ($\mu_D = 0$). 즉, 변화가 없다.
      • 대립가설 ($H_1$): 평균 차이는 0보다 크다 ($\mu_D &gt; 0$). 즉, 체중이 유의미하게 감소했다.
    • 가정: 독립표본 T-검정과는 달리, 각 그룹의 정규성이 아닌 '두 측정값의 차이'가 정규분포를 따른다는 가정이 필요합니다. 따라서 shapiro.testDifference 데이터에 적용해야 합니다.
    • 결과 해석: p-value가 매우 작게 나왔으므로(e.g., < 2.2e-16), 우연히 이런 결과가 나올 확률은 거의 없다는 의미입니다. 따라서 귀무가설을 기각하고, 이 보조제가 체중 감량에 통계적으로 매우 유의미한 효과가 있다고 결론 내릴 수 있습니다. estimate에 나오는 mean of the differences는 평균적인 체중 감소량을 나타냅니다.

240. 비료 종류에 따른 작물 수확량 비교 (일원분산분석, ANOVA)

문제 상황: 당신은 농업 기술 연구소의 데이터 과학자입니다. 새로 개발한 비료 A, B와 기존 비료(Control)가 옥수수 수확량에 미치는 영향을 비교하고자 합니다. 각각의 비료를 15개의 동일한 조건의 밭에 시비하고 수확량을 측정했습니다.

과제: 세 가지 비료(Control, Fertilizer A, Fertilizer B)에 따른 옥수수 평균 수확량에 차이가 있는지 일원분산분석(One-way ANOVA)을 유의수준 5% (α = 0.05) 하에서 검증하세요. 만약 차이가 있다면, 어떤 비료들 사이에 차이가 있는지 사후분석(Post-hoc test)을 통해 밝혀내세요.

  1. 데이터를 탐색하고 그룹별 기술통계 및 박스플롯을 통해 분포를 시각화하세요.
  2. 분산분석의 가정(정규성, 등분산성)을 확인하세요.
  3. 일원분산분석(ANOVA)을 수행하고 F-통계량과 p-value를 확인하세요.
  4. ANOVA 결과가 유의미할 경우, Tukey의 HSD(Honestly Significant Difference) 사후분석을 실시하여 구체적으로 어떤 그룹 간에 차이가 있는지 확인하세요.

정답 코드

# 필요한 라이브러리 로드
library(dplyr)
library(ggplot2)
library(car)

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(424)
yield_data <- data.frame(
  fertilizer = factor(rep(c("Control", "Fertilizer_A", "Fertilizer_B"), each = 15)),
  yield = c(rnorm(15, mean = 50, sd = 5),
            rnorm(15, mean = 58, sd = 5),
            rnorm(15, mean = 56, sd = 5))
)

# 1. 데이터 탐색 및 시각화
# 그룹별 기술통계
yield_data %>%
  group_by(fertilizer) %>%
  summarise(
    count = n(),
    mean = mean(yield),
    sd = sd(yield)
  )

# 박스플롯
ggplot(yield_data, aes(x = fertilizer, y = yield, fill = fertilizer)) +
  geom_boxplot() +
  labs(title = "비료 종류에 따른 옥수수 수확량", x = "비료 종류", y = "수확량 (kg/10a)") +
  theme_minimal()

# 2. ANOVA 가정 확인
# 2-1. 정규성 (모델의 잔차에 대해 확인)
aov_model_for_residuals <- aov(yield ~ fertilizer, data = yield_data)
residuals <- residuals(aov_model_for_residuals)
shapiro.test(residuals) # p-value > 0.05 이면 정규성 만족

# 2-2. 등분산성
leveneTest(yield ~ fertilizer, data = yield_data) # p-value > 0.05 이면 등분산성 만족

# 3. 일원분산분석(ANOVA) 수행
# H0: 세 그룹의 평균 수확량은 모두 같다 (mu_C = mu_A = mu_B)
# H1: 적어도 한 그룹의 평균 수확량은 다르다
anova_result <- aov(yield ~ fertilizer, data = yield_data)
summary(anova_result)

# 4. Tukey HSD 사후분석
# ANOVA 결과가 유의할 때만 수행 (F-검정의 p-value < 0.05)
anova_summary <- summary(anova_result)
if (anova_summary[[1]]$`Pr(>F)`[1] < 0.05) {
  print("ANOVA 결과가 유의하므로 사후분석을 실시합니다.")
  tukey_result <- TukeyHSD(anova_result)
  print(tukey_result)
  
  # 사후분석 결과 시각화
  plot(tukey_result)
} else {
  print("ANOVA 결과가 유의하지 않으므로 사후분석을 실시하지 않습니다.")
}

해설

이 문제는 세 개 이상의 집단 평균을 비교할 때 사용하는 분산분석(ANOVA)과, 그 결과 어떤 집단 간에 차이가 있는지를 구체적으로 밝히는 사후분석 방법을 다룹니다.

  1. 핵심 R 개념:

    • aov(formula, data): 분산분석 모델을 적합시키는 함수입니다. formula종속변수 ~ 독립변수 형태로 지정합니다.
    • summary(): aov 객체에 summary() 함수를 적용하면 분산분석표(ANOVA table)를 출력해줍니다. 이 표에서 F-통계량과 p-value를 확인할 수 있습니다.
    • residuals(): 모델 객체에서 잔차(residuals)를 추출합니다. ANOVA의 정규성 가정은 원본 데이터가 아닌, 이 잔차가 정규분포를 따르는지를 확인해야 합니다.
    • TukeyHSD(aov_object): Tukey의 HSD(Honestly Significant Difference) 사후분석을 수행합니다. 이 방법은 모든 가능한 그룹 쌍(Control-A, Control-B, A-B)에 대해 평균 차이를 검정하며, 다중비교로 인해 발생할 수 있는 1종 오류의 증가를 보정해줍니다.
  2. 통계적 근거:

    • 일원분산분석 (One-way ANOVA): 하나의 요인(독립변수, 여기서는 '비료 종류')에 따라 세 개 이상의 집단으로 나뉘었을 때, 이 집단들의 평균(종속변수, '수확량')이 동일한지를 검정하는 방법입니다.
    • ANOVA의 원리: 이름은 '분산'분석이지만, 실제로는 '평균'을 비교합니다. 그 원리는 데이터의 총 변동(Total Variation)을 '집단 간 변동(Between-group Variation)'과 '집단 내 변동(Within-group Variation)'으로 분해하는 것입니다.
      • 만약 집단 간 변동이 집단 내 변동에 비해 충분히 크다면, 집단 간 평균에 유의미한 차이가 있다고 결론 내립니다.
    • F-통계량: $$ F = \frac{\text{집단 간 분산 (MSB)}}{\text{집단 내 분산 (MSW)}} $$ 이 F-통계량 값이 클수록 집단 간 평균 차이가 크다는 증거가 됩니다.
    • 가설 설정:
      • 귀무가설 ($H_0$): $ \mu_{Control} = \mu_{A} = \mu_{B} $ (모든 그룹의 평균은 같다).
      • 대립가설 ($H_1$): 모든 그룹의 평균이 같지는 않다 (적어도 하나의 등호가 성립하지 않는다).
    • 사후분석 (Post-hoc Test): ANOVA의 대립가설이 채택되면, 우리는 "적어도 한 그룹은 다르다"는 것만 알 수 있습니다. 구체적으로 어떤 그룹들이 다른지를 알기 위해 사후분석을 실시합니다. Tukey HSD 결과표에서 p adj (adjusted p-value)가 0.05보다 작은 쌍이 통계적으로 유의미한 차이가 있는 그룹입니다. 이 예제에서는 Fertilizer_A-Control 쌍의 p-value가 매우 작으므로, 비료 A가 대조군보다 수확량을 유의미하게 증가시킨다고 결론 내릴 수 있습니다.

241. 광고비와 매출의 상관관계 분석

문제 상황: 당신은 온라인 의류 쇼핑몰 '패션포워드'의 데이터 분석가입니다. 지난 30일 동안의 일별 페이스북 광고비와 일별 총 매출 데이터를 수집했습니다. 광고비 지출이 매출에 얼마나 강한 선형 관계를 가지는지 파악하고 싶습니다.

과제: 주어진 광고비(ad_spend)와 매출(sales) 데이터에 대해 다음 분석을 수행하세요.

  1. 광고비와 매출 데이터에 대한 산점도(scatterplot)를 그려 두 변수 간의 관계를 시각적으로 탐색하세요.
  2. 피어슨(Pearson) 상관계수를 계산하여 두 변수 간의 선형 관계의 강도와 방향을 정량적으로 측정하세요.
  3. 계산된 상관계수에 대한 유의성 검정을 수행하여, 관찰된 상관관계가 통계적으로 유의미한지(즉, 우연히 발생한 것이 아닌지) 판단하세요.

정답 코드

# 필요한 라이브러리 로드
library(ggplot2)

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(314)
days <- 30
ad_spend <- round(runif(days, 50, 200)) # 50~200만원 사이의 광고비
# 매출 = 기본매출 + 광고비*효과 + 노이즈
sales <- round(1000 + ad_spend * 8 + rnorm(days, mean = 0, sd = 200))

marketing_data <- data.frame(ad_spend, sales)

# 1. 산점도 시각화
ggplot(marketing_data, aes(x = ad_spend, y = sales)) +
  geom_point(color = "blue", size = 3, alpha = 0.7) +
  geom_smooth(method = "lm", col = "red", se = FALSE) + # 선형 회귀선 추가
  labs(
    title = "광고비와 매출의 관계",
    x = "일별 광고비 (만원)",
    y = "일별 매출 (만원)"
  ) +
  theme_bw()

# 2 & 3. 피어슨 상관계수 계산 및 유의성 검정
correlation_test_result <- cor.test(marketing_data$ad_spend, marketing_data$sales,
                                    method = "pearson")
print(correlation_test_result)

# 결과 해석
cat("\n--- 최종 결론 ---\n")
r_value <- correlation_test_result$estimate
p_value <- correlation_test_result$p.value

cat("피어슨 상관계수 (r):", round(r_value, 4), "\n")
cat("p-value:", p_value, "\n")

if (p_value < 0.05) {
  cat("결론: p-value가 0.05보다 작으므로, 두 변수 간의 상관관계는 통계적으로 유의미합니다.\n")
  if (r_value > 0) {
    cat("광고비와 매출 사이에는 강한 양의 선형 관계가 있습니다.\n")
  } else {
    cat("광고비와 매출 사이에는 강한 음의 선형 관계가 있습니다.\n")
  }
} else {
  cat("결론: p-value가 0.05보다 크므로, 통계적으로 유의미한 상관관계를 찾을 수 없습니다.\n")
}

해설

이 문제는 두 연속형 변수 간의 선형 관계를 측정하는 가장 기본적인 방법인 상관 분석을 다룹니다.

  1. 핵심 R 개념:

    • geom_point(): ggplot2에서 산점도를 그리는 함수입니다. 두 변수 간의 관계 패턴을 시각적으로 파악하는 데 필수적입니다.
    • geom_smooth(method = "lm"): 산점도 위에 데이터에 가장 잘 맞는 선(회귀선)을 추가합니다. method="lm"은 선형 모델(linear model)을 의미합니다. 이 선의 기울기를 통해 관계의 방향성을 시각적으로 확인할 수 있습니다.
    • cor.test(x, y, method): 두 변수 xy 간의 상관계수를 계산하고 유의성 검정을 함께 수행합니다.
      • method = "pearson": 피어슨 상관계수를 계산합니다. 두 변수가 모두 연속형이고, 선형 관계를 가지며, 정규분포를 따른다고 가정할 때 사용합니다. (가정이 깨지면 "kendall"이나 "spearman"을 사용합니다.)
  2. 통계적 근거:

    • 상관 분석 (Correlation Analysis): 두 변수가 함께 변화하는 경향, 즉 선형 관계의 강도와 방향을 분석하는 기법입니다.
    • 피어슨 상관계수 (r, Pearson Correlation Coefficient): -1과 1 사이의 값을 가집니다.
      • $r \approx 1$: 강한 양의 선형 관계 (한 변수가 증가하면 다른 변수도 증가).
      • $r \approx -1$: 강한 음의 선형 관계 (한 변수가 증가하면 다른 변수는 감소).
      • $r \approx 0$: 선형 관계가 거의 없음.
    • 상관관계는 인과관계가 아님 (Correlation is not Causation): 상관계수가 높게 나왔다고 해서 광고비가 매출을 '유발'했다고 단정할 수는 없습니다. 제3의 요인(예: 계절적 특수, 프로모션 등)이 두 변수 모두에 영향을 미쳤을 수도 있습니다. 상관 분석은 관계의 존재를 보여줄 뿐, 인과관계를 증명하지는 않습니다.
    • 유의성 검정:
      • 귀무가설 ($H_0$): 두 변수 간의 상관계수는 0이다 ($\rho = 0$). 즉, 선형 관계가 없다.
      • 대립가설 ($H_1$): 두 변수 간의 상관계수는 0이 아니다 ($\rho \neq 0$). 즉, 선형 관계가 있다.
    • 결과 해석: 이 예제에서 상관계수(r)는 약 0.93으로 매우 높은 양의 값을 보이며, p-value는 매우 작습니다. 이는 광고비와 매출 사이에 매우 강한 양의 선형 관계가 있으며, 이 관계가 우연에 의한 것이 아님을 강력하게 시사합니다.

242. 중고차 가격 예측 모델링 (단순 선형 회귀분석)

문제 상황: 당신은 중고차 거래 플랫폼 '카마켓'의 데이터 분석가입니다. 자동차의 주행거리(km)가 중고차 가격에 어떤 영향을 미치는지 분석하고, 이를 바탕으로 주행거리를 이용해 가격을 예측하는 단순 모델을 만들고자 합니다.

과제: 주어진 중고차 데이터(cars_data)에서 주행거리(mileage)를 독립변수, 가격(price)을 종속변수로 하여 다음의 단순 선형 회귀분석을 수행하세요.

  1. 주행거리에 따른 가격의 변화를 산점도로 시각화하세요.
  2. 단순 선형 회귀 모델을 적합(fitting)시키고, 모델의 요약 정보(회귀계수, R-squared 등)를 출력하세요.
  3. 회귀 모델의 결과를 해석하세요. (회귀식 도출, 주행거리 1km 증가 시 가격 변화, 모델 설명력 평가)
  4. 적합된 모델을 사용하여 주행거리가 80,000km인 자동차의 예상 가격을 예측하세요.

정답 코드

# 필요한 라이브러리 로드
library(ggplot2)

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(500)
n_cars <- 100
mileage <- round(runif(n_cars, 10000, 150000)) # 1만 ~ 15만 km
# 가격 = 기본가격 - 주행거리*감가상각 + 노이즈
price <- round(3000 - mileage * 0.15 + rnorm(n_cars, 0, 200))

cars_data <- data.frame(mileage, price)

# 1. 산점도 시각화
ggplot(cars_data, aes(x = mileage, y = price)) +
  geom_point(aes(color = price), alpha = 0.8) +
  scale_color_gradient(low = "blue", high = "red") +
  geom_smooth(method = "lm", color = "black") +
  labs(title = "주행거리에 따른 중고차 가격", x = "주행거리 (km)", y = "가격 (만원)") +
  theme_minimal()

# 2. 단순 선형 회귀 모델 적합
# price = b0 + b1 * mileage
lm_model <- lm(price ~ mileage, data = cars_data)
summary(lm_model)

# 3. 회귀 모델 결과 해석
model_summary <- summary(lm_model)
intercept <- coef(lm_model)[1]
slope <- coef(lm_model)[2]
r_squared <- model_summary$r.squared

cat("\n--- 회귀 모델 해석 ---\n")
cat("1. 회귀식:", paste0("Price = ", round(intercept, 2), " + (", round(slope, 4), ") * Mileage\n"))
cat("2. 절편(Intercept) 의미: 주행거리가 0km인 신차의 이론적 가격은 약", round(intercept, 2), "만원입니다.\n")
cat("3. 기울기(Slope) 의미: 주행거리가 1km 증가할 때마다 중고차 가격은 평균적으로 약", abs(round(slope, 4)), "만원 감소합니다.\n")
cat("4. 모델 설명력 (R-squared):", round(r_squared, 4), "입니다. 이는 가격 변동의 약", round(r_squared*100, 2), "%가 주행거리에 의해 설명됨을 의미합니다.\n")
cat("5. 기울기(mileage)의 p-value가 매우 작으므로, 주행거리는 가격에 통계적으로 유의미한 영향을 미칩니다.\n")

# 4. 새로운 데이터 예측
new_mileage <- data.frame(mileage = 80000)
predicted_price <- predict(lm_model, newdata = new_mileage)

cat("\n--- 가격 예측 ---\n")
cat("주행거리가 80,000 km인 자동차의 예상 가격은 약", round(predicted_price, 2), "만원입니다.\n")

해설

이 문제는 한 변수(독립변수)를 사용하여 다른 변수(종속변수)를 예측하는 단순 선형 회귀분석의 전 과정을 다룹니다.

  1. 핵심 R 개념:

    • lm(formula, data): 선형 모델(Linear Model)을 적합시키는 R의 핵심 함수입니다. formula종속변수 ~ 독립변수 형식으로 지정합니다.
    • summary(): lm 객체에 적용하면 모델에 대한 상세한 요약 정보를 제공합니다. 이 요약 정보에는 회귀계수, 표준오차, t-값, p-값, R-squared, F-통계량 등 모델 평가에 필요한 모든 정보가 포함되어 있습니다.
    • coef(): 적합된 모델에서 회귀계수(절편과 기울기)만을 추출합니다.
    • predict(model, newdata): 학습된 model을 사용하여 새로운 데이터(newdata)에 대한 예측값을 생성합니다. newdata는 모델 학습에 사용된 독립변수와 동일한 이름의 열을 가진 데이터프레임이어야 합니다.
  2. 통계적 근거:

    • 단순 선형 회귀분석 (Simple Linear Regression): 하나의 독립변수 $X$와 종속변수 $Y$ 사이의 선형 관계를 모델링하는 기법입니다. 모델의 형태는 다음과 같습니다. $$ Y = \beta_0 + \beta_1 X + \epsilon $$ 여기서 $\beta_0$는 절편(intercept), $\beta_1$은 기울기(slope), $\epsilon$은 오차항(error term)입니다. lm 함수는 이 $\beta_0$$\beta_1$의 추정치($b_0$, $b_1$)를 계산합니다.
    • 회귀계수 해석:
      • 절편 ($\beta_0$): 독립변수 $X$가 0일 때의 $Y$의 기댓값입니다. 이 문제에서는 '주행거리가 0인 신차의 가격'으로 해석할 수 있습니다.
      • 기울기 ($\beta_1$): 독립변수 $X$가 한 단위 증가할 때 $Y$가 변화하는 양의 기댓값입니다. 이 문제에서는 '주행거리가 1km 늘어날 때마다 가격이 얼마나 감소하는지'를 나타냅니다.
    • 결정계수 (R-squared, $R^2$): 모델의 설명력을 나타내는 지표로, 0과 1 사이의 값을 가집니다. 종속변수($Y$)의 총 변동 중에서 회귀 모델에 의해 설명되는 변동의 비율을 의미합니다. $R^2$가 1에 가까울수록 모델이 데이터를 잘 설명한다고 할 수 있습니다. 이 예제에서는 $R^2$가 약 0.92로, 주행거리가 중고차 가격 변동의 92%를 설명하는 매우 강력한 모델임을 알 수 있습니다.
    • 가설 검정: summary 결과에서 Coefficients 테이블의 mileage 행을 보면, 그 p-value(Pr(>|t|))가 매우 작습니다. 이는 "기울기 $\beta_1$이 0이다(즉, 주행거리는 가격에 영향이 없다)"라는 귀무가설을 기각하고, 주행거리가 가격에 유의미한 영향을 미친다는 것을 통계적으로 뒷받침합니다.

243. 신규 명상 앱의 스트레스 완화 효과 분석 (비모수 검정)

문제 상황: 당신은 멘탈 헬스케어 스타트업 '마인드풀'의 데이터 과학자입니다. 새로 개발한 명상 앱의 스트레스 완화 효과를 측정하고자 합니다. 20명의 실험 참가자를 두 그룹으로 나누어, 한 그룹(A그룹)은 명상 앱을 사용하게 하고, 다른 그룹(B그룹)은 아무런 처치도 하지 않았습니다. 2주 후, 두 그룹 모두에게 스트레스 지수를 1(전혀 없음)부터 10(매우 심함)까지의 정수 척도로 응답하게 했습니다. 데이터가 정규분포를 따르지 않을 가능성이 있어 비모수적인 방법으로 분석하고자 합니다.

과제: 두 그룹의 스트레스 지수 중앙값에 차이가 있는지 윌콕슨 순위합 검정(Wilcoxon Rank-Sum Test, 또는 Mann-Whitney U Test)을 유의수준 5% (α = 0.05) 하에서 검증하세요.

  1. 두 그룹의 데이터를 박스플롯으로 시각화하여 분포를 비교하세요.
  2. 두 그룹 데이터의 정규성을 Shapiro-Wilk 검정으로 확인하여 비모수 검정의 타당성을 확보하세요.
  3. 윌콕슨 순위합 검정을 수행하고 결과를 해석하세요.

정답 코드

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(111)
# 명상 앱 그룹(A)은 스트레스 지수가 낮을 것으로 예상
stress_A <- sample(1:6, size = 10, replace = TRUE, prob = c(0.2, 0.3, 0.2, 0.15, 0.1, 0.05))
# 대조군(B)
stress_B <- sample(3:10, size = 10, replace = TRUE, prob = c(0.05, 0.1, 0.15, 0.2, 0.2, 0.15, 0.1, 0.05))

stress_data <- data.frame(
  score = c(stress_A, stress_B),
  group = factor(rep(c("MeditationApp", "Control"), each = 10))
)

# 1. 박스플롯 시각화
library(ggplot2)
ggplot(stress_data, aes(x = group, y = score, fill = group)) +
  geom_boxplot() +
  geom_jitter(width = 0.1, alpha = 0.6) +
  labs(title = "그룹별 스트레스 지수 분포", x = "그룹", y = "스트레스 지수 (1-10)") +
  theme_minimal()

# 2. 정규성 검정
shapiro.test(stress_A)
shapiro.test(stress_B)
# p-value가 작게 나와 정규성 가정을 만족하지 못할 가능성이 높음

# 3. 윌콕슨 순위합 검정 (Mann-Whitney U Test)
# H0: 두 그룹의 분포(중앙값)는 동일하다.
# H1: 두 그룹의 분포(중앙값)는 다르다.
wilcox_test_result <- wilcox.test(stress_A, stress_B)
# 또는 wilcox.test(score ~ group, data = stress_data)
print(wilcox_test_result)

# 결과 해석
cat("\n--- 최종 결론 ---\n")
if (wilcox_test_result$p.value < 0.05) {
  cat("결론: p-value가 유의수준 0.05보다 작으므로 귀무가설을 기각합니다.\n")
  cat("즉, 명상 앱 사용 그룹과 대조군의 스트레스 지수 분포(중앙값)에는 통계적으로 유의미한 차이가 있습니다.\n")
} else {
  cat("결론: p-value가 유의수준 0.05보다 크므로 귀무가설을 기각할 수 없습니다.\n")
  cat("즉, 두 그룹 간 스트레스 지수에 유의미한 차이가 있다는 증거를 찾지 못했습니다.\n")
}

해설

이 문제는 T-검정의 가정을 만족하지 못할 때(특히 데이터가 정규분포를 따르지 않거나, 순서형 척도일 때) 사용하는 대표적인 비모수 검정 방법인 윌콕슨 순위합 검정을 다룹니다.

  1. 핵심 R 개념:

    • wilcox.test(x, y): 윌콕슨 검정을 수행합니다. 두 개의 독립적인 표본 xy를 입력하면 윌콕슨 순위합 검정(Mann-Whitney U test)을 수행합니다. 만약 paired = TRUE 옵션을 주면 대응표본에 대한 윌콕슨 부호 순위 검정(Wilcoxon signed-rank test)을 수행합니다.
    • geom_jitter(): 박스플롯 위에 실제 데이터 포인트를 흩뿌려주는 ggplot2 함수입니다. 데이터의 양이 적을 때 분포를 더 명확하게 보여주는 효과가 있습니다.
  2. 통계적 근거:

    • 비모수 검정 (Non-parametric Test): 모집단의 분포에 대한 가정을 하지 않는 통계적 검정 방법입니다. 데이터가 특정 분포(예: 정규분포)를 따른다고 가정하지 않기 때문에 '분포 무관(distribution-free)' 검정이라고도 불립니다.
    • 윌콕슨 순위합 검정 (Wilcoxon Rank-Sum Test): 독립표본 T-검정의 비모수적 대응 방법입니다. 두 집단의 평균을 비교하는 대신, 두 집단의 '중앙값' 또는 '분포의 위치'가 같은지를 검정합니다.
    • 검정 원리:
      1. 두 그룹의 모든 데이터를 합쳐서 크기 순으로 정렬합니다.
      2. 각 데이터에 순위(rank)를 매깁니다. (동일한 값이 있으면 평균 순위를 부여)
      3. 한 그룹(보통 표본 크기가 작은 그룹)의 순위들의 합(Rank-Sum)을 구합니다. 이것이 검정 통계량이 됩니다.
      4. 이 순위합이 우연히 나올 수 있는 범위를 벗어나는지를 확인하여 가설을 검정합니다.
    • 가설 설정:
      • 귀무가설 ($H_0$): 두 집단의 분포는 동일하다. (두 집단에서 뽑은 값들이 같은 모집단에서 나왔다.)
      • 대립가설 ($H_1$): 두 집단의 분포는 동일하지 않다. (한 집단의 값들이 다른 집단의 값들보다 체계적으로 크거나 작다.)
    • 언제 사용하는가?:
      1. 데이터가 정규분포를 따르지 않을 때.
      2. 데이터가 순서형 척도(Ordinal scale, 예: 만족도 15점, 스트레스 110점)일 때.
      3. 표본 크기가 매우 작아서 정규성을 가정하기 어려울 때.
      4. 이상치(outlier)가 존재하여 평균보다 중앙값으로 비교하는 것이 더 적절할 때.

244. 스트리밍 서비스 추천 알고리즘 성능 비교 종합 분석

문제 상황: 당신은 동영상 스트리밍 서비스 '뷰플릭스'의 데이터 분석가입니다. 새로운 콘텐츠 추천 알고리즘(알고리즘 B)을 개발하여 기존 알고리즘(알고리즘 A)과 성능을 비교하고자 합니다. 200명의 사용자를 100명씩 두 그룹으로 나누어 각각 다른 알고리즘을 적용하고, 일주일 후 사용자별 총 시청 시간(분)을 측정했습니다.

과제: 이 데이터를 바탕으로 새로운 알고리즘 B가 기존 알고리즘 A보다 우수한지 종합적으로 분석하세요.

  1. 두 알고리즘 그룹(A, B)의 시청 시간 데이터에 대한 주요 기술통계량(평균, 중앙값, 표준편차)을 계산하고 비교하세요.
  2. 두 그룹의 데이터 분포를 밀도 그림(density plot)으로 시각화하여 직관적으로 비교하세요.
  3. 독립표본 T-검정을 수행하여 두 알고리즘의 평균 시청 시간에 통계적으로 유의미한 차이가 있는지 검증하세요. (단, T-검정의 가정인 정규성 및 등분산성을 먼저 확인해야 합니다.)
  4. 분석 결과를 종합하여 "새로운 알고리즘 B가 기존 알고리즘 A보다 성능이 우수하다"는 주장에 대한 결론을 내리세요.

정답 코드

# 필요한 라이브러리 로드
library(dplyr)
library(ggplot2)
library(car)

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(2025)
algo_A_watchtime <- rnorm(100, mean = 350, sd = 80)
algo_B_watchtime <- rnorm(100, mean = 390, sd = 85)

streaming_data <- data.frame(
  watch_time = c(algo_A_watchtime, algo_B_watchtime),
  algorithm = factor(rep(c("Algorithm_A", "Algorithm_B"), each = 100))
)

# 1. 기술통계량 계산 및 비교
summary_stats <- streaming_data %>%
  group_by(algorithm) %>%
  summarise(
    mean_time = mean(watch_time),
    median_time = median(watch_time),
    sd_time = sd(watch_time),
    count = n()
  )
print("--- 1. 기술통계량 비교 ---")
print(summary_stats)

# 2. 밀도 그림 시각화
ggplot(streaming_data, aes(x = watch_time, fill = algorithm)) +
  geom_density(alpha = 0.6) +
  geom_vline(data = summary_stats, aes(xintercept = mean_time, color = algorithm),
             linetype = "dashed", size = 1) +
  scale_color_manual(values = c("Algorithm_A" = "red", "Algorithm_B" = "blue")) +
  scale_fill_manual(values = c("Algorithm_A" = "red", "Algorithm_B" = "blue")) +
  labs(
    title = "알고리즘별 사용자 시청 시간 분포",
    x = "총 시청 시간 (분)",
    y = "밀도"
  ) +
  theme_bw()

# 3. T-검정 수행 (가정 확인 포함)
cat("\n--- 3. T-검정 및 가정 확인 ---\n")
# 3-1. 정규성 검정 (각 그룹별)
normality_A <- shapiro.test(filter(streaming_data, algorithm == "Algorithm_A")$watch_time)
normality_B <- shapiro.test(filter(streaming_data, algorithm == "Algorithm_B")$watch_time)
cat("알고리즘 A 정규성 p-value:", normality_A$p.value, "\n")
cat("알고리즘 B 정규성 p-value:", normality_B$p.value, "\n")
# 두 p-value 모두 0.05보다 크므로 정규성 가정 만족

# 3-2. 등분산성 검정
levene_test <- leveneTest(watch_time ~ algorithm, data = streaming_data)
cat("등분산성(Levene's Test) p-value:", levene_test$`Pr(>F)`[1], "\n")
# p-value > 0.05 이므로 등분산성 가정 만족

# 3-3. 독립표본 T-검정
# H0: 두 알고리즘의 평균 시청 시간은 같다.
# H1: 알고리즘 B의 평균 시청 시간이 A보다 길다.
t_test_result <- t.test(watch_time ~ algorithm, data = streaming_data,
                        var.equal = TRUE, # 등분산성 만족
                        alternative = "less") # B가 더 큰지 보려면 A-B < 0 인지 봐야함
print(t_test_result)

# 4. 최종 결론
cat("\n--- 4. 종합 결론 ---\n")
p_value <- t_test_result$p.value
mean_A <- summary_stats$mean_time[1]
mean_B <- summary_stats$mean_time[2]

cat(paste0("알고리즘 A의 평균 시청 시간은 ", round(mean_A, 2), "분, 알고리즘 B는 ", round(mean_B, 2), "분입니다.\n"))
cat(paste0("T-검정 결과 p-value는 ", format.pval(p_value, digits = 4), "입니다.\n"))

if (p_value < 0.05) {
  cat("결론: p-value가 유의수준 0.05보다 작으므로, 귀무가설을 기각합니다.\n")
  cat("새로운 추천 알고리즘 B는 기존 알고리즘 A에 비해 사용자의 평균 시청 시간을 통계적으로 유의미하게 증가시켰다고 결론 내릴 수 있습니다.\n")
} else {
  cat("결론: p-value가 유의수준 0.05보다 크므로, 귀무가설을 기각할 수 없습니다.\n")
  cat("두 알고리즘 간의 평균 시청 시간 차이가 통계적으로 유의미하다는 충분한 증거를 찾지 못했습니다.\n")
}

해설

이 문제는 실제 데이터 분석 프로젝트의 축소판과 같이, 기술통계 확인, 시각화를 통한 탐색, 가설검정을 통한 통계적 추론, 그리고 최종 결론 도출까지의 전 과정을 체계적으로 수행하는 능력을 평가합니다.

  1. 데이터 분석 흐름:

    • 1단계 (탐색): dplyr을 이용한 기술통계량 계산과 ggplot2를 이용한 시각화는 데이터의 전반적인 특징과 그룹 간의 차이를 직관적으로 파악하는 첫걸음입니다. 시각화 결과, 알고리즘 B의 분포가 전체적으로 오른쪽으로 이동해 있는 것을 통해 B의 시청 시간이 더 길 것이라고 잠정적으로 추측할 수 있습니다.
    • 2단계 (가정 확인): 가설검정(T-검정)을 수행하기 전에, 해당 검정 방법이 요구하는 통계적 가정이 데이터에 부합하는지 반드시 확인해야 합니다. shapiro.test로 정규성을, leveneTest로 등분산성을 확인하는 것은 분석의 신뢰도를 높이는 중요한 절차입니다.
    • 3단계 (통계적 추론): 가정이 만족됨을 확인한 후, t.test를 수행하여 관찰된 평균의 차이가 통계적으로 유의미한지, 즉 우연히 발생한 수준을 넘어서는지를 p-value를 통해 객관적으로 판단합니다.
    • 4단계 (결론 도출): 모든 분석 결과를 종합하여 명확하고 간결한 결론을 내립니다. 단순히 "p-value가 0.05보다 작다"가 아니라, "새로운 알고리즘 B가 기존 알고리즘 A보다 사용자 시청 시간을 유의미하게 증가시켰다"와 같이 문제의 맥락에 맞는 해석을 제공해야 합니다.
  2. t.testalternative 옵션:

    • alternative = "less": 이 옵션은 "첫 번째 그룹의 평균이 두 번째 그룹의 평균보다 작다"($\mu_A &lt; \mu_B$)를 대립가설로 설정합니다. R에서 factor의 레벨 순서에 따라 Algorithm_A가 첫 번째 그룹이 됩니다. 따라서 t.test(watch_time ~ algorithm, ...)에서 이 옵션은 우리가 검증하고자 하는 "알고리즘 B의 성능이 더 우수하다"는 가설과 일치합니다.
    • 만약 alternative = "greater"를 사용했다면 "A가 B보다 크다"를 검정하게 되고, alternative = "two.sided"(기본값)를 사용했다면 "A와 B는 다르다"를 검정하게 됩니다. 우리가 원하는 방향성이 명확하므로 단측 검정을 사용하는 것이 더 적절합니다.

245. 게임 서버 지역별 플레이 시간 차이 분석 (ANOVA 종합)

문제 상황: 당신은 글로벌 게임사 '월드 오브 코드크래프트'의 데이터 분석가입니다. 북미(NA), 유럽(EU), 아시아(Asia) 3개 지역 서버의 유저 그룹별 일주일 평균 플레이 시간에 차이가 있는지 분석하여 지역별 마케팅 및 운영 전략에 활용하고자 합니다.

과제: 세 지역 서버 사용자들의 평균 플레이 시간에 통계적으로 유의미한 차이가 있는지 종합적으로 분석하세요.

  1. 지역별(NA, EU, Asia) 평균 플레이 시간의 기술통계량을 계산하고, 박스플롯으로 분포를 비교 시각화하세요.
  2. 분산분석(ANOVA)의 가정인 잔차의 정규성과 등분산성을 검정하세요.
  3. 세 그룹의 평균 플레이 시간에 차이가 있는지 일원분산분석(ANOVA)을 수행하세요.
  4. ANOVA 결과, 그룹 간에 유의미한 차이가 발견되었다면, 구체적으로 어떤 지역들 사이에 차이가 있는지 Tukey HSD 사후분석을 통해 규명하고, 최종 분석 보고서를 요약하여 결론을 내리세요.

정답 코드

# 필요한 라이브러리 로드
library(dplyr)
library(ggplot2)
library(car)

# 데이터 생성 (재현성을 위해 시드 설정)
set.seed(777)
playtime_data <- data.frame(
  region = factor(rep(c("NA", "EU", "Asia"), each = 50)),
  playtime = c(rnorm(50, mean = 25, sd = 7),  # 북미
               rnorm(50, mean = 28, sd = 7),  # 유럽
               rnorm(50, mean = 22, sd = 6))   # 아시아
)

# 1. 기술통계 및 시각화
summary_stats_region <- playtime_data %>%
  group_by(region) %>%
  summarise(
    mean_playtime = mean(playtime),
    sd_playtime = sd(playtime),
    median_playtime = median(playtime),
    count = n()
  )
print("--- 1. 지역별 기술통계 ---")
print(summary_stats_region)

ggplot(playtime_data, aes(x = region, y = playtime, fill = region)) +
  geom_boxplot(alpha = 0.8) +
  labs(title = "서버 지역별 주간 평균 플레이 시간", x = "서버 지역", y = "플레이 시간 (시간/주)") +
  theme_classic()

# 2. ANOVA 가정 검정
cat("\n--- 2. ANOVA 가정 검정 ---\n")
# 모델 생성
aov_model <- aov(playtime ~ region, data = playtime_data)

# 2-1. 잔차의 정규성
residuals <- residuals(aov_model)
shapiro_test <- shapiro.test(residuals)
cat("잔차의 정규성 (Shapiro-Wilk) p-value:", shapiro_test$p.value, "\n")

# 2-2. 등분산성
levene_test <- leveneTest(playtime ~ region, data = playtime_data)
cat("등분산성 (Levene's Test) p-value:", levene_test$`Pr(>F)`[1], "\n")

# 3. 일원분산분석(ANOVA) 수행
cat("\n--- 3. ANOVA 분석 결과 ---\n")
anova_summary <- summary(aov_model)
print(anova_summary)
anova_p_value <- anova_summary[[1]]$`Pr(>F)`[1]

# 4. 사후분석 및 최종 결론
cat("\n--- 4. 사후분석 및 최종 결론 ---\n")
if (anova_p_value < 0.05) {
  cat("ANOVA 결과, 지역별 평균 플레이 시간에 통계적으로 유의미한 차이가 있습니다 (p < 0.05).\n")
  cat("구체적인 차이를 확인하기 위해 Tukey HSD 사후분석을 실시합니다.\n\n")
  
  tukey_result <- TukeyHSD(aov_model)
  print(tukey_result)
  
  cat("\n** 최종 분석 보고서 요약 **\n")
  cat("1. 분석 목적: 북미, 유럽, 아시아 서버 간 주간 평균 플레이 시간 차이 검증.\n")
  cat("2. 분석 방법: 일원분산분석(ANOVA) 및 Tukey HSD 사후분석.\n")
  cat("3. 분석 결과:\n")
  cat("   - ANOVA 분석 결과, 세 지역 간 평균 플레이 시간에는 통계적으로 유의미한 차이가 존재함 (F-value=", 
      round(anova_summary[[1]]$`F value`[1], 2), ", p-value=", format.pval(anova_p_value, digits=4), ").\n")
  cat("   - Tukey HSD 사후분석 결과, 다음 그룹들 간에 유의미한 차이가 발견됨:\n")
  if (tukey_result$region["EU-Asia", "p adj"] < 0.05) cat("     - 유럽(EU) 서버는 아시아(Asia) 서버보다 평균 플레이 시간이 유의하게 길다.\n")
  if (tukey_result$region["NA-Asia", "p adj"] < 0.05) cat("     - 북미(NA) 서버는 아시아(Asia) 서버보다 평균 플레이 시간이 유의하게 길다.\n")
  if (tukey_result$region["EU-NA", "p adj"] >= 0.05) cat("     - 유럽(EU)과 북미(NA) 서버 간에는 유의미한 차이가 발견되지 않았다.\n")
  cat("4. 결론 및 제언: 유럽과 북미 유저 그룹이 아시아 유저 그룹보다 게임에 더 많은 시간을 투자하는 경향이 있습니다. 아시아 지역의 플레이 시간 증대를 위한 맞춤형 이벤트나 콘텐츠 업데이트를 고려해볼 수 있습니다.\n")
  
} else {
  cat("ANOVA 결과, 지역별 평균 플레이 시간에 통계적으로 유의미한 차이를 발견하지 못했습니다 (p >= 0.05).\n")
}

해설

이 문제는 ANOVA 분석의 전체적인 흐름을 실제 비즈니스 문제 해결 과정에 적용하는 종합 예제입니다.

  1. 분석의 체계성: 이 문제는 단순히 코드를 실행하는 것을 넘어, 데이터 분석가로서 문제를 어떻게 접근하고 해결하며, 그 결과를 어떻게 보고하는지를 보여줍니다.

    • 문제 정의: 지역별 플레이 시간 차이 분석.
    • 데이터 탐색: 기술통계와 박스플롯으로 현황 파악.
    • 방법론 선택 및 가정 검토: 세 그룹 비교이므로 ANOVA를 선택하고, 신뢰도를 위해 정규성과 등분산성 가정을 확인.
    • 핵심 분석 수행: aov()summary()로 ANOVA 실행.
    • 심층 분석: ANOVA 결과가 유의할 경우, TukeyHSD()로 구체적인 차이 규명.
    • 결론 및 제언: 분석 결과를 바탕으로 비즈니스적 의미를 해석하고 실행 가능한 제언(Actionable Insight)을 도출.
  2. 통계적 해석의 깊이:

    • ANOVA의 p-value는 "세 그룹 중 적어도 하나는 다르다"는 '전체적인(omnibus)' 결론만 내려줍니다. 이것만으로는 어떤 그룹이 어떻게 다른지 알 수 없습니다.
    • Tukey HSD의 역할: 이 '전체적인' 결론을 구체화하는 것이 사후분석의 역할입니다. Tukey HSD 결과표의 p adj (조정된 p-value)를 통해 어떤 쌍(pair)의 차이가 유의미한지를 정확히 집어낼 수 있습니다. 이 예제에서는 EU-Asia, NA-Asia 간의 차이는 유의미했지만, EU-NA 간의 차이는 유의미하지 않음을 보여줍니다.
    • 이러한 구체적인 정보는 "유럽과 북미 유저들은 비슷한 패턴을 보이지만, 아시아 유저들은 이들과 다른 패턴을 보인다"는 더 깊이 있는 인사이트를 제공하며, 이는 지역별로 차별화된 전략을 수립하는 데 중요한 근거가 됩니다.
  3. 보고서 형식의 결론: 마지막 cat() 함수로 출력되는 부분은 실제 현업에서 데이터 분석 결과를 요약하여 보고하는 형태를 흉내 낸 것입니다. 분석의 목적, 방법, 결과, 그리고 결론/제언을 명확하게 구조화하여 전달하는 것은 데이터 과학자의 중요한 역량 중 하나입니다.

R 프로그래밍 중급 문제 (246 ~ 260번)

주제: 선형 회귀, 로지스틱 회귀 모델링 (lm, glm) 및 결과 해석


246. 카페 일일 매출 예측: 단순 선형 회귀

문제 상황: 당신은 작은 카페의 데이터 분석가입니다. 최근 날씨가 매출에 큰 영향을 미치는 것 같아, 일일 평균 기온과 그날의 커피 판매량(잔) 사이의 관계를 분석하고자 합니다. 지난 30일간의 데이터를 바탕으로, 기온이 커피 판매량에 미치는 영향을 알아보기 위한 단순 선형 회귀 모델을 만들어 보세요.

과제:

  1. 아래와 같이 temperature (섭씨)와 coffee_sales (잔) 데이터를 생성하세요. (set.seed를 사용하여 재현 가능하게 만드세요.)
  2. temperature를 독립 변수, coffee_sales를 종속 변수로 하는 단순 선형 회귀 모델을 lm() 함수를 사용하여 적합시키세요.
  3. summary() 함수를 사용하여 모델의 결과를 출력하고, 회귀 계수(Coefficients)의 의미를 설명하세요.

정답 코드

# 1. 데이터 생성
set.seed(42)
temperature <- runif(30, 5, 25) # 5도에서 25도 사이의 기온
noise <- rnorm(30, 0, 15)      # 현실적인 노이즈 추가
coffee_sales <- 100 + 7.5 * temperature + noise
cafe_data <- data.frame(temperature, coffee_sales)

# 2. 단순 선형 회귀 모델 적합
sales_model <- lm(coffee_sales ~ temperature, data = cafe_data)

# 3. 모델 결과 출력 및 해석
summary(sales_model)

해설

이 문제는 가장 기본적인 형태의 예측 모델인 단순 선형 회귀를 다룹니다. 하나의 독립 변수(기온)가 하나의 종속 변수(커피 판매량)에 미치는 선형적인 관계를 모델링합니다.

코드 분석:

  1. set.seed(42): 난수 생성을 고정하여 언제 실행해도 동일한 결과를 얻게 합니다. 이는 분석의 재현성을 위해 중요합니다.
  2. temperature <- runif(30, 5, 25): 5도와 25도 사이에서 균등하게 분포된 30개의 난수를 생성하여 기온 데이터를 만듭니다.
  3. coffee_sales <- 100 + 7.5 * temperature + noise: 실제 관계를 시뮬레이션합니다. 기본 판매량 100잔에, 기온이 1도 오를 때마다 7.5잔씩 판매량이 증가하는 관계에 약간의 무작위성(noise)을 더해 현실적인 데이터를 생성합니다.
  4. lm(coffee_sales ~ temperature, data = cafe_data): lm 함수는 선형 모델(Linear Model)을 적합시키는 함수입니다. 종속변수 ~ 독립변수 형태의 수식(formula)을 사용합니다. 이 경우, coffee_salestemperature로 설명하는 모델을 만듭니다.

결과 해석: summary(sales_model) 결과의 Coefficients 부분을 보면 다음과 유사한 내용을 볼 수 있습니다.

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)  97.410      9.049   10.76   <2e-16 ***
temperature   7.712      0.574   13.44   <2e-16 ***
  • (Intercept)의 Estimate (97.410): 절편($\beta_0$)에 해당합니다. 독립 변수인 temperature가 0일 때 예측되는 coffee_sales의 값입니다. 즉, 기온이 0°C일 때 약 97잔의 커피가 팔릴 것으로 예측됩니다.
  • temperature의 Estimate (7.712): 기울기($\beta_1$)에 해당합니다. temperature가 1단위(1°C) 증가할 때 coffee_sales가 평균적으로 얼마나 변하는지를 나타냅니다. 즉, 기온이 1°C 오를 때마다 커피 판매량이 약 7.7잔 증가할 것으로 예측됩니다.
  • Pr(>|t|): p-value를 의미합니다. 이 값이 매우 작으면(보통 0.05 미만), 해당 계수가 통계적으로 유의미하다는 것을 의미하며, 해당 변수가 종속 변수에 실제로 영향을 미친다고 해석할 수 있습니다. 여기서는 두 계수 모두 p-value가 2e-16보다 작으므로 매우 유의미합니다.

수학적으로 이 모델은 다음과 같은 식으로 표현됩니다: $$ \text{coffee_sales} = 97.410 + 7.712 \times \text{temperature} + \epsilon $$ 여기서 $\epsilon$은 오차항을 의미합니다.


247. 중고차 가격 예측: 다중 선형 회귀

문제 상황: 당신은 중고차 거래 플랫폼의 데이터 과학자입니다. 고객들이 자신의 차 가격을 합리적으로 책정할 수 있도록, 자동차의 주행거리, 연식, 엔진 크기를 바탕으로 중고차 가격을 예측하는 모델을 만들려고 합니다.

과제:

  1. 아래와 같이 mileage(주행거리, km), age(연식, year), engine_size(배기량, cc)와 price(가격, 만원) 데이터를 생성하세요.
  2. price를 종속 변수로, mileage, age, engine_size를 독립 변수로 하는 다중 선형 회귀 모델을 적합시키세요.
  3. summary()를 통해 모델 결과를 확인하고, 어떤 변수가 중고차 가격에 가장 큰 영향을 미치는지(계수의 절대값 기준) 판단하세요. (단, 변수들의 스케일이 다르다는 점은 감안해야 합니다.)

정답 코드

# 1. 데이터 생성
set.seed(123)
n <- 100
mileage <- round(runif(n, 10000, 200000))      # 1만 ~ 20만 km
age <- round(runif(n, 1, 10))                  # 1년 ~ 10년
engine_size <- round(runif(n, 1000, 3000))     # 1000cc ~ 3000cc
noise <- rnorm(n, 0, 200)

# 가격 = 기본가 - (주행거리 영향) - (연식 영향) + (엔진크기 영향) + 노이즈
price <- 4000 - (mileage * 0.05) - (age * 150) + (engine_size * 0.8) + noise
car_data <- data.frame(price, mileage, age, engine_size)

# 2. 다중 선형 회귀 모델 적합
car_price_model <- lm(price ~ mileage + age + engine_size, data = car_data)

# 3. 모델 결과 확인
summary(car_price_model)

# 계수 확인
coef(car_price_model)

해설

다중 선형 회귀는 두 개 이상의 독립 변수를 사용하여 종속 변수를 예측하는 모델입니다. 각 변수가 다른 변수들의 영향을 통제한 상태에서 종속 변수에 미치는 순수한 영향을 파악할 수 있습니다.

코드 분석:

  1. price <- 4000 - (mileage * 0.05) - (age * 150) + (engine_size * 0.8) + noise: 중고차 가격의 특성을 반영하여 데이터를 생성했습니다.
    • 주행거리(mileage)가 늘어날수록 가격은 하락합니다.
    • 연식(age)이 오래될수록 가격은 하락합니다.
    • 엔진 크기(engine_size)가 클수록 가격은 상승합니다.
  2. lm(price ~ mileage + age + engine_size, data = car_data): + 기호를 사용하여 여러 독립 변수를 모델에 포함시킵니다. 이는 pricemileage, age, engine_size 세 변수의 선형 결합으로 설명하겠다는 의미입니다.

결과 해석: summary() 결과의 Coefficients는 다음과 유사하게 나타납니다.

Coefficients:
              Estimate Std. Error t value Pr(>|t|)    
(Intercept)  4.062e+03  1.383e+02  29.366  < 2e-16 ***
mileage     -5.060e-02  8.288e-04 -61.050  < 2e-16 ***
age         -1.487e+02  6.635e+00 -22.414  < 2e-16 ***
engine_size  7.768e-01  4.909e-02  15.825  < 2e-16 ***
  • mileage (-0.0506): 다른 변수들이 일정할 때, 주행거리가 1km 늘어날 때마다 중고차 가격은 평균적으로 약 0.05만원(500원) 하락합니다.
  • age (-148.7): 다른 변수들이 일정할 때, 연식이 1년 늘어날 때마다 가격은 평균적으로 약 148.7만원 하락합니다.
  • engine_size (0.7768): 다른 변수들이 일정할 때, 배기량이 1cc 늘어날 때마다 가격은 평균적으로 약 0.77만원(7700원) 상승합니다.

어떤 변수가 가장 큰 영향을 미치는가? 계수의 절대값만 보면 age가 -148.7로 가장 커 보입니다. 하지만 이는 각 변수의 단위(scale)가 다르기 때문에 직접적인 비교는 어렵습니다. age는 1년 단위로 변하지만, mileage는 1km 단위로 변합니다. 정확한 영향력을 비교하려면 변수들을 표준화(standardization)한 후 회귀 분석을 실행하여 표준화된 회귀 계수(standardized coefficient)를 비교해야 합니다. 하지만 이 문제에서는 단순히 계수 값을 해석하는 데 초점을 맞춥니다. 각 변수가 1단위 변할 때의 가격 변화량은 age가 가장 크다고 말할 수 있습니다.

수학적으로 이 모델은 다음과 같습니다: $$ \text{price} = \beta_0 + \beta_1 \times \text{mileage} + \beta_2 \times \text{age} + \beta_3 \times \text{engine_size} + \epsilon $$


248. 모델 성능 평가하기: R-squared와 F-statistic 해석

문제 상황: 247번 문제에서 만든 중고차 가격 예측 모델이 얼마나 데이터를 잘 설명하는지, 그리고 모델 자체가 통계적으로 유의미한지 평가해야 합니다. summary() 결과에 포함된 주요 통계 지표들을 해석해 보세요.

과제:

  1. 247번 문제에서 생성한 car_price_model을 다시 사용합니다.
  2. summary(car_price_model) 결과를 보고 다음 두 가지 값을 찾아서 그 의미를 설명하세요.
    • Multiple R-squared (또는 Adjusted R-squared)
    • F-statisticp-value

정답 코드

# 247번 문제의 데이터와 모델을 재사용
set.seed(123)
n <- 100
mileage <- round(runif(n, 10000, 200000))
age <- round(runif(n, 1, 10))
engine_size <- round(runif(n, 1000, 3000))
noise <- rnorm(n, 0, 200)
price <- 4000 - (mileage * 0.05) - (age * 150) + (engine_size * 0.8) + noise
car_data <- data.frame(price, mileage, age, engine_size)
car_price_model <- lm(price ~ mileage + age + engine_size, data = car_data)

# 모델 요약 정보 출력
model_summary <- summary(car_price_model)
print(model_summary)

# 특정 값 추출 및 설명
r_squared <- model_summary$r.squared
adj_r_squared <- model_summary$adj.r.squared
f_statistic <- model_summary$fstatistic

cat("\n--- 해석 ---\n")
cat("Multiple R-squared:", r_squared, "\n")
cat("Adjusted R-squared:", adj_r_squared, "\n")
cat("F-statistic:", f_statistic[1], "on", f_statistic[2], "and", f_statistic[3], "DF\n")
cat("p-value:", pf(f_statistic[1], f_statistic[2], f_statistic[3], lower.tail = FALSE), "\n")

해설

선형 회귀 모델을 만들고 나면, 이 모델이 얼마나 좋은지 평가하는 것이 매우 중요합니다. R-squared와 F-statistic은 모델의 전반적인 성능을 평가하는 핵심 지표입니다.

결과 해석: summary() 결과의 마지막 줄 근처에서 다음 정보를 찾을 수 있습니다.

Multiple R-squared:  0.981,	Adjusted R-squared:  0.9804 
F-statistic:  1642 on 3 and 96 DF,  p-value: < 2.2e-16
  1. Multiple R-squared (결정계수, $R^2$):

    • 값: 0.981
    • 의미: 종속 변수(price)의 총 변동성 중에서 우리 모델의 독립 변수들(mileage, age, engine_size)에 의해 설명되는 변동성의 비율을 나타냅니다. 값이 1에 가까울수록 모델이 데이터를 잘 설명한다는 의미입니다.
    • 해석: "중고차 가격의 전체 변동 중 약 98.1%가 주행거리, 연식, 엔진 크기라는 세 변수로 설명됩니다." 이는 모델의 설명력이 매우 높다는 것을 의미합니다.
    • 수학적 정의: $$ R^2 = 1 - \frac{SS_{res}}{SS_{tot}} = 1 - \frac{\sum_{i=1}^{n}(y_i - \hat{y}i)^2}{\sum{i=1}^{n}(y_i - \bar{y})^2} $$ 여기서 $SS_{res}$는 잔차 제곱합(Residual Sum of Squares), $SS_{tot}$는 총 제곱합(Total Sum of Squares)입니다.
  2. Adjusted R-squared (조정된 결정계수):

    • 값: 0.9804
    • 의미: Multiple R-squared는 모델에 독립 변수가 추가될수록 그 변수가 유의미하지 않더라도 값이 증가하는 경향이 있습니다. Adjusted R-squared는 이러한 점을 보완하기 위해 변수의 개수를 고려하여 값을 조정합니다. 불필요한 변수가 추가되면 오히려 값이 감소할 수 있어, 서로 다른 개수의 변수를 가진 모델들을 비교할 때 더 유용합니다.
    • 해석: 변수의 개수를 고려했을 때도 모델의 설명력은 약 98.04%로 매우 높습니다.
  3. F-statistic and p-value (F-통계량과 p-값):

    • 값: F-statistic = 1642, p-value < 2.2e-16
    • 의미: 이 지표는 "모델에 포함된 모든 독립 변수들의 회귀 계수가 전부 0인가?"라는 귀무가설(null hypothesis)을 검정합니다. 즉, '우리의 모델이 아무런 설명력이 없는가?'를 테스트합니다.
    • 해석: F-통계량이 매우 크고, 이에 해당하는 p-value가 매우 작으면(보통 0.05 미만) 귀무가설을 기각합니다. 이 경우 p-value가 거의 0에 가까우므로, "모델의 독립 변수들 중 적어도 하나는 종속 변수인 가격에 유의미한 영향을 미친다"고 결론 내릴 수 있습니다. 즉, 이 모델 전체는 통계적으로 매우 유의미합니다.

249. 게임 캐릭터 스탯 분석: 범주형 변수 처리

문제 상황: 게임 개발사에서 캐릭터 밸런스를 분석하고 있습니다. 캐릭터의 levelclass(직업)가 attack_power(공격력)에 미치는 영향을 알아보고자 합니다. class는 'Warrior', 'Mage', 'Archer' 세 종류가 있는 범주형 변수입니다. 선형 회귀 모델에서 R이 범주형 변수를 어떻게 처리하는지 확인해 보세요.

과제:

  1. level, class, attack_power 데이터를 생성하세요.
  2. attack_power를 종속 변수로, levelclass를 독립 변수로 하는 선형 회귀 모델을 만드세요.
  3. summary() 결과를 보고, 범주형 변수인 class가 어떻게 모델에 표현되었는지 설명하세요. 특히 'Warrior'와 'Mage'에 대한 계수의 의미를 설명하세요.

정답 코드

# 1. 데이터 생성
set.seed(200)
n <- 90
level <- sample(10:50, n, replace = TRUE)
class <- factor(sample(c("Archer", "Warrior", "Mage"), n, replace = TRUE))

# 클래스별 공격력 차등 부여
attack_power <- 50 + level * 2.5 + 
                ifelse(class == "Warrior", 30, 0) + 
                ifelse(class == "Mage", -10, 0) + 
                rnorm(n, 0, 8)

game_data <- data.frame(level, class, attack_power)

# 2. 선형 회귀 모델 적합
attack_model <- lm(attack_power ~ level + class, data = game_data)

# 3. 결과 확인 및 해석
summary(attack_model)

해설

선형 회귀 모델은 기본적으로 수치형 변수를 다루지만, R의 lm() 함수는 범주형 변수(factor)를 자동으로 '더미 변수(dummy variables)'로 변환하여 모델에 포함시킵니다.

코드 분석:

  1. class <- factor(...): class 변수를 factor 타입으로 명시적으로 지정했습니다. lm()은 문자열 벡터도 자동으로 factor로 인식하지만, 명시적으로 지정하는 것이 좋은 습관입니다.
  2. lm(attack_power ~ level + class, ...): 모델 수식에 factor 변수인 class를 그대로 사용했습니다. R은 내부적으로 이를 처리해줍니다.

결과 해석: summary() 결과의 Coefficients 부분은 다음과 유사합니다.

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)  49.0305     2.7766  17.659  < 2e-16 ***
level         2.5317     0.0818  30.951  < 2e-16 ***
classMage   -11.4398     2.3789  -4.809 6.78e-06 ***
classWarrior 28.5372     2.3396  12.197  < 2e-16 ***
  • 더미 변수 생성: R은 class 변수의 레벨('Archer', 'Mage', 'Warrior') 중 하나를 '기준 레벨(base level)'로 자동 선택합니다. 보통 알파벳 순서상 가장 앞선 'Archer'가 기준이 됩니다. 그리고 나머지 레벨('Mage', 'Warrior')에 대해 더미 변수(classMage, classWarrior)를 생성합니다.

    • classMage는 캐릭터가 'Mage'일 때 1, 아닐 때 0의 값을 가집니다.
    • classWarrior는 캐릭터가 'Warrior'일 때 1, 아닐 때 0의 값을 가집니다.
    • 캐릭터가 기준 레벨인 'Archer'일 때는 classMageclassWarrior가 모두 0이 됩니다.
  • 계수 해석:

    • (Intercept) (49.03): 모든 수치형 변수가 0이고, 모든 범주형 변수가 기준 레벨일 때의 예측값입니다. 즉, level이 0인 'Archer'의 평균 공격력 예측치입니다.
    • level (2.53): 직업(class)이 동일할 때, 레벨이 1 증가하면 공격력은 평균적으로 2.53 증가합니다.
    • classMage (-11.44): 기준 레벨인 'Archer'에 비해 'Mage'의 공격력이 평균적으로 얼마나 다른지를 나타냅니다. 즉, 동일한 레벨일 때 'Mage'는 'Archer'보다 공격력이 약 11.44 낮습니다.
    • classWarrior (28.54): 기준 레벨인 'Archer'에 비해 'Warrior'의 공격력이 평균적으로 얼마나 다른지를 나타냅니다. 즉, 동일한 레벨일 때 'Warrior'는 'Archer'보다 공격력이 약 28.54 높습니다.

예측식:

  • Archer: $\text{attack_power} = 49.03 + 2.53 \times \text{level}$
  • Mage: $\text{attack_power} = 49.03 - 11.44 + 2.53 \times \text{level}$
  • Warrior: $\text{attack_power} = 49.03 + 28.54 + 2.53 \times \text{level}$

250. 마케팅 캠페인 효과 분석: 상호작용 항(Interaction Term)

문제 상황: 한 회사가 TV 광고와 온라인 광고를 동시에 진행했습니다. 마케팅 분석가인 당신은 광고비 지출이 매출에 미치는 영향을 분석하던 중, "온라인 광고의 효과가 TV 광고비 지출 규모에 따라 달라지지 않을까?"라는 가설을 세웠습니다. 이 가설을 검증하기 위해 상호작용 항을 포함한 회귀 모델을 만들어 보세요.

과제:

  1. TV 광고비(tv_ad), 온라인 광고비(online_ad), 그리고 매출(sales) 데이터를 생성하세요. 데이터 생성 시, 두 광고비의 상호작용 효과를 포함시키세요.
  2. 두 가지 모델을 만드세요.
    • model_no_interaction: tv_adonline_ad의 주효과(main effect)만 포함한 모델
    • model_with_interaction: 주효과와 함께 두 변수 간의 상호작용 항(tv_ad:online_ad)을 포함한 모델
  3. summary()를 통해 두 번째 모델의 상호작용 항 계수를 해석하고, 이 상호작용이 통계적으로 유의미한지 판단하세요.

정답 코드

# 1. 데이터 생성
set.seed(321)
n <- 100
tv_ad <- runif(n, 50, 200)
online_ad <- runif(n, 20, 100)

# 상호작용 효과를 포함한 매출 데이터 생성
# 온라인 광고 효과(계수 3.5)가 TV 광고비에 따라 증가(0.02 * tv_ad)하도록 설정
sales <- 1000 + 2.0 * tv_ad + 3.5 * online_ad + 0.02 * tv_ad * online_ad + rnorm(n, 0, 50)
marketing_data <- data.frame(tv_ad, online_ad, sales)

# 2. 두 가지 모델 생성
# 상호작용 없는 모델
model_no_interaction <- lm(sales ~ tv_ad + online_ad, data = marketing_data)

# 상호작용 있는 모델
# tv_ad * online_ad 는 tv_ad + online_ad + tv_ad:online_ad 와 동일한 표현
model_with_interaction <- lm(sales ~ tv_ad * online_ad, data = marketing_data)

# 3. 상호작용 모델 결과 확인 및 해석
summary(model_with_interaction)

해설

상호작용 항은 한 독립 변수의 효과가 다른 독립 변수의 수준에 따라 달라지는 경우를 모델링하기 위해 사용됩니다. 예를 들어, 어떤 약의 효과가 남성과 여성에게 다르게 나타나는 경우 '약'과 '성별' 변수 간에 상호작용이 있다고 말할 수 있습니다.

코드 분석:

  • sales <- ... + 0.02 * tv_ad * online_ad + ...: 데이터 생성 단계에서 의도적으로 상호작용 효과를 추가했습니다.
  • lm(sales ~ tv_ad * online_ad, ...): * 연산자는 R의 formula에서 주효과와 상호작용 항을 모두 포함하라는 의미의 단축 표현입니다. 이는 lm(sales ~ tv_ad + online_ad + tv_ad:online_ad, ...)와 완전히 동일합니다. : 연산자는 순수한 상호작용 항만을 의미합니다.

결과 해석: summary(model_with_interaction)Coefficients 결과는 다음과 유사합니다.

Coefficients:
                 Estimate Std. Error t value Pr(>|t|)    
(Intercept)     1.009e+03  2.551e+01  39.544  < 2e-16 ***
tv_ad           1.961e+00  1.879e-01  10.435  < 2e-16 ***
online_ad       3.310e+00  4.067e-01   8.139 2.18e-12 ***
tv_ad:online_ad 2.083e-02  2.887e-03   7.214 2.19e-10 ***
  • tv_ad:online_ad (0.02083): 이것이 상호작용 항의 계수입니다.

    • 해석: 이 계수는 online_adsales에 미치는 영향이 tv_ad 값에 따라 어떻게 변하는지를 나타냅니다. 구체적으로, tv_ad가 1단위 증가할 때마다, online_adsales에 미치는 영향력(즉, online_ad의 기울기)이 0.02083만큼 증가합니다.
    • 반대로 해석해도 동일합니다: online_ad가 1단위 증가할 때마다, tv_adsales에 미치는 영향력(tv_ad의 기울기)이 0.02083만큼 증가합니다.
    • 결론: TV 광고에 돈을 많이 쓸수록, 온라인 광고 1단위 지출의 매출 증대 효과가 더 커지는 '시너지 효과'가 있음을 의미합니다.
  • 통계적 유의성: tv_ad:online_ad 항의 p-value(Pr(>|t|))가 2.19e-10으로 매우 작으므로, 이 상호작용 효과는 통계적으로 매우 유의미하다고 할 수 있습니다. 따라서 상호작용이 없는 모델보다 있는 모델이 데이터를 더 잘 설명합니다.

온라인 광고의 효과(기울기) 계산: 이 모델에서 online_adsales에 미치는 전체 효과는 단순한 계수 3.310이 아니라, tv_ad 값에 따라 달라집니다. $$ \frac{\partial(\text{sales})}{\partial(\text{online_ad})} = \beta_{\text{online_ad}} + \beta_{\text{interaction}} \times \text{tv_ad} = 3.310 + 0.02083 \times \text{tv_ad} $$


251. 회귀 모델 진단: 잔차 분석(Residual Analysis)

문제 상황: 당신은 자동차 연비(mpg)를 자동차 무게(wt)로 예측하는 모델을 만들었습니다. 선형 회귀 모델은 몇 가지 중요한 가정(선형성, 등분산성, 잔차의 정규성 등)을 만족해야 신뢰할 수 있습니다. 모델의 잔차를 분석하여 이러한 가정들이 잘 만족되는지 시각적으로 진단해 보세요.

과제:

  1. R에 내장된 mtcars 데이터셋을 사용하세요.
  2. mpg(연비)를 종속 변수로, wt(무게)를 독립 변수로 하는 선형 회귀 모델을 만드세요.
  3. plot() 함수를 모델 객체에 직접 적용하여 4개의 기본 진단 플롯을 그려보세요.
  4. 첫 번째 플롯인 'Residuals vs Fitted'와 두 번째 플롯인 'Normal Q-Q'를 보고 모델의 가정이 잘 만족되는지 해석하세요.

정답 코드

# 1. mtcars 데이터셋 로드 (자동으로 로드되어 있음)
# 2. 선형 회귀 모델 생성
model_mpg <- lm(mpg ~ wt, data = mtcars)

# 3. 모델 진단 플롯 그리기
# par(mfrow=c(2, 2)) 를 실행하면 4개 플롯을 한 번에 볼 수 있습니다.
par(mfrow = c(2, 2))
plot(model_mpg)
par(mfrow = c(1, 1)) # 플롯 설정을 원래대로 되돌림

# 4. 해석 (주석으로 설명)

해설

회귀 모델을 만들고 결과를 해석하는 것만큼 중요한 것이 모델의 가정이 충족되었는지 확인하는 '모델 진단' 과정입니다. plot(model_object)는 이 과정을 위한 4가지 유용한 시각화 자료를 자동으로 생성해 줍니다.

진단 플롯 해석:

  1. Residuals vs Fitted (잔차 대 예측값 플롯):

    • 무엇을 보는가? 예측값(Fitted values)에 따른 잔차(Residuals)의 분포를 봅니다.
    • 이상적인 형태: 점들이 x축(잔차=0)을 중심으로 패턴 없이 무작위로 흩어져 있어야 합니다. 빨간색 실선이 수평에 가까워야 합니다.
    • mtcars 모델 해석: 플롯을 보면 점들이 무작위가 아니라 뚜렷한 곡선(포물선) 형태를 띠고 있습니다. 빨간색 선도 활처럼 휘어 있습니다. 이는 선형성(Linearity) 가정 위배를 시사합니다. 즉, wtmpg의 관계는 단순한 직선이 아닐 가능성이 높습니다. (예: 2차항 I(wt^2)을 추가하거나 로그 변환을 고려해볼 수 있습니다.) 또한, 예측값이 커질수록 잔차의 퍼짐 정도가 달라지는 경향도 약간 보여 등분산성(Homoscedasticity) 가정도 완벽하지 않을 수 있음을 암시합니다.
  2. Normal Q-Q (Q-Q 플롯):

    • 무엇을 보는가? 잔차가 정규분포를 따르는지를 확인합니다.
    • 이상적인 형태: 점들이 점선으로 된 대각선 위에 거의 일직선으로 놓여 있어야 합니다.
    • mtcars 모델 해석: 대부분의 점들이 대각선 위에 잘 위치해 있지만, 양쪽 끝(꼬리 부분)에서 약간 벗어나는 경향이 보입니다. 이는 잔차 분포가 완벽한 정규분포는 아닐 수 있음을 나타내지만, 선형성 가정 위배만큼 심각해 보이지는 않습니다. 이 정도는 종종 수용 가능한 수준으로 간주됩니다.
  3. Scale-Location (척도-위치 플롯):

    • 'Residuals vs Fitted'와 유사하지만, y축이 표준화 잔차의 절대값의 제곱근($\sqrt{|\text{Standardized residuals}|}$)입니다. 등분산성 가정을 더 명확하게 확인하는 데 사용됩니다. 빨간 선이 수평에 가까워야 등분산성 가정을 만족합니다.
  4. Residuals vs Leverage (잔차 대 지렛대값 플롯):

    • 영향력 있는 관측치(influential points), 즉 모델에 큰 영향을 미치는 데이터 포인트를 식별하는 데 사용됩니다. 점선(Cook's distance) 바깥에 있는 점들은 영향력이 큰 값일 수 있으므로 주의 깊게 살펴볼 필요가 있습니다.

결론: 이 진단 플롯, 특히 'Residuals vs Fitted'를 통해 mpg ~ wt 모델은 선형성 가정을 만족하지 못하므로, 모델 개선이 필요하다는 중요한 단서를 얻을 수 있습니다.


252. 새로운 데이터 예측하기: predict() 함수 활용

문제 상황: 247번에서 만든 중고차 가격 예측 모델(car_price_model)을 실제 업무에 사용하려고 합니다. 새로운 중고차 3대의 정보가 들어왔을 때, 이 모델을 사용하여 각 차량의 예상 가격을 예측해 보세요.

과제:

  1. 247번 문제의 car_price_model을 다시 생성합니다.
  2. 예측에 사용할 새로운 데이터(3대의 자동차 정보)를 담은 데이터프레임 new_cars를 만드세요.
    • Car A: mileage=80000, age=3, engine_size=1600
    • Car B: mileage=150000, age=8, engine_size=2000
    • Car C: mileage=30000, age=2, engine_size=2500
  3. predict() 함수를 사용하여 new_cars 데이터에 대한 가격을 예측하고, 결과를 원래 데이터와 합쳐서 출력하세요.

정답 코드

# 1. 247번 모델 재생성
set.seed(123)
n <- 100
mileage <- round(runif(n, 10000, 200000))
age <- round(runif(n, 1, 10))
engine_size <- round(runif(n, 1000, 3000))
noise <- rnorm(n, 0, 200)
price <- 4000 - (mileage * 0.05) - (age * 150) + (engine_size * 0.8) + noise
car_data <- data.frame(price, mileage, age, engine_size)
car_price_model <- lm(price ~ mileage + age + engine_size, data = car_data)

# 2. 새로운 데이터 생성
new_cars <- data.frame(
  mileage = c(80000, 150000, 30000),
  age = c(3, 8, 2),
  engine_size = c(1600, 2000, 2500)
)

# 3. 가격 예측 및 결과 결합
predicted_prices <- predict(car_price_model, newdata = new_cars)

result <- cbind(new_cars, predicted_price = predicted_prices)
print(result)

해설

학습된 회귀 모델의 주된 목적 중 하나는 새로운 데이터에 대한 예측입니다. R에서는 predict() 함수를 통해 이 작업을 간단하게 수행할 수 있습니다.

코드 분석:

  1. new_cars <- data.frame(...): 예측을 수행할 새로운 데이터를 데이터프레임 형태로 만듭니다. 매우 중요한 점은, 이 데이터프레임의 컬럼 이름(mileage, age, engine_size)이 원래 모델을 학습시킬 때 사용했던 데이터의 컬럼 이름과 정확히 일치해야 한다는 것입니다.
  2. predict(car_price_model, newdata = new_cars): predict() 함수의 첫 번째 인자로는 학습된 모델 객체를, newdata 인자로는 예측을 원하는 새로운 데이터프레임을 전달합니다. predict 함수는 모델의 회귀식을 new_cars 데이터의 각 행에 적용하여 예측값을 계산합니다.
    • 예를 들어 Car A에 대한 예측은 다음과 같이 계산됩니다: $$ \text{Predicted Price}_A = \hat{\beta}_0 + \hat{\beta}_1 \times 80000 + \hat{\beta}_2 \times 3 + \hat{\beta}_3 \times 1600 $$ 여기서 $\hat{\beta}$ 값들은 summary(car_price_model)에서 확인한 계수 추정치입니다.
  3. cbind(new_cars, predicted_price = predicted_prices): cbind 함수를 사용하여 원래의 new_cars 데이터와 예측된 가격 벡터를 열 기준으로 합쳐서 보기 좋은 결과 테이블을 만듭니다.

결과 출력:

   mileage age engine_size predicted_price
1   80000   3        1600        886.9538
2  150000   8        2000       -1178.5034
3   30000   2        2500       4141.6508

(결과는 set.seed에 따라 정확히 재현됩니다.) 이 결과를 통해 각 신규 차량의 예상 중고 가격을 구체적인 수치로 제시할 수 있습니다. Car B의 경우 예측 가격이 음수가 나왔는데, 이는 모델의 한계를 보여줍니다. 현실에서는 가격이 음수가 될 수 없으므로, 이런 극단적인 예측값에 대해서는 추가적인 분석이나 모델 개선(예: 비선형 모델 사용, 데이터 범위 제한 등)이 필요할 수 있습니다.


253. 대학원 합격 예측: 단순 로지스틱 회귀

문제 상황: 당신은 대학 입학처의 데이터 분석가입니다. 학부 성적(GPA)이 대학원 합격 여부에 미치는 영향을 분석하여, 향후 입시 정책에 참고 자료로 활용하고자 합니다. 합격/불합격과 같은 이진(binary) 결과를 예측하는 데에는 로지스틱 회귀가 적합합니다.

과제:

  1. 학부 성적(gpa)과 합격 여부(admit, 0=불합격, 1=합격) 데이터를 생성하세요. GPA가 높을수록 합격 확률이 높아지도록 만드세요.
  2. admit을 종속 변수로, gpa를 독립 변수로 하는 로지스틱 회귀 모델을 glm() 함수를 사용하여 적합시키세요. family 인자를 올바르게 설정해야 합니다.
  3. summary()로 모델 결과를 확인하고, GPA 계수의 부호가 합격에 긍정적인 영향을 미치는지 부정적인 영향을 미치는지 해석하세요.

정답 코드

# 1. 데이터 생성
set.seed(456)
n <- 100
gpa <- runif(n, 2.5, 4.5)

# 로지스틱 함수를 이용해 합격 확률 계산
# GPA가 높을수록 확률이 높아지도록 log-odds 설정
log_odds <- -10 + 3 * gpa
prob <- 1 / (1 + exp(-log_odds))

# 계산된 확률에 따라 0 또는 1의 결과 생성
admit <- rbinom(n, 1, prob)

admission_data <- data.frame(gpa, admit)

# 2. 로지스틱 회귀 모델 적합
admit_model <- glm(admit ~ gpa, data = admission_data, family = "binomial")

# 3. 모델 결과 확인 및 해석
summary(admit_model)

해설

로지스틱 회귀는 종속 변수가 범주형(특히 0 또는 1의 이진 형태)일 때 사용하는 회귀 분석 기법입니다. 선형 회귀처럼 결과를 직접 예측하는 것이 아니라, 특정 카테고리에 속할 '확률'을 예측합니다.

코드 분석:

  1. log_odds <- -10 + 3 * gpa: 로지스틱 회귀의 핵심인 로짓(log-odds)을 먼저 계산합니다. GPA에 대한 선형 결합으로 표현됩니다.
  2. prob <- 1 / (1 + exp(-log_odds)): 로짓을 확률로 변환하는 시그모이드(Sigmoid) 또는 로지스틱(Logistic) 함수입니다. 이 함수의 결과는 항상 0과 1 사이의 값을 가집니다.
  3. admit <- rbinom(n, 1, prob): 각 학생의 합격 확률(prob)에 따라 베르누이 시행(성공/실패)을 하여 실제 합격 여부(1/0)를 시뮬레이션합니다.
  4. glm(..., family = "binomial"): glm은 일반화 선형 모델(Generalized Linear Model) 함수입니다. family = "binomial" 인자를 통해 종속 변수가 이항 분포를 따르는 로지스틱 회귀를 수행하도록 지정합니다.

결과 해석: summary(admit_model)Coefficients 결과는 다음과 유사합니다.

Coefficients:
            Estimate Std. Error z value Pr(>|z|)    
(Intercept) -13.0478     2.6329  -4.956 7.20e-07 ***
gpa           3.8344     0.7712   4.972 6.63e-07 ***
  • gpa의 Estimate (3.8344): 이 계수는 로그 오즈(log-odds)에 대한 영향을 나타냅니다.
    • 부호 해석: 계수가 양수(+)이므로, GPA가 증가할수록 합격할 로그 오즈가 증가한다는 의미입니다. 로그 오즈가 증가하면 합격 확률도 증가하므로, GPA는 합격에 긍정적인 영향을 미칩니다.
    • 크기 해석: GPA가 1단위 증가할 때, 합격할 로그 오즈는 3.8344만큼 증가합니다.

로지스틱 회귀의 수학적 배경: 로지스틱 회귀는 성공 확률 $p$를 직접 모델링하는 대신, $p$를 변환한 오즈(Odds) 또는 **로그-오즈(Log-Odds)**를 선형 모델로 설명합니다.

  • 확률(Probability): $p = P(Y=1 | X)$
  • 오즈(Odds): 성공 확률을 실패 확률로 나눈 값. $\text{Odds} = \frac{p}{1-p}$
  • 로그-오즈(Log-Odds) 또는 로짓(Logit): $\text{logit}(p) = \ln\left(\frac{p}{1-p}\right)$

로지스틱 회귀 모델의 식은 다음과 같습니다: $$ \ln\left(\frac{p}{1-p}\right) = \beta_0 + \beta_1 X_1 + \dots + \beta_k X_k $$ 이 문제의 경우: $$ \ln\left(\frac{p_{\text{admit}}}{1-p_{\text{admit}}}\right) = -13.0478 + 3.8344 \times \text{gpa} $$


254. 로지스틱 회귀 계수 해석: 오즈비(Odds Ratio)

문제 상황: 253번 문제에서 만든 대학원 합격 예측 모델의 gpa 계수가 3.8344로 나왔습니다. 이 값은 로그-오즈 스케일이어서 직관적으로 이해하기 어렵습니다. 이 계수를 오즈비(Odds Ratio)로 변환하여 "GPA가 1점 오르면 합격할 확률이 몇 배나 더 높아지는지"를 쉽게 설명할 수 있도록 해석해 보세요.

과제:

  1. 253번 문제의 admit_model을 다시 생성합니다.
  2. coef() 함수로 모델의 계수를 추출하세요.
  3. 추출된 gpa 계수에 exp() 함수를 적용하여 오즈비를 계산하세요.
  4. 계산된 오즈비의 의미를 구체적인 문장으로 설명하세요.

정답 코드

# 1. 253번 모델 재생성
set.seed(456)
n <- 100
gpa <- runif(n, 2.5, 4.5)
log_odds <- -10 + 3 * gpa
prob <- 1 / (1 + exp(-log_odds))
admit <- rbinom(n, 1, prob)
admission_data <- data.frame(gpa, admit)
admit_model <- glm(admit ~ gpa, data = admission_data, family = "binomial")

# 2. 모델 계수 추출
model_coeffs <- coef(admit_model)
print(model_coeffs)

# 3. GPA 계수에 대한 오즈비 계산
gpa_odds_ratio <- exp(model_coeffs["gpa"])
print(gpa_odds_ratio)

# 4. 오즈비 의미 설명 (주석)
# GPA가 1점 증가할 때, 합격할 오즈(odds)는 약 46.26배 증가(곱해짐)합니다.
# 즉, GPA가 1점 높은 학생은 그렇지 않은 학생에 비해 합격할 가능성(오즈 기준)이 46배 이상 높다고 해석할 수 있습니다.

해설

로지스틱 회귀의 계수 $\beta$는 로그-오즈의 변화량을 나타내므로 바로 해석하기 어렵습니다. 여기에 지수 함수($e^x$)를 취한 $e^\beta$는 **오즈비(Odds Ratio, OR)**가 되어 훨씬 직관적인 해석을 가능하게 합니다.

수학적 원리: 모델 식은 $\ln(\text{Odds}) = \beta_0 + \beta_1 X_1$ 입니다. $X_1$이 1단위 증가하면, 새로운 로그-오즈는 $\ln(\text{Odds}') = \beta_0 + \beta_1 (X_1+1) = (\beta_0 + \beta_1 X_1) + \beta_1 = \ln(\text{Odds}) + \beta_1$ 이 됩니다.

두 로그-오즈의 차이는 다음과 같습니다. $$ \ln(\text{Odds}') - \ln(\text{Odds}) = \beta_1 $$ 로그의 성질에 의해, 이는 오즈의 비율(ratio)의 로그 값과 같습니다. $$ \ln\left(\frac{\text{Odds}'}{\text{Odds}}\right) = \beta_1 $$ 양변에 지수 함수를 취하면 오즈비(OR)를 얻을 수 있습니다. $$ \text{Odds Ratio} = \frac{\text{Odds}'}{\text{Odds}} = e^{\beta_1} $$

코드 및 결과 해석:

  1. model_coeffs <- coef(admit_model): coef() 함수는 모델 객체에서 계수 추정치만을 벡터 형태로 추출합니다.
  2. gpa_odds_ratio <- exp(model_coeffs["gpa"]): gpa에 해당하는 계수 값(3.8344)에 exp()를 적용합니다. exp(3.8344)는 약 46.26입니다.

오즈비(46.26) 해석:

  • 의미: 이 값은 $X_1$이 1단위 증가할 때 오즈가 몇 배가 되는지를 나타냅니다.
  • 구체적 설명: "다른 조건이 동일할 때, GPA가 1점 높은 학생의 합격 오즈는 GPA가 1점 낮은 학생의 합격 오즈보다 46.26배 높다."
  • 주의: "합격 확률이 46.26배 높다"고 해석하는 것은 틀린 해석입니다. 오즈(Odds)와 확률(Probability)은 다른 개념입니다. 오즈비는 확률의 '비율'이 아니라 오즈의 '비율'입니다. 하지만 오즈비가 1보다 크면 해당 변수가 긍정적인 영향을, 1보다 작으면 부정적인 영향을, 1에 가까우면 거의 영향이 없다고 해석할 수 있어 매우 유용합니다.

255. 고객 이탈 예측: 다중 로지스틱 회귀

문제 상황: 통신사 데이터 분석팀에서 고객 이탈(Churn)에 영향을 미치는 요인을 분석하고 있습니다. 고객의 월별 요금(monthly_charges), 총 사용 기간(tenure), 약정 유형(contract)이 이탈 여부(churn)에 어떤 영향을 주는지 다중 로지스틱 회귀 모델로 분석해 보세요.

과제:

  1. 고객 데이터를 시뮬레이션하여 생성하세요. churn은 'Yes'/'No' 형태의 factor 변수로 만드세요.
  2. churn을 종속 변수로, monthly_charges, tenure, contract를 독립 변수로 하는 다중 로지스틱 회귀 모델을 만드세요.
  3. summary() 결과를 보고, 각 변수가 고객 이탈에 미치는 영향을 (긍정적/부정적) 설명하세요.

정답 코드

# 1. 데이터 생성
set.seed(789)
n <- 200
tenure <- sample(1:72, n, replace = TRUE) # 사용 기간 (1~72개월)
monthly_charges <- runif(n, 20, 120)      # 월 요금
contract <- factor(sample(c("Month-to-month", "One year", "Two year"), n, replace = TRUE, 
                          prob = c(0.6, 0.25, 0.15)))

# 이탈 로그-오즈 계산
log_odds_churn <- 1.5 - 0.08 * tenure + 0.03 * monthly_charges -
                  ifelse(contract == "One year", 1.5, 0) -
                  ifelse(contract == "Two year", 3.0, 0) + rnorm(n, 0, 0.5)

prob_churn <- 1 / (1 + exp(-log_odds_churn))
churn_binary <- rbinom(n, 1, prob_churn)
churn <- factor(ifelse(churn_binary == 1, "Yes", "No"), levels = c("No", "Yes"))

telecom_data <- data.frame(tenure, monthly_charges, contract, churn)

# 2. 다중 로지스틱 회귀 모델 적합
churn_model <- glm(churn ~ tenure + monthly_charges + contract, 
                   data = telecom_data, family = "binomial")

# 3. 모델 결과 확인 및 해석
summary(churn_model)

해설

여러 개의 독립 변수(수치형, 범주형 모두 포함)를 사용하여 이진 종속 변수를 예측할 때 다중 로지스틱 회귀를 사용합니다.

코드 분석:

  1. churn <- factor(..., levels = c("No", "Yes")): glm에서 로지스틱 회귀를 수행할 때, 종속 변수 factor의 두 번째 레벨을 '성공(event)'(즉, Y=1)으로 간주합니다. 여기서는 Yes가 두 번째이므로, 모델은 'Yes'일 확률을 예측하게 됩니다. 순서를 명확히 지정하는 것이 중요합니다.
  2. glm(churn ~ tenure + monthly_charges + contract, ...): +를 사용하여 여러 변수를 모델에 포함시킵니다. contract는 범주형 변수이므로 lm에서와 같이 자동으로 더미 변수 처리됩니다.

결과 해석: summary() 결과의 Coefficients는 다음과 유사합니다.

Coefficients:
                   Estimate Std. Error z value Pr(>|z|)    
(Intercept)        1.189587   0.722527   1.646   0.0997 .  
tenure            -0.087284   0.013233  -6.596 4.22e-11 ***
monthly_charges    0.032936   0.008453   3.896 9.77e-05 ***
contractOne year  -1.849615   0.589882  -3.136   0.0017 ** 
contractTwo year  -3.834042   0.840733  -4.560 5.11e-06 ***
  • 기준 레벨: contract의 기준 레벨은 알파벳순으로 가장 빠른 'Month-to-month'입니다.
  • tenure (-0.087): 계수가 음수입니다. 사용 기간(tenure)이 길수록 이탈할 로그-오즈가 감소합니다. 즉, 오래 사용한 고객일수록 이탈할 확률이 낮아집니다.
  • monthly_charges (0.033): 계수가 양수입니다. 월 요금(monthly_charges)이 높을수록 이탈할 로그-오즈가 증가합니다. 즉, 월 요금이 비쌀수록 이탈할 확률이 높아집니다.
  • contractOne year (-1.85): 'Month-to-month' 약정 고객에 비해 'One year' 약정 고객의 이탈 로그-오즈는 1.85만큼 낮습니다. 즉, 1년 약정 고객은 월별 약정 고객보다 이탈 확률이 현저히 낮습니다.
  • contractTwo year (-3.83): 'Month-to-month' 약정 고객에 비해 'Two year' 약정 고객의 이탈 로그-오즈는 3.83만큼 낮습니다. 즉, 2년 약정 고객은 월별 약정 고객보다 이탈 확률이 훨씬 더 낮습니다.

이 분석을 통해 회사는 장기 약정 고객 유치 및 월 요금제 조정 등의 전략을 수립하는 데 중요한 근거를 얻을 수 있습니다.


256. 로지스틱 회귀 모델 평가: 혼동 행렬(Confusion Matrix)

문제 상황: 255번에서 만든 고객 이탈 예측 모델의 성능을 평가해야 합니다. 모델이 얼마나 정확하게 '이탈'과 '유지' 고객을 분류하는지 알아보기 위해, 예측 결과를 바탕으로 혼동 행렬(Confusion Matrix)을 만들고 정확도(Accuracy)를 계산해 보세요.

과제:

  1. 255번 문제의 telecom_datachurn_model을 다시 생성합니다.
  2. predict() 함수를 사용하여 학습 데이터에 대한 이탈 확률을 예측하세요. (type="response" 옵션을 사용해야 합니다.)
  3. 예측된 확률이 0.5보다 크면 'Yes'(이탈), 그렇지 않으면 'No'(유지)로 예측 결과를 분류하세요. (이 0.5를 분류 임계값(threshold)이라고 합니다.)
  4. 실제 값(telecom_data$churn)과 예측 값으로 혼동 행렬을 만들고, 전체 정확도를 계산하세요.

정답 코드

# 1. 255번 데이터 및 모델 재생성
set.seed(789)
n <- 200
tenure <- sample(1:72, n, replace = TRUE)
monthly_charges <- runif(n, 20, 120)
contract <- factor(sample(c("Month-to-month", "One year", "Two year"), n, replace = TRUE, prob = c(0.6, 0.25, 0.15)))
log_odds_churn <- 1.5 - 0.08 * tenure + 0.03 * monthly_charges - ifelse(contract == "One year", 1.5, 0) - ifelse(contract == "Two year", 3.0, 0) + rnorm(n, 0, 0.5)
prob_churn <- 1 / (1 + exp(-log_odds_churn))
churn_binary <- rbinom(n, 1, prob_churn)
churn <- factor(ifelse(churn_binary == 1, "Yes", "No"), levels = c("No", "Yes"))
telecom_data <- data.frame(tenure, monthly_charges, contract, churn)
churn_model <- glm(churn ~ tenure + monthly_charges + contract, data = telecom_data, family = "binomial")

# 2. 이탈 확률 예측
predicted_probs <- predict(churn_model, type = "response")

# 3. 확률을 기준으로 예측 클래스 분류
threshold <- 0.5
predicted_class <- factor(ifelse(predicted_probs > threshold, "Yes", "No"), levels = c("No", "Yes"))

# 4. 혼동 행렬 생성 및 정확도 계산
# table() 함수를 사용하면 간단히 혼동 행렬을 만들 수 있습니다.
# (첫 인자에 예측값, 두 번째 인자에 실제값을 넣는 것이 일반적)
conf_matrix <- table(Predicted = predicted_class, Actual = telecom_data$churn)
print("Confusion Matrix:")
print(conf_matrix)

# 정확도 계산: (TP + TN) / (전체 데이터 수)
accuracy <- sum(diag(conf_matrix)) / sum(conf_matrix)
cat("\nAccuracy:", accuracy, "\n")

해설

혼동 행렬은 분류 모델의 성능을 상세하게 보여주는 표입니다. 모델이 어떤 클래스를 잘 맞추고, 어떤 클래스를 헷갈려 하는지 한눈에 파악할 수 있습니다.

코드 분석:

  1. predict(churn_model, type = "response"): glm 모델에 predict를 사용할 때, type="response" 옵션을 주면 로그-오즈가 아닌 0과 1 사이의 확률(이 경우 'Yes'일 확률)을 직접 반환해 줍니다. 이 옵션을 사용하지 않으면 로그-오즈 값을 반환합니다.
  2. ifelse(predicted_probs > threshold, "Yes", "No"): 예측된 확률값을 분류 규칙(임계값 0.5)에 따라 'Yes' 또는 'No'라는 구체적인 클래스로 변환합니다.
  3. table(Predicted = predicted_class, Actual = telecom_data$churn): table() 함수는 두 factor 변수를 받아 교차표(contingency table)를 만듭니다. 이것이 바로 혼동 행렬입니다.
  4. sum(diag(conf_matrix)) / sum(conf_matrix): diag()는 행렬의 대각 원소를 추출합니다. 혼동 행렬에서 대각 원소는 올바르게 예측된 경우(TN, TP)를 의미하므로, 이들의 합은 전체 정답 개수입니다. 이를 전체 데이터 수로 나누면 정확도(Accuracy)가 됩니다.

혼동 행렬 해석: 출력된 혼동 행렬은 다음과 같은 구조를 가집니다.

Confusion Matrix:
         Actual
Predicted  No Yes
      No  127  19  (TN, FN)
      Yes  11  43  (FP, TP)
  • TN (True Negative, 127): 실제 'No'를 'No'로 올바르게 예측. (이탈 안 할 고객을 유지할 것으로 예측)
  • FP (False Positive, 11): 실제 'No'를 'Yes'로 잘못 예측. (유지할 고객을 이탈할 것으로 예측 - Type I Error)
  • FN (False Negative, 19): 실제 'Yes'를 'No'로 잘못 예측. (이탈할 고객을 유지할 것으로 예측 - Type II Error)
  • TP (True Positive, 43): 실제 'Yes'를 'Yes'로 올바르게 예측. (이탈할 고객을 이탈할 것으로 예측)

정확도 (Accuracy): $$ \text{Accuracy} = \frac{TP+TN}{TP+TN+FP+FN} = \frac{43+127}{43+127+11+19} = \frac{170}{200} = 0.85 $$ 이 모델의 전체 정확도는 85%로, 꽤 준수한 성능을 보인다고 할 수 있습니다. 하지만 클래스 불균형이 심한 데이터에서는 정확도만으로 모델을 평가하기 어려우므로, 정밀도(Precision), 재현율(Recall/Sensitivity) 등 다른 지표도 함께 고려해야 합니다.


257. 모델의 분별력 평가: ROC 커브와 AUC

문제 상황: 256번에서는 분류 임계값(threshold)을 0.5로 고정하여 모델을 평가했습니다. 하지만 임계값을 어떻게 설정하느냐에 따라 모델의 성능(특히 민감도와 특이도)이 달라집니다. 모든 가능한 임계값에 대한 모델의 성능을 종합적으로 평가하기 위해 ROC(Receiver Operating Characteristic) 커브를 그리고 AUC(Area Under the Curve) 값을 계산해 보세요.

과제:

  1. 255번 문제의 telecom_datachurn_model을 다시 생성합니다.
  2. pROC 패키지를 설치하고 로드하세요. (install.packages("pROC"))
  3. roc() 함수를 사용하여 ROC 객체를 생성하세요. 실제 값(churn)과 예측 확률을 인자로 전달해야 합니다.
  4. 생성된 ROC 객체를 plot()하여 ROC 커브를 시각화하고, auc() 함수로 AUC 값을 계산하여 출력하세요. AUC 값의 의미를 설명하세요.

정답 코드

# 1. 255번 데이터 및 모델 재생성 (이전 문제와 동일)
set.seed(789)
n <- 200
tenure <- sample(1:72, n, replace = TRUE)
monthly_charges <- runif(n, 20, 120)
contract <- factor(sample(c("Month-to-month", "One year", "Two year"), n, replace = TRUE, prob = c(0.6, 0.25, 0.15)))
log_odds_churn <- 1.5 - 0.08 * tenure + 0.03 * monthly_charges - ifelse(contract == "One year", 1.5, 0) - ifelse(contract == "Two year", 3.0, 0) + rnorm(n, 0, 0.5)
prob_churn <- 1 / (1 + exp(-log_odds_churn))
churn_binary <- rbinom(n, 1, prob_churn)
churn <- factor(ifelse(churn_binary == 1, "Yes", "No"), levels = c("No", "Yes"))
telecom_data <- data.frame(tenure, monthly_charges, contract, churn)
churn_model <- glm(churn ~ tenure + monthly_charges + contract, data = telecom_data, family = "binomial")

# 2. pROC 패키지 설치 및 로드
# install.packages("pROC") # 최초 1회 실행
library(pROC)

# 3. ROC 객체 생성
# 예측 확률 계산
predicted_probs <- predict(churn_model, type = "response")
roc_obj <- roc(telecom_data$churn, predicted_probs)

# 4. ROC 커브 시각화 및 AUC 계산
plot(roc_obj, main="ROC Curve for Churn Prediction", print.auc=TRUE)

auc_value <- auc(roc_obj)
cat("AUC Value:", auc_value, "\n")

해설

ROC 커브와 AUC는 이진 분류 모델의 성능을 평가하는 매우 중요한 도구입니다. 이들은 특정 임계값에 의존하지 않고 모델의 전반적인 '분별력(discriminative power)'을 보여줍니다.

핵심 개념:

  • 민감도 (Sensitivity, True Positive Rate): 실제 Positive(Yes) 중에서 모델이 Positive로 예측한 비율. $TPR = \frac{TP}{TP+FN}$
  • 특이도 (Specificity, True Negative Rate): 실제 Negative(No) 중에서 모델이 Negative로 예측한 비율. $TNR = \frac{TN}{TN+FP}$
  • FPR (False Positive Rate): $1 - \text{Specificity}$. 실제 Negative 중에서 모델이 Positive로 잘못 예측한 비율. $FPR = \frac{FP}{TN+FP}$
  • ROC 커브: x축을 FPR, y축을 TPR로 놓고, 분류 임계값을 0부터 1까지 변화시킬 때의 (FPR, TPR) 좌표들을 이어 그린 곡선입니다.
  • AUC (Area Under the Curve): ROC 커브 아래쪽의 면적. 0과 1 사이의 값을 가지며, 1에 가까울수록 모델의 성능이 좋음을 의미합니다.

코드 분석:

  1. library(pROC): ROC 분석을 위한 전문 패키지를 로드합니다.
  2. roc(telecom_data$churn, predicted_probs): roc 함수의 첫 번째 인자에는 실제 정답(factor 또는 0/1 벡터), 두 번째 인자에는 '성공' 클래스에 대한 예측 확률을 넣습니다.
  3. plot(roc_obj, print.auc=TRUE): 생성된 roc 객체를 plot하면 ROC 커브가 그려집니다. print.auc=TRUE 옵션은 플롯에 AUC 값을 자동으로 표시해줍니다.

결과 해석:

  • ROC 커브의 모양: 커브가 왼쪽 위 모서리(0,1)에 가까울수록 좋습니다. 이는 낮은 FPR(잘못된 긍정 예측이 적음)을 유지하면서 높은 TPR(실제 긍정을 잘 찾아냄)을 달성한다는 의미입니다. 대각선(y=x)은 무작위 추측(동전 던지기)에 해당하는 성능을 나타냅니다. 우리 모델의 커브는 대각선보다 훨씬 위쪽에 있으므로, 무작위 추측보다 훨씬 성능이 좋습니다.
  • AUC 값: (실행 시마다 약간 다를 수 있지만) 약 0.91 정도의 값이 나옵니다.
    • AUC = 0.5: 모델이 분별력이 전혀 없음 (랜덤 추측).
    • 0.7 < AUC < 0.8: 수용 가능한(Acceptable) 성능.
    • 0.8 < AUC < 0.9: 훌륭한(Excellent) 성능.
    • 0.9 < AUC < 1.0: 아주 뛰어난(Outstanding) 성능.
    • AUC = 1.0: 완벽한 분류기.
    • 해석: 우리 모델의 AUC 값은 약 0.91이므로, 이탈 고객과 유지 고객을 구별하는 능력이 아주 뛰어나다고 평가할 수 있습니다. AUC는 "모델이 임의로 선택한 Positive 샘플에 대해 Negative 샘플보다 더 높은 확률 점수를 부여할 확률"로도 해석할 수 있습니다.

258. 최적의 모델 선택하기: AIC (Akaike Information Criterion)

문제 상황: 중고차 가격을 예측(247번 문제)하는데, 어떤 변수 조합이 가장 좋은 모델을 만드는지 궁금합니다. '주행거리'와 '연식'만 사용한 단순한 모델과, '엔진 크기'까지 포함한 복잡한 모델 중 어떤 것이 더 나은 모델일까요? 모델의 복잡도와 설명력을 모두 고려하는 AIC를 사용하여 두 모델을 비교해 보세요.

과제:

  1. 247번 문제의 car_data를 다시 생성합니다.
  2. 두 개의 선형 회귀 모델을 만드세요.
    • model_simple: price ~ mileage + age
    • model_complex: price ~ mileage + age + engine_size
  3. AIC() 함수를 사용하여 각 모델의 AIC 값을 계산하고 비교하세요.
  4. 어떤 모델을 선택해야 하는지, 그리고 그 이유는 무엇인지 설명하세요.

정답 코드

# 1. 247번 데이터 재생성
set.seed(123)
n <- 100
mileage <- round(runif(n, 10000, 200000))
age <- round(runif(n, 1, 10))
engine_size <- round(runif(n, 1000, 3000))
noise <- rnorm(n, 0, 200)
price <- 4000 - (mileage * 0.05) - (age * 150) + (engine_size * 0.8) + noise
car_data <- data.frame(price, mileage, age, engine_size)

# 2. 두 개의 모델 생성
model_simple <- lm(price ~ mileage + age, data = car_data)
model_complex <- lm(price ~ mileage + age + engine_size, data = car_data)

# 3. AIC 값 계산 및 비교
aic_simple <- AIC(model_simple)
aic_complex <- AIC(model_complex)

cat("AIC for simple model:", aic_simple, "\n")
cat("AIC for complex model:", aic_complex, "\n")

# 4. 모델 선택 및 이유 설명
if (aic_complex < aic_simple) {
  cat("\n결론: 복잡한 모델(model_complex)을 선택합니다.\n")
  cat("이유: 복잡한 모델의 AIC 값이 더 낮기 때문입니다. 이는 engine_size 변수를 추가함으로써 모델의 복잡도가 증가했지만, 그로 인해 얻는 정보의 이득(설명력 향상)이 복잡도 페널티보다 더 크다는 것을 의미합니다.\n")
} else {
  cat("\n결론: 단순한 모델(model_simple)을 선택합니다.\n")
  cat("이유: 단순한 모델의 AIC 값이 더 낮거나 같기 때문입니다. engine_size 변수 추가가 모델 성능 향상에 크게 기여하지 못했음을 의미합니다.\n")
}

해설

모델에 변수를 많이 추가할수록 학습 데이터에 대한 설명력(R-squared)은 항상 증가하지만, 이는 과적합(overfitting)의 위험을 높입니다. AIC는 모델의 적합도(goodness of fit)와 복잡도(complexity) 사이의 균형을 맞추는 지표로, 여러 모델 중 '최적'의 모델을 선택하는 데 널리 사용됩니다.

AIC의 개념: AIC는 정보 이론에 기반을 둔 지표로, 더 낮은 AIC 값을 가진 모델이 더 좋은 모델로 간주됩니다.

수학적 정의는 다음과 같습니다. $$ AIC = 2k - 2\ln(\hat{L}) $$

  • $k$: 모델의 파라미터 개수 (회귀 계수 개수 + 오차항의 분산). 모델이 복잡할수록 $k$가 커집니다. $2k$는 모델 복잡도에 대한 페널티 항입니다.
  • $\hat{L}$: 모델의 최대우도(maximum likelihood). 모델이 데이터를 얼마나 잘 설명하는지를 나타냅니다. $\ln(\hat{L})$은 로그-우도(log-likelihood)이며, 이 값이 클수록 모델의 적합도가 좋습니다. $-2\ln(\hat{L})$는 모델의 적합도를 나타내는 항입니다.

AIC는 모델의 적합도가 좋으면서도($-2\ln(\hat{L})$가 작음) 너무 복잡하지 않은($k$가 작음) 모델을 선호합니다.

결과 해석: 실행 결과는 다음과 같습니다.

AIC for simple model: 1374.966 
AIC for complex model: 1222.188 

model_complex의 AIC 값이 model_simple보다 현저히 낮습니다. 이는 engine_size 변수를 추가하는 것이 모델의 복잡성을 증가시키는 비용보다 데이터를 더 잘 설명함으로써 얻는 이득이 훨씬 크다는 것을 의미합니다. 따라서 우리는 두 모델 중 model_complex를 더 나은 모델로 선택해야 합니다.


259. 자동 변수 선택: 단계적 회귀(Stepwise Regression)

문제 상황: 한 병원에서 환자의 수축기 혈압(blood_pressure)에 영향을 미치는 요인을 찾고 있습니다. 분석 가능한 변수로 age(나이), weight(체중), bmi(체질량지수), salt_intake(일일 나트륨 섭취량), stress_level(스트레스 지수) 등 여러 후보 변수가 있습니다. 이 중에서 혈압 예측에 가장 중요한 변수들만으로 구성된 최적의 모델을 자동으로 찾고 싶습니다.

과제:

  1. 환자 데이터를 시뮬레이션하여 생성하세요.
  2. 모든 변수를 포함한 전체 모델(full_model)을 만드세요.
  3. step() 함수를 사용하여 후진 제거법(backward elimination) 기반의 단계적 회귀를 수행하고, 최종적으로 선택된 모델을 확인하세요.
  4. 최종 모델의 summary() 결과를 보고 어떤 변수들이 선택되었는지 설명하세요.

정답 코드

# 1. 데이터 생성
set.seed(999)
n <- 150
age <- rnorm(n, 50, 10)
weight <- rnorm(n, 70, 15)
# bmi는 weight와 강한 상관관계를 가지도록 생성
bmi <- weight / (1.75^2) + rnorm(n, 0, 1) 
# salt_intake는 혈압과 약한 관계, stress_level은 관계 없도록 생성
salt_intake <- runif(n, 2000, 5000)
stress_level <- sample(1:10, n, replace = TRUE)

# 혈압 데이터 생성 (age, weight가 주 영향, salt_intake는 미미한 영향)
blood_pressure <- 100 + 0.8 * age + 0.5 * weight + 0.001 * salt_intake + rnorm(n, 0, 5)

patient_data <- data.frame(blood_pressure, age, weight, bmi, salt_intake, stress_level)

# 2. 전체 모델 생성
full_model <- lm(blood_pressure ~ ., data = patient_data)
# blood_pressure ~ . 는 blood_pressure ~ age + weight + bmi + ... 와 동일

# 3. 단계적 회귀 수행 (후진 제거법)
stepwise_model <- step(full_model, direction = "backward", trace = 0) # trace=0은 중간 과정 생략

# 4. 최종 모델 결과 확인
summary(stepwise_model)

해설

단계적 회귀는 여러 개의 후보 독립 변수 중에서 통계적으로 유의미한 변수들만 선택하여 최적의 예측 모델을 구성하는 자동화된 방법 중 하나입니다.

주요 방법:

  • 전진 선택법 (Forward Selection): 변수가 없는 상태에서 시작하여, 모델의 성능을 가장 많이 향상시키는 변수를 하나씩 추가해 나가는 방식.
  • 후진 제거법 (Backward Elimination): 모든 변수를 포함한 모델에서 시작하여, 모델 성능에 가장 적은 영향을 미치는(가장 유의미하지 않은) 변수를 하나씩 제거해 나가는 방식.
  • 단계적 방법 (Stepwise Method): 전진 선택과 후진 제거를 혼합하여, 변수를 추가하거나 제거하는 과정을 반복하는 방식.

R의 step() 함수는 AIC를 기준으로 변수를 추가하거나 제거합니다. direction = "backward"는 후진 제거법을 사용하라는 의미입니다. step 함수는 각 단계에서 변수 하나를 제거했을 때의 AIC를 계산하고, 현재 모델의 AIC보다 낮아지는 제거 조합이 있다면 그중 AIC가 가장 낮은 모델로 이동합니다. 더 이상 AIC가 낮아지지 않으면 과정을 멈춥니다.

코드 분석:

  • lm(blood_pressure ~ ., data = patient_data): .은 데이터프레임에서 종속 변수를 제외한 모든 나머지 변수를 독립 변수로 사용하라는 의미의 단축 표현입니다.
  • step(full_model, direction = "backward", trace = 0): full_model에서 시작하여 변수를 하나씩 제거하면서 AIC를 낮추는 과정을 수행합니다. trace=0은 각 단계의 상세 출력을 생략하여 최종 결과만 깔끔하게 보게 해줍니다. (학습 목적으로는 trace=1로 설정하여 과정을 보는 것도 좋습니다.)

결과 해석: summary(stepwise_model)의 결과는 다음과 유사합니다.

Call:
lm(formula = blood_pressure ~ age + weight + salt_intake, data = patient_data)

Coefficients:
 (Intercept)          age       weight  salt_intake  
   98.989098     0.803874     0.505030     0.001049  
  • 최종 선택된 변수: age, weight, salt_intake
  • 제거된 변수: bmi, stress_level
  • 해석: 후진 제거법을 통해, stress_levelbmi 변수는 혈압을 예측하는 데 통계적으로 유의미한 기여를 하지 못하여 모델에서 제거되었습니다. bmiweight와 매우 강한 상관관계(다중공선성)가 있어, weight가 모델에 포함된 상태에서는 추가적인 설명력을 거의 제공하지 못했기 때문에 제거되었을 가능성이 높습니다. stress_level은 우리가 데이터를 생성할 때부터 의도적으로 혈압과 관계없게 만들었기 때문에 제거되는 것이 당연합니다. 최종적으로 age, weight, salt_intake 세 변수가 혈압을 설명하는 최적의 조합으로 선택되었습니다.

260. 종합 실전: 게임 승패 예측 모델링 프로젝트

문제 상황: 당신은 인기 온라인 게임 '리그 오브 R'의 데이터 분석가입니다. 게임이 끝난 후의 데이터를 바탕으로, 어떤 팀이 승리했는지(result, 'Win'/'Loss') 예측하는 모델을 만들고자 합니다. 팀의 평균 킬(kills), 데스(deaths), 어시스트(assists), 그리고 획득 골드(gold) 데이터가 주어졌습니다.

과제 (프로젝트 단계):

  1. 데이터 생성: 게임 결과를 시뮬레이션하는 데이터를 생성하세요. 승리팀은 보통 킬, 어시스트, 골드가 높고 데스가 낮은 경향이 있도록 만드세요. result는 'Win'/'Loss'의 factor 변수로 만드세요.
  2. 모델 구축: result를 종속 변수로, 나머지 변수들을 독립 변수로 하는 로지스틱 회귀 모델을 구축하세요.
  3. 결과 해석: summary()를 통해 각 스탯이 승리에 미치는 영향을 해석하세요. 특히 deathsgold의 계수를 오즈비로 변환하여 설명하세요.
  4. 모델 평가: 학습 데이터에 대한 예측을 수행하고, 혼동 행렬을 만들어 모델의 정확도를 계산하세요.

정답 코드

# 1. 데이터 생성
set.seed(101)
n <- 300
kills <- round(rnorm(n, 15, 5))
deaths <- round(rnorm(n, 15, 5))
assists <- round(rnorm(n, 25, 8))
gold <- round(rnorm(n, 12000, 2000))

# 승리 로그-오즈 계산
log_odds_win <- -2 + 0.2 * kills - 0.3 * deaths + 0.1 * assists + 0.0005 * gold + rnorm(n, 0, 0.5)

prob_win <- 1 / (1 + exp(-log_odds_win))
win_binary <- rbinom(n, 1, prob_win)
result <- factor(ifelse(win_binary == 1, "Win", "Loss"), levels = c("Loss", "Win"))

game_data <- data.frame(kills, deaths, assists, gold, result)


# 2. 모델 구축
win_model <- glm(result ~ kills + deaths + assists + gold, 
                 data = game_data, family = "binomial")


# 3. 결과 해석
print("--- Model Summary ---")
summary(win_model)

# deaths와 gold의 오즈비 계산 및 해석
coeffs <- coef(win_model)
odds_ratios <- exp(coeffs[c("deaths", "gold")])
cat("\n--- Odds Ratios ---\n")
print(odds_ratios)
cat("해석: 다른 조건이 동일할 때, deaths가 1 증가하면 승리할 오즈가 약 26% 감소합니다 (0.74배가 됨).\n")
cat("해석: 다른 조건이 동일할 때, gold가 1000 증가하면 승리할 오즈가 약 68% 증가합니다 (exp(0.00051 * 1000) = 1.67배가 됨).\n")


# 4. 모델 평가
# 예측 확률 ('Win'일 확률)
predicted_probs <- predict(win_model, type = "response")

# 예측 클래스 분류 (임계값 0.5)
predicted_class <- factor(ifelse(predicted_probs > 0.5, "Win", "Loss"), levels = c("Loss", "Win"))

# 혼동 행렬 및 정확도
conf_matrix <- table(Predicted = predicted_class, Actual = game_data$result)
print("\n--- Confusion Matrix ---")
print(conf_matrix)

accuracy <- sum(diag(conf_matrix)) / sum(conf_matrix)
cat("\nAccuracy:", accuracy, "\n")

해설

이 문제는 로지스틱 회귀 분석의 전 과정을 아우르는 종합 실전 문제입니다. 데이터 생성부터 모델 구축, 핵심 결과 해석, 그리고 성능 평가까지 이어지는 데이터 분석의 미니 프로젝트와 같습니다.

1. 데이터 생성: 승패에 영향을 미치는 핵심 지표들의 관계를 log_odds_win 계산식에 반영했습니다. kills, assists, gold는 양의 계수를, deaths는 음의 계수를 주어 현실적인 게임 데이터를 시뮬레이션했습니다.

2. 모델 구축: glm(result ~ ., ...)와 유사하게 모든 변수를 사용하여 로지스틱 회귀 모델을 적합시켰습니다. family = "binomial"을 통해 이진 분류 문제임을 명시했습니다.

3. 결과 해석:

  • summary() 결과:
    • kills, assists, gold의 계수는 양수로, 이 스탯들이 높을수록 승리 확률이 높아짐을 의미합니다.
    • deaths의 계수는 음수로, 데스가 많을수록 승리 확률이 낮아짐을 의미합니다. 모든 계수의 p-value가 매우 작아 통계적으로 유의미함을 확인할 수 있습니다.
  • 오즈비 해석:
    • deaths의 오즈비는 약 0.74입니다. 이는 데스가 1 증가할 때마다 승리 오즈가 0.74배가 된다는 의미로, 기존 오즈의 74% 수준으로 감소, 즉 약 26% 감소함을 뜻합니다.
    • gold의 오즈비는 exp(0.00051)로 약 1.00051입니다. 이 값 자체는 해석이 어렵지만, 골드가 1000단위 증가할 때의 오즈비를 계산하면 exp(0.00051 * 1000) = exp(0.51) ≈ 1.67이 됩니다. 즉, 골드를 1000 더 획득한 팀은 승리 오즈가 약 1.67배(67% 증가) 높아진다고 해석할 수 있습니다. 단위(scale)를 고려한 해석이 중요합니다.

4. 모델 평가:

  • 혼동 행렬: predicttable 함수를 이용해 모델의 예측 성능을 표로 정리했습니다.
    --- Confusion Matrix ---
             Actual
    Predicted Loss Win
         Loss  120  18
         Win    13 149
    
  • 정확도: 약 0.896으로, 모델이 약 90%의 정확도로 게임의 승패를 예측함을 알 수 있습니다. 이는 생성된 데이터의 특성상 변수들이 승패를 명확하게 가르기 때문이며, 실제 데이터에서는 이보다 낮은 성능을 보일 수 있습니다. 이 모델은 팀의 전력 분석이나 게임 후 피드백 시스템 개발 등에 활용될 수 있는 가능성을 보여줍니다.

훌륭합니다! 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 당신의 실력을 한 단계 끌어올릴 중급 5단계 R 프로그래밍 문제들을 준비했습니다. 이 문제들은 실무에서 마주할 법한 복잡한 데이터 변환, 웹 데이터 수집 및 정제, 그리고 시계열 및 텍스트 데이터 분석 시나리오를 종합적으로 다룹니다.

각 문제는 단순한 함수 사용법을 넘어, 데이터 분석의 전체적인 흐름과 문제 해결 능력을 기르는 데 초점을 맞추었습니다. 시작하겠습니다!


261. 게임 캐릭터 스탯 문자열 파싱

문제 상황: 당신은 인기 MMORPG 게임 'R-Chronicles'의 데이터 분석가입니다. 캐릭터 정보 데이터베이스에서 'stats'라는 컬럼에 캐릭터의 주요 스탯이 (STR:95/DEX:88/INT:75)와 같은 형식의 단일 문자열로 저장되어 있는 것을 발견했습니다. 이 데이터를 분석하기 용이하도록 각 스탯을 별도의 컬럼으로 분리해야 합니다.

과제 지시 사항:

  1. 주어진 characters 데이터프레임에서 stats 컬럼을 파싱하세요.
  2. stringr 패키지의 함수를 사용하여 각 캐릭터의 STR, DEX, INT 값을 추출하여 각각 strength, dexterity, intelligence라는 새로운 숫자형 컬럼을 생성하세요.
  3. 세 스탯의 평균값인 avg_stat 컬럼을 추가하세요.
# 초기 데이터
library(dplyr)
library(stringr)

characters <- tibble(
  character_id = c("KNI001", "ARC002", "WIZ003", "ASN004"),
  class = c("Knight", "Archer", "Wizard", "Assassin"),
  stats = c("(STR:120/DEX:85/INT:50)", "(STR:90/DEX:150/INT:70)", 
            "(STR:60/DEX:90/INT:160)", "(STR:110/DEX:130/INT:65)")
)

정답 코드

library(dplyr)
library(stringr)

characters_processed <- characters %>%
  mutate(
    strength = as.numeric(str_extract(stats, "(?<=STR:)\\d+")),
    dexterity = as.numeric(str_extract(stats, "(?<=DEX:)\\d+")),
    intelligence = as.numeric(str_extract(stats, "(?<=INT:)\\d+"))
  ) %>%
  mutate(
    avg_stat = (strength + dexterity + intelligence) / 3
  ) %>%
  select(character_id, class, strength, dexterity, intelligence, avg_stat)

print(characters_processed)

해설

이 문제는 복잡한 문자열 안에 포함된 구조적인 데이터를 추출하는 전형적인 사례입니다. stringr 패키지와 정규표현식(Regular Expression)을 활용하는 능력이 핵심입니다.

  1. str_extract(string, pattern): 이 함수는 문자열(string)에서 정규표현식 pattern과 일치하는 첫 번째 부분을 추출합니다.
  2. 정규표현식 해설: (?<=STR:)\\d+
    • (?<=STR:): 이것은 '긍정형 후방탐색(Positive Lookbehind)'입니다. STR:라는 문자열이 앞에 존재해야 한다는 조건을 의미하지만, STR: 자체는 추출 결과에 포함되지 않습니다. 즉, "STR: 바로 뒤에 있는 것을 찾아라"는 의미입니다.
    • \\d+: \d는 숫자 하나를 의미하고, +는 '하나 이상'을 의미하는 수량자입니다. 따라서 \\d+는 하나 이상의 연속된 숫자를 의미합니다.
    • 종합하면, (?<=STR:)\\d+는 "STR: 바로 뒤에 나오는 연속된 숫자들"을 찾아냅니다. DEX와 INT도 동일한 원리로 추출합니다.
  3. as.numeric(): str_extract는 문자열을 반환하므로, 수학적 계산(평균)을 위해 as.numeric() 함수를 사용하여 숫자형으로 변환해 주어야 합니다.
  4. mutate()와 파이프 연산자 (%>%): dplyrmutate()를 사용하여 새로운 컬럼을 순차적으로 추가합니다. 첫 mutate에서 스탯을 추출하고, 두 번째 mutate에서 추출된 스탯으로 평균을 계산하여 코드의 가독성과 단계별 처리를 명확하게 합니다.
  5. select(): 마지막으로 select()를 사용하여 불필요해진 원본 stats 컬럼을 제외하고 원하는 컬럼만 순서대로 정렬하여 분석에 용이한 최종 데이터프레임을 만듭니다.

262. 커피숍 시간대별 매출 분석

문제 상황: 당신은 'Tidy Cafe'라는 커피숍의 데이터 분석가입니다. POS 시스템에서 추출한 거래 기록 데이터에는 각 거래가 발생한 정확한 타임스탬프가 포함되어 있습니다. 매니저는 요일별, 그리고 시간대별 매출 패턴을 파악하여 인력 배치를 최적화하고 싶어합니다.

과제 지시 사항:

  1. 주어진 transactions 데이터프레임의 timestamp 컬럼(문자열)을 lubridate를 사용하여 datetime 객체로 변환하세요.
  2. timestamp를 기반으로 day_of_week (월, 화, 수...)와 hour_of_day (0-23) 컬럼을 새로 생성하세요.
  3. 요일별, 시간대별 총 매출(total_sales)을 계산하세요.
  4. 가장 매출이 높은 상위 5개 '요일-시간대' 조합을 출력하세요.
# 초기 데이터
library(dplyr)
library(lubridate)

transactions <- tibble(
  transaction_id = 1:1000,
  timestamp = format(
    as.POSIXct("2023-10-23 07:00:00") + runif(1000, 0, 7*24*3600),
    "%Y-%m-%d %H:%M:%S"
  ),
  amount = round(runif(1000, 3, 15), 2)
)

정답 코드

library(dplyr)
library(lubridate)

sales_analysis <- transactions %>%
  # 1. timestamp를 datetime 객체로 변환
  mutate(timestamp_dt = ymd_hms(timestamp, tz = "Asia/Seoul")) %>%
  
  # 2. 요일과 시간 컬럼 생성
  mutate(
    day_of_week = wday(timestamp_dt, label = TRUE, week_start = 1), # 월요일 시작
    hour_of_day = hour(timestamp_dt)
  ) %>%
  
  # 3. 요일별, 시간대별 총 매출 계산
  group_by(day_of_week, hour_of_day) %>%
  summarise(total_sales = sum(amount), .groups = 'drop') %>%
  
  # 4. 매출 상위 5개 조합 출력
  arrange(desc(total_sales)) %>%
  head(5)

print(sales_analysis)

해설

이 문제는 시계열 데이터 분석의 기초를 다지는 중요한 예제입니다. lubridate 패키지를 사용하여 시간 정보를 다루고 dplyr로 집계하는 과정을 보여줍니다.

  1. lubridate::ymd_hms(): 이 함수는 "Year-Month-Day Hour:Minute:Second" 형식의 문자열을 파싱하여 POSIXct라는 표준 datetime 객체로 변환합니다. tz 인수는 타임존을 지정하여 시차와 관련된 문제를 예방합니다.
  2. lubridate::wday(): datetime 객체에서 요일 정보를 추출합니다.
    • label = TRUE: '1', '2'와 같은 숫자 대신 '월', '화' ('Mon', 'Tue')와 같은 요일 레이블을 반환합니다.
    • week_start = 1: 한 주의 시작을 월요일로 설정합니다 (기본값은 일요일).
  3. lubridate::hour(): datetime 객체에서 시간(0-23) 정보를 정수로 추출합니다.
  4. group_by() & summarise(): dplyr의 핵심적인 집계 기능입니다. group_by(day_of_week, hour_of_day)를 통해 이후의 계산(여기서는 sum())이 '요일-시간'의 모든 고유한 조합별로 수행되도록 지시합니다. .groups = 'drop' 인수는 summarise 후 그룹핑을 해제하여 이후 작업에 예기치 않은 영향을 주지 않도록 하는 좋은 습관입니다.
  5. arrange(desc(total_sales)): 계산된 total_salesdesc()(내림차순) 기준으로 정렬하여 가장 매출이 높은 조합이 맨 위로 오게 합니다.
  6. head(5): 정렬된 결과에서 상위 5개의 행만 선택하여 최종 결과를 도출합니다.

263. 주식 데이터 형태 변환 (Wide to Long)

문제 상황: 당신은 금융 데이터 분석가입니다. 여러 기술주의 일별 종가(closing price) 데이터가 'wide' 포맷으로 주어졌습니다. 즉, 날짜 컬럼 하나와 각 주식의 티커(AAPL, MSFT, GOOG)가 별도의 컬럼으로 구성되어 있습니다. ggplot2와 같은 시각화 도구나 다른 분석 기법을 적용하기 위해 이 데이터를 'long' 포맷(tidy format)으로 변환해야 합니다.

과제 지시 사항:

  1. 주어진 stock_prices_wide 데이터프레임을 'long' 포맷으로 변환하세요.
  2. 새로운 데이터프레임은 date, ticker, closing_price 세 개의 컬럼을 가져야 합니다.
  3. tidyr 패키지의 pivot_longer() 함수를 사용하세요.
# 초기 데이터
library(tidyr)
library(dplyr)
library(lubridate)

stock_prices_wide <- tibble(
  date = ymd(c("2023-11-01", "2023-11-02", "2023-11-03")),
  AAPL = c(173.97, 177.57, 176.65),
  MSFT = c(348.44, 352.80, 356.12),
  GOOG = c(130.28, 131.95, 133.46)
)

정답 코드

library(tidyr)
library(dplyr)

stock_prices_long <- stock_prices_wide %>%
  pivot_longer(
    cols = -date,              # date 컬럼을 제외한 모든 컬럼을 대상으로 함
    names_to = "ticker",       # 대상 컬럼들의 이름을 저장할 새 컬럼명
    values_to = "closing_price" # 대상 컬럼들의 값을 저장할 새 컬럼명
  )

print(stock_prices_long)

해설

이 문제는 'Tidy Data' 원칙을 이해하고 tidyr 패키지를 사용하여 데이터 구조를 변환하는 핵심적인 능력을 평가합니다. Wide 포맷 데이터는 사람이 보기에는 편할 수 있지만, R에서 프로그래밍 방식으로 분석하고 시각화하기에는 Long 포맷이 훨씬 효율적입니다.

  1. Tidy Data 원칙:

    • 각 변수는 고유한 열(column)을 형성한다.
    • 각 관측치는 고유한 행(row)을 형성한다.
    • 각 값은 고유한 셀(cell)을 형성한다.
    • 원본 데이터에서 'AAPL', 'MSFT', 'GOOG'는 사실 '주식 티커'라는 하나의 변수에 대한 값들이지만, 여러 컬럼에 흩어져 있어 Tidy Data 원칙을 위배합니다.
  2. tidyr::pivot_longer(): Wide 포맷 데이터를 Long 포맷으로 변환하는 함수입니다.

    • cols: 어떤 컬럼들을 Long 포맷으로 변환할지 지정합니다. -datedate 컬럼을 제외한 나머지 모든 컬럼(AAPL, MSFT, GOOG)을 의미합니다. c(AAPL, MSFT, GOOG) 또는 AAPL:GOOG와 같이 명시적으로 지정할 수도 있습니다.
    • names_to: cols에 지정된 컬럼들의 이름(여기서는 "AAPL", "MSFT", "GOOG")이 들어갈 새로운 컬럼의 이름을 지정합니다. 우리는 이것을 "ticker"라고 명명했습니다.
    • values_to: cols에 지정된 컬럼들의 값(주가)이 들어갈 새로운 컬럼의 이름을 지정합니다. 우리는 이것을 "closing_price"라고 명명했습니다.

이 변환을 통해, 이제 ticker별로 데이터를 group_by하거나, ggplot(aes(x = date, y = closing_price, color = ticker))와 같이 손쉽게 시각화를 수행할 수 있는 구조가 되었습니다.


264. 제품 식별 코드 분리 분석

문제 상황: 한 제조 회사의 제품 데이터베이스에는 product_code라는 컬럼이 있습니다. 이 코드는 제품카테고리-제조국-일련번호 형식 (예: ELEC-KR-1001)으로 구성되어 있습니다. 마케팅팀은 카테고리별, 제조국별 제품 수를 파악하고자 합니다.

과제 지시 사항:

  1. 주어진 products 데이터프레임의 product_code 컬럼을 -를 기준으로 세 개의 새로운 컬럼(category, country_code, serial_no)으로 분리하세요.
  2. tidyr 패키지의 separate() 함수를 사용하세요.
  3. 분리된 데이터를 바탕으로, 각 category별 제품 수를 계산하여 출력하세요.
# 초기 데이터
library(dplyr)
library(tidyr)

products <- tibble(
  product_id = 1:6,
  product_code = c("ELEC-KR-1001", "FURN-CN-2030", "ELEC-US-1002", 
                   "ELEC-KR-1003", "TOYS-CN-3100", "FURN-US-2045")
)

정답 코드

library(dplyr)
library(tidyr)

products_separated <- products %>%
  separate(
    col = product_code, 
    into = c("category", "country_code", "serial_no"), 
    sep = "-",
    remove = FALSE # 원본 product_code 컬럼 유지
  )

category_counts <- products_separated %>%
  count(category, sort = TRUE)

print(products_separated)
print(category_counts)

해설

이 문제는 하나의 컬럼에 여러 정보가 결합되어 있을 때 이를 분리하는 방법을 다룹니다. tidyr::separate()는 이런 작업을 위해 특별히 설계된 강력한 함수입니다.

  1. tidyr::separate(): 지정된 컬럼을 구분자(sep)를 기준으로 여러 컬럼으로 분리합니다.

    • col: 분리할 대상 컬럼을 지정합니다 (여기서는 product_code).
    • into: 새로 생성될 컬럼들의 이름을 문자형 벡터로 지정합니다 (c("category", "country_code", "serial_no")). 벡터의 원소 개수는 분리 후 생성될 조각의 수와 일치해야 합니다.
    • sep: 데이터를 분리할 기준이 되는 구분자를 지정합니다 (여기서는 "-"). 정규표현식도 사용할 수 있습니다.
    • remove = FALSE: 이 인수는 매우 유용합니다. 기본값은 TRUE로, 원본 컬럼(product_code)을 제거합니다. FALSE로 설정하면 원본 컬럼을 유지하면서 새로운 컬럼들을 추가할 수 있어 데이터 검증에 용이합니다.
  2. dplyr::count(): group_by()summarise(n = n())를 한 번에 실행하는 편리한 함수입니다. count(category)category별로 그룹화하여 각 그룹의 행 수를 세고, n이라는 컬럼에 저장합니다. sort = TRUE 인수를 추가하면 결과가 n을 기준으로 내림차순 정렬됩니다. 이 단계를 통해 "각 카테고리별 제품 수"라는 분석 목표를 명확하게 달성할 수 있습니다.


265. 웹 스크레이핑 기초: 위키피디아 테이블 가져오기

문제 상황: 당신은 지정학 연구원입니다. 연구를 위해 위키피디아에 있는 '인구순 나라 목록' 테이블 데이터가 필요합니다. 웹 브라우저에서 직접 복사/붙여넣기 하는 대신, R을 사용하여 프로그래밍 방식으로 데이터를 수집하고 정제하려고 합니다.

과제 지시 사항:

  1. rvest 패키지를 사용하여 아래 URL의 웹 페이지 내용을 R로 가져오세요.
    • URL: https://en.wikipedia.org/wiki/List_of_countries_and_dependencies_by_population
  2. 페이지에 있는 첫 번째 wikitable 클래스의 테이블을 추출하여 데이터프레임으로 변환하세요.
  3. 추출된 데이터프레임에서 첫 10개국의 데이터만 선택하고, 컬럼 이름을 Rank, Country, Population, Date, Source로 변경하세요.
  4. Population 컬럼을 숫자형으로 변환하세요. (쉼표(,)를 제거해야 합니다.)

정답 코드

library(rvest)
library(dplyr)
library(stringr)

# 1. URL에서 웹 페이지 읽기
url <- "https://en.wikipedia.org/wiki/List_of_countries_and_dependencies_by_population"
webpage <- read_html(url)

# 2. 첫 번째 wikitable 추출 및 데이터프레임 변환
population_table <- webpage %>%
  html_nodes(".wikitable") %>% # CSS 선택자로 wikitable 클래스를 가진 노드 선택
  .[[1]] %>%                   # 리스트의 첫 번째 테이블 선택
  html_table()                 # HTML 테이블을 데이터프레임으로 파싱

# 3. 데이터 정제 및 컬럼명 변경
population_data <- population_table %>%
  head(10) %>%
  select(1:5) %>% # 필요한 5개 컬럼만 선택
  setNames(c("Rank", "Country", "Population", "Date", "Source"))

# 4. Population 컬럼을 숫자형으로 변환
population_data <- population_data %>%
  mutate(
    Country = str_remove(Country, "\\[[a-z]\\]$"), # Country 이름 뒤 주석 제거
    Population = as.numeric(str_replace_all(Population, ",", ""))
  )

print(population_data)

해설

이 문제는 웹 스크레이핑의 기본 과정을 보여줍니다. rvest 패키지는 웹 페이지의 HTML 구조를 파싱하고 원하는 정보를 추출하는 데 매우 효과적입니다.

  1. rvest::read_html(): 지정된 URL의 HTML 내용을 다운로드하여 R에서 다룰 수 있는 xml_document 객체로 만듭니다.
  2. rvest::html_nodes(): CSS 선택자나 XPath를 사용하여 HTML 문서 내에서 특정 노드(태그)를 선택합니다.
    • .wikitable: CSS 클래스 선택자입니다. .은 클래스를 의미하므로, class="wikitable" 속성을 가진 모든 HTML 요소를 선택합니다. 위키피디아의 표준 테이블은 대부분 이 클래스를 가집니다.
  3. .[[1]]: html_nodes()는 조건을 만족하는 모든 노드를 리스트 형태로 반환합니다. 우리가 필요한 것은 첫 번째 테이블이므로, 리스트의 첫 번째 원소를 선택합니다.
  4. rvest::html_table(): 선택된 <table> 노드를 깔끔한 데이터프레임으로 자동 변환해주는 매우 편리한 함수입니다.
  5. setNames(): 데이터프레임의 컬럼 이름을 한 번에 변경하는 함수입니다. names(df) <- c(...)와 동일한 역할을 하지만 dplyr 파이프라인 내에서 사용하기 편리합니다.
  6. str_replace_all(Population, ",", ""): Population 컬럼의 값(예: "1,428,627,663")에 포함된 모든 쉼표(,)를 빈 문자열("")로 바꾸어 숫자 변환이 가능하도록 만듭니다. 이후 as.numeric()으로 숫자형으로 최종 변환합니다. 추가적으로 str_remove()를 사용하여 국가 이름 뒤에 붙는 불필요한 주석([a], [b] 등)을 제거하는 정제 과정도 포함했습니다.

266. 영화 리뷰 사이트 스크레이핑 및 정제

문제 상황: 당신은 영화 평론 사이트의 데이터를 분석하여 최근 인기 영화들의 평점과 상영 시간 정보를 수집하려고 합니다. IMDb의 'Top 250 Movies' 페이지에서 정보를 스크레이핑해야 합니다. 이 페이지의 데이터는 깔끔한 테이블 형태가 아니므로, 각 영화 정보를 개별적으로 추출하여 조합해야 합니다.

과제 지시 사항:

  1. rvest를 사용하여 IMDb Top 250 페이지 (https://www.imdb.com/chart/top/)에 접속하세요.
  2. 각 영화의 '제목(title)', '연도(year)', '평점(rating)' 정보를 추출하세요.
    • 제목: .titleColumn a CSS 선택자
    • 평점: .ratingColumn strong CSS 선택자
  3. 추출한 정보를 하나의 tibble (데이터프레임)로 결합하세요.
  4. '제목'에서 연도 정보 (YYYY)를 분리하여 별도의 year 컬럼을 만들고, 제목 컬럼에서는 연도를 제거하세요. (힌트: str_extractstr_remove)

정답 코드

library(rvest)
library(dplyr)
library(stringr)

# 1. URL 접속
url <- "https://www.imdb.com/chart/top/"
# IMDb는 User-Agent를 요구할 수 있음
webpage <- read_html(url)

# 2. 정보 추출
# 제목과 연도가 함께 있는 텍스트 추출
title_year_raw <- webpage %>%
  html_nodes(".titleColumn") %>%
  html_text(trim = TRUE)

# 평점 추출
ratings <- webpage %>%
  html_nodes(".ratingColumn strong") %>%
  html_text(trim = TRUE) %>%
  as.numeric()

# 3. 데이터프레임으로 결합
imdb_top_250 <- tibble(
  title_year_raw = title_year_raw,
  rating = ratings
)

# 4. 제목과 연도 분리 및 정제
imdb_top_250_clean <- imdb_top_250 %>%
  mutate(
    rank = row_number(),
    year = as.numeric(str_extract(title_year_raw, "(?<=\\()\\d{4}(?=\\))")),
    title = str_remove(title_year_raw, "\\d+\\.\\s*"), # 맨 앞의 '1. ' 같은 순위 제거
    title = str_remove(title, "\\s*\\(\\d{4}\\)")      # '(YYYY)' 부분 제거
  ) %>%
  select(rank, title, year, rating)

print(head(imdb_top_250_clean, 10))

해설

이 문제는 테이블이 아닌, 구조화된 텍스트 목록에서 정보를 스크레이핑하는 더 현실적인 시나리오입니다. 각기 다른 CSS 선택자를 사용하여 원하는 정보 조각들을 모은 뒤, stringr로 정제하여 최종 데이터셋을 만듭니다.

  1. CSS 선택자:
    • .titleColumn: titleColumn 클래스를 가진 요소를 선택합니다. 이 안에는 순위, 제목, 연도가 모두 포함되어 있습니다.
    • .ratingColumn strong: ratingColumn 클래스 하위의 <strong> 태그를 선택합니다. 평점 정보가 이 태그 안에 굵게 표시되어 있습니다.
  2. html_text(trim = TRUE): html_nodes()로 선택한 HTML 요소에서 태그를 제외한 텍스트 내용만 추출합니다. trim = TRUE는 텍스트 앞뒤의 불필요한 공백을 제거합니다.
  3. 데이터 결합: tibble()을 사용하여 각각 추출한 텍스트 벡터들(title_year_raw, ratings)을 컬럼으로 하는 데이터프레임을 생성합니다. 이 시점에서는 아직 데이터가 정제되지 않은 상태입니다.
  4. stringr를 이용한 정제:
    • str_extract(title_year_raw, "(?<=\\()\\d{4}(?=\\))"): 정규표현식을 사용하여 연도를 추출합니다.
      • (?<=\\(): '긍정형 후방탐색'. ( 바로 뒤를 의미합니다. (는 특수문자이므로 \\로 이스케이프 처리합니다.
      • \\d{4}: 숫자 4개를 의미합니다.
      • (?=\\)): '긍정형 전방탐색'. ) 바로 앞을 의미합니다.
      • 종합하면, (YYYY) 패턴에서 YYYY 부분만 정확히 추출합니다.
    • str_remove(title_year_raw, "\\d+\\.\\s*"): 1. , 2. 등 맨 앞에 붙은 순위와 점, 공백을 제거합니다.
    • str_remove(title, "\\s*\\(\\d{4}\\)"): 위에서 추출한 연도 부분 (YYYY)를 제목에서 제거하여 순수한 제목만 남깁니다.

이 과정을 통해, 비정형적인 웹 페이지의 HTML 구조로부터 정형적인 데이터프레임을 성공적으로 구축할 수 있습니다.


267. 서버 로그 데이터 파싱 및 분석

문제 상황: 당신은 웹 서비스 회사의 시스템 엔지니어입니다. 서버 로그가 하나의 긴 문자열로 기록되고 있습니다. 각 로그 라인은 [타임스탬프] [로그레벨] 메시지 (IP: xxx.xxx.xxx.xxx) 와 같은 형식을 가집니다. 이 로그를 분석하여 시간대별 에러 발생 빈도와 주요 에러 발생 IP를 파악해야 합니다.

과제 지시 사항:

  1. 주어진 log_data 벡터의 각 원소를 파싱하여 timestamp, log_level, message, ip_address 정보를 담은 데이터프레임을 생성하세요. tidyr::extract 또는 stringr::str_match를 활용하세요.
  2. timestamplubridate를 사용하여 datetime 객체로 변환하세요.
  3. 시간대별(hour) ERROR 로그의 발생 횟수를 계산하세요.
  4. ERROR를 가장 많이 발생시킨 상위 3개 IP 주소를 찾으세요.
# 초기 데이터
log_data <- c(
  "[2023-11-10 09:15:23] [INFO] User login successful (IP: 192.168.1.10)",
  "[2023-11-10 09:16:01] [ERROR] Database connection failed (IP: 10.0.0.5)",
  "[2023-11-10 09:16:15] [DEBUG] Query executed: SELECT * FROM users (IP: 192.168.1.10)",
  "[2023-11-10 09:17:45] [ERROR] Payment gateway timeout (IP: 203.0.113.24)",
  "[2023-11-10 09:18:02] [ERROR] Database connection failed (IP: 10.0.0.5)",
  "[2023-11-10 10:25:11] [WARN] High memory usage detected (IP: 192.168.1.10)",
  "[2023-11-10 10:30:05] [ERROR] Null pointer exception in module X (IP: 10.0.0.5)"
)

정답 코드

library(dplyr)
library(tidyr)
library(stringr)
library(lubridate)

log_df <- tibble(raw_log = log_data)

# 1. 정규표현식을 이용한 데이터 파싱
log_parsed <- log_df %>%
  extract(
    raw_log,
    into = c("timestamp", "log_level", "message", "ip_address"),
    regex = "\\[(.*)\\] \\[(.*)\\] (.*) \\(IP: (.*)\\)",
    remove = FALSE
  )

# 2. timestamp 변환 및 시간 컬럼 추가
log_final <- log_parsed %>%
  mutate(
    timestamp_dt = ymd_hms(timestamp, tz = "UTC"),
    hour = hour(timestamp_dt)
  )

# 3. 시간대별 ERROR 로그 발생 횟수
error_by_hour <- log_final %>%
  filter(log_level == "ERROR") %>%
  count(hour, name = "error_count", sort = TRUE)

# 4. ERROR 발생 상위 IP 주소
top_error_ips <- log_final %>%
  filter(log_level == "ERROR") %>%
  count(ip_address, name = "error_count", sort = TRUE) %>%
  head(3)

print("시간대별 에러 수:")
print(error_by_hour)

print("에러 발생 상위 IP:")
print(top_error_ips)

해설

이 문제는 정규표현식을 사용하여 비정형 텍스트 로그에서 정형 데이터를 추출하는 실무적인 능력을 측정합니다. tidyr::extract는 이 작업을 매우 우아하게 처리해줍니다.

  1. tidyr::extract(): 이 함수는 정규표현식의 '캡처 그룹(capture group)'을 사용하여 문자열 컬럼을 여러 컬럼으로 분리합니다.
    • regex: 사용될 정규표현식입니다.
    • \\[(.*)\\]: [] 사이의 모든 문자(.*)를 첫 번째 캡처 그룹으로 잡습니다 (타임스탬프). 대괄호는 특수문자이므로 \\[로 이스케이프합니다.
    • \\[(.*)\\]: 두 번째 캡처 그룹 (로그레벨).
    • (.*): 세 번째 캡처 그룹 (메시지).
    • \\(IP: (.*)\\): (IP: ) 사이의 모든 문자를 네 번째 캡처 그룹으로 잡습니다 (IP 주소). 괄호도 특수문자이므로 \\(로 이스케이프합니다.
    • into: 캡처 그룹에 해당하는 내용을 순서대로 저장할 새 컬럼 이름을 지정합니다.
  2. lubridate 활용: 파싱된 timestamp 문자열을 ymd_hms()로 datetime 객체로 변환하고, hour() 함수로 시간 정보만 추출하여 시간대별 분석을 준비합니다.
  3. 분석 로직:
    • filter(log_level == "ERROR"): 분석 대상을 ERROR 로그로 한정합니다.
    • count(): group_by()summarise()를 결합한 함수로, 지정된 컬럼(hour 또는 ip_address)을 기준으로 개수를 세어줍니다. name 인수로 개수가 저장될 컬럼명을 지정할 수 있고, sort = TRUE로 결과를 내림차순 정렬할 수 있습니다.

이와 같은 로그 파싱 및 분석은 시스템 장애 감지, 사용자 행동 분석 등 다양한 IT 운영 및 분석 업무의 기본이 됩니다.


268. 금융 데이터 시계열 변환 및 월별 수익률 계산

문제 상황: 당신은 퀀트 분석가로, 여러 자산의 월별 가격 데이터(wide 포맷)를 가지고 있습니다. 이 데이터를 사용하여 각 자산의 월별 수익률(monthly return)을 계산하고, 가장 변동성이 컸던 달을 찾아내고자 합니다.

과제 지시 사항:

  1. 주어진 monthly_prices 데이터를 pivot_longer()를 사용하여 long 포맷으로 변환하세요. 컬럼은 month, asset, price가 되어야 합니다.
  2. asset별로 월별 수익률을 계산하여 monthly_return 컬럼을 추가하세요. 수익률 공식은 $$ \text{Return}t = \frac{\text{Price}t - \text{Price}{t-1}}{\text{Price}{t-1}} $$ 입니다. dplyr::lag() 함수를 활용하세요.
  3. 모든 자산을 통틀어, 월별 수익률의 표준편차(standard deviation)가 가장 컸던 month를 찾으세요. 이는 시장의 변동성이 가장 컸던 달을 의미합니다.
# 초기 데이터
library(dplyr)
library(tidyr)
library(lubridate)

monthly_prices <- tibble(
  month = seq(ymd("2023-01-01"), by = "month", length.out = 6),
  Stock = c(100, 105, 110, 108, 115, 120),
  Bond = c(1000, 1002, 1001, 1005, 1006, 1008),
  Gold = c(1800, 1850, 1820, 1880, 1900, 1870)
)

정답 코드

library(dplyr)
library(tidyr)
library(lubridate)

# 1. Wide to Long 변환
prices_long <- monthly_prices %>%
  pivot_longer(
    cols = -month,
    names_to = "asset",
    values_to = "price"
  )

# 2. 자산별 월별 수익률 계산
returns <- prices_long %>%
  group_by(asset) %>%
  arrange(month) %>% # 시간 순서 정렬이 중요!
  mutate(
    previous_price = lag(price, 1),
    monthly_return = (price - previous_price) / previous_price
  ) %>%
  ungroup()

# 3. 월별 수익률 표준편차 계산 및 변동성 가장 큰 달 찾기
most_volatile_month <- returns %>%
  filter(!is.na(monthly_return)) %>% # 첫 달의 NA 값 제외
  group_by(month) %>%
  summarise(return_std_dev = sd(monthly_return)) %>%
  arrange(desc(return_std_dev)) %>%
  head(1)

print("월별 수익률 데이터:")
print(returns)
print("가장 변동성이 컸던 달:")
print(most_volatile_month)

해설

이 문제는 데이터 형태 변환, 시계열 계산, 통계적 분석을 결합한 종합적인 문제입니다.

  1. pivot_longer(): 263번 문제와 마찬가지로, 분석에 용이한 long 포맷으로 데이터를 변환합니다. 이는 자산별(asset)로 그룹화하여 계산을 수행하기 위한 필수 전처리 단계입니다.
  2. group_by(asset): 수익률은 각 자산 내에서 이전 달과 비교하여 계산되어야 합니다. group_by(asset)을 통해 lag() 함수가 다른 자산의 가격을 참조하는 것을 방지합니다.
  3. arrange(month): lag() 함수는 데이터의 행 순서에 의존합니다. 따라서 계산 전에 arrange(month)를 사용하여 각 그룹 내에서 데이터를 시간순으로 명확하게 정렬하는 것이 매우 중요합니다.
  4. dplyr::lag(): 이 함수는 벡터에서 현재 위치보다 n만큼 앞선 위치의 값을 가져옵니다. lag(price, 1)은 바로 이전 행의 price 값을 의미합니다. 첫 번째 행에는 이전 값이 없으므로 NA가 반환됩니다.
  5. 수익률 계산: LaTeX 수식 $$ (\text{Price}t - \text{Price}{t-1}) / \text{Price}_{t-1} $$ 을 R 코드로 (price - lag(price, 1)) / lag(price, 1) 또는 (price / lag(price, 1)) - 1로 구현합니다.
  6. 변동성 분석:
    • filter(!is.na(monthly_return)): lag()으로 인해 발생한 NA 값을 분석에서 제외합니다.
    • group_by(month): 이번에는 월별로 그룹화하여 해당 월에 모든 자산의 수익률을 모읍니다.
    • summarise(return_std_dev = sd(monthly_return)): 각 월별로 수익률의 표준편차를 계산합니다. 표준편차는 데이터가 평균으로부터 얼마나 흩어져 있는지를 나타내는 척도로, 금융에서는 변동성(volatility)의 대리 지표로 널리 사용됩니다.
    • arrange(desc(return_std_dev))를 통해 표준편차가 가장 큰, 즉 변동성이 가장 심했던 달을 찾아냅니다.

269. 소셜 미디어 데이터: 해시태그 및 포스팅 시간 분석

문제 상황: 당신은 소셜 미디어 마케팅 분석가입니다. 사용자들이 작성한 포스트 데이터가 있으며, 각 포스트에는 작성 시간과 해시태그가 포함되어 있습니다. 어떤 해시태그가 가장 많이 사용되는지, 그리고 포스트가 주로 어떤 시간대에 올라오는지 분석하여 마케팅 전략에 활용하고자 합니다.

과제 지시 사항:

  1. 주어진 posts 데이터프레임에서 hashtags 컬럼의 문자열을 개별 해시태그로 분리하여 'long' 형태의 데이터로 만드세요. 즉, 포스트 하나가 여러 행으로 나타날 수 있으며 각 행은 하나의 해시태그를 가집니다. (tidyr::separate_rows 사용)
  2. 가장 많이 사용된 상위 5개 해시태그와 그 빈도를 계산하세요.
  3. created_at 컬럼을 사용하여 포스트가 '오전(AM)', '오후(PM)', '저녁(Evening)', '밤(Night)' 중 어느 시간대에 작성되었는지 분류하는 time_of_day 컬럼을 만드세요.
    • 오전(AM): 6시 ~ 11시
    • 오후(PM): 12시 ~ 17시
    • 저녁(Evening): 18시 ~ 21시
    • 밤(Night): 22시 ~ 5시
  4. 각 시간대(time_of_day)별 포스트 수를 계산하세요.
# 초기 데이터
library(dplyr)
library(tidyr)
library(lubridate)
library(stringr)

posts <- tibble(
  post_id = 1:5,
  created_at = ymd_hms(c("2023-11-25 08:30:00", "2023-11-25 14:45:10", 
                         "2023-11-25 20:10:25", "2023-11-25 23:55:00",
                         "2023-11-26 10:20:00")),
  hashtags = c("#RStats #DataScience", "#ggplot2 #Visualization", 
               "#Tidyverse #RStats", "#DataAnalysis #RStats", "#MachineLearning")
)

정답 코드

library(dplyr)
library(tidyr)
library(lubridate)
library(stringr)

# 1. 해시태그 분리
hashtags_long <- posts %>%
  separate_rows(hashtags, sep = "\\s+")

# 2. 상위 해시태그 계산
top_hashtags <- hashtags_long %>%
  count(hashtags, sort = TRUE) %>%
  head(5)

# 3. 시간대 분류
posts_with_time_of_day <- posts %>%
  mutate(
    hour = hour(created_at),
    time_of_day = case_when(
      hour >= 6 & hour <= 11  ~ "오전(AM)",
      hour >= 12 & hour <= 17 ~ "오후(PM)",
      hour >= 18 & hour <= 21 ~ "저녁(Evening)",
      TRUE                    ~ "밤(Night)"
    )
  )

# 4. 시간대별 포스트 수 계산
posts_by_time_of_day <- posts_with_time_of_day %>%
  count(time_of_day)

print("상위 5개 해시태그:")
print(top_hashtags)
print("시간대별 포스트 수:")
print(posts_by_time_of_day)

해설

이 문제는 텍스트 데이터의 분리와 시계열 데이터의 범주화를 결합한 문제입니다.

  1. tidyr::separate_rows(): 이 함수는 separate()와 유사하지만, 분리된 조각들을 새로운 컬럼이 아닌 새로운 행으로 만듭니다. 이는 하나의 관측치(포스트)가 여러 개의 값(해시태그)을 가질 때 Tidy Data 형태로 변환하는 이상적인 방법입니다.
    • sep = "\\s+": 구분자를 '하나 이상의 공백'으로 지정합니다. 해시태그들이 공백으로 구분되어 있기 때문입니다.
  2. count(hashtags, sort = TRUE): 분리된 long 데이터에서 hashtags 컬럼을 기준으로 개수를 세면 각 해시태그의 사용 빈도를 쉽게 계산할 수 있습니다.
  3. 시간대 분류:
    • hour = hour(created_at): lubridate를 사용하여 시간 정보를 추출합니다.
    • dplyr::case_when(): 여러 if-else 조건을 순차적으로 검사하여 값을 할당하는 매우 유용한 함수입니다. SQL의 CASE WHEN 문과 유사합니다.
      • hour >= 6 & hour <= 11 ~ "오전(AM)": 첫 번째 조건. hour가 6과 11 사이이면 "오전(AM)"을 할당합니다.
      • ...: 이후 조건들을 순서대로 검사합니다.
      • TRUE ~ "밤(Night)": 앞의 모든 조건이 거짓일 경우를 처리하는 'default' 조건입니다. TRUE는 항상 참이므로, 마지막에 배치하여 나머지 모든 경우를 포괄합니다.
  4. 시간대별 집계: case_when으로 생성된 time_of_day 범주형 변수를 기준으로 count()를 적용하여 각 시간대별 포스팅 빈도를 분석합니다.

270. 항공편 운항 데이터: 지연 시간 계산 및 분석

문제 상황: 당신은 항공 데이터 분석가입니다. 항공편 운항 기록 데이터에는 '예정 출발/도착 시간'과 '실제 출발/도착 시간'이 기록되어 있습니다. 항공사별 평균 지연 시간을 계산하고, 지연이 가장 심했던 항공사를 찾아내야 합니다. 데이터는 서로 다른 타임존을 포함하고 있어 처리에 주의가 필요합니다.

과제 지시 사항:

  1. 주어진 flights 데이터의 모든 시간 관련 컬럼을 lubridate를 사용하여 datetime 객체로 변환하세요. 각 공항의 타임존(origin_tz, dest_tz)을 반드시 적용해야 합니다.
  2. '출발 지연 시간(dep_delay)'과 '도착 지연 시간(arr_delay)'을 분(minute) 단위로 계산하여 새로운 컬럼으로 추가하세요. 지연 시간 = 실제 시간 - 예정 시간.
  3. 항공사(airline)별 평균 출발 지연 시간과 평균 도착 지연 시간을 계산하세요.
  4. 평균 도착 지연 시간이 가장 길었던 항공사를 찾으세요.
# 초기 데이터
library(dplyr)
library(lubridate)

flights <- tibble(
  flight_id = c("KE101", "AA202", "DL303"),
  airline = c("Korean Air", "American Airlines", "Delta"),
  origin_airport = c("ICN", "JFK", "ATL"),
  dest_airport = c("JFK", "LAX", "ICN"),
  origin_tz = c("Asia/Seoul", "America/New_York", "America/New_York"),
  dest_tz = c("America/New_York", "America/Los_Angeles", "Asia/Seoul"),
  scheduled_dep = c("2023-11-26 10:00:00", "2023-11-26 15:30:00", "2023-11-26 22:00:00"),
  actual_dep =    c("2023-11-26 10:25:00", "2023-11-26 15:20:00", "2023-11-26 23:10:00"),
  scheduled_arr = c("2023-11-26 11:00:00", "2023-11-26 18:45:00", "2023-11-27 04:00:00"),
  actual_arr =    c("2023-11-26 11:35:00", "2023-11-26 18:40:00", "2023-11-27 05:30:00")
)

정답 코드

library(dplyr)
library(lubridate)

flights_processed <- flights %>%
  # 1. rowwise()를 사용하여 행별로 다른 타임존 적용
  rowwise() %>%
  mutate(
    scheduled_dep_dt = ymd_hms(scheduled_dep, tz = origin_tz),
    actual_dep_dt = ymd_hms(actual_dep, tz = origin_tz),
    scheduled_arr_dt = ymd_hms(scheduled_arr, tz = dest_tz),
    actual_arr_dt = ymd_hms(actual_arr, tz = dest_tz)
  ) %>%
  ungroup() %>%
  
  # 2. 지연 시간 계산 (분 단위)
  mutate(
    dep_delay = as.numeric(actual_dep_dt - scheduled_dep_dt, units = "mins"),
    arr_delay = as.numeric(actual_arr_dt - scheduled_arr_dt, units = "mins")
  )

# 3. 항공사별 평균 지연 시간 계산
avg_delays_by_airline <- flights_processed %>%
  group_by(airline) %>%
  summarise(
    avg_dep_delay = mean(dep_delay, na.rm = TRUE),
    avg_arr_delay = mean(arr_delay, na.rm = TRUE)
  )

# 4. 도착 지연이 가장 심한 항공사
worst_airline <- avg_delays_by_airline %>%
  arrange(desc(avg_arr_delay)) %>%
  head(1)

print("항공편별 지연 시간:")
print(select(flights_processed, flight_id, airline, dep_delay, arr_delay))
print("항공사별 평균 지연 시간:")
print(avg_delays_by_airline)
print("도착 지연이 가장 심한 항공사:")
print(worst_airline)

해설

이 문제는 lubridate의 핵심 기능인 타임존 처리를 다룹니다. 서로 다른 타임존의 시간을 정확하게 계산하는 것은 글로벌 데이터 분석에서 매우 중요합니다.

  1. 타임존 처리:
    • ymd_hms(..., tz = ...): 문자열을 datetime 객체로 변환할 때 tz 인수를 통해 해당 시간의 타임존을 명시적으로 지정합니다.
    • rowwise(): dplyrmutate는 기본적으로 벡터화 연산을 수행합니다. 즉, ymd_hms(scheduled_dep, tz = origin_tz)를 그냥 실행하면 origin_tz의 첫 번째 값("Asia/Seoul")만 모든 행에 적용하려 하여 에러가 발생합니다. rowwise()는 이후의 연산(mutate)이 각 행(row)별로 독립적으로 수행되도록 만듭니다. 따라서 각 행의 scheduled_dep 값과 해당 행의 origin_tz 값이 올바르게 짝지어집니다. 연산이 끝나면 ungroup()으로 rowwise 상태를 해제하는 것이 좋습니다.
  2. 시간 차이 계산:
    • lubridate를 사용하여 생성된 datetime 객체들은 직접 뺄셈 연산이 가능합니다 (actual_dep_dt - scheduled_dep_dt).
    • 연산 결과는 difftime이라는 특별한 객체로 반환됩니다.
    • as.numeric(..., units = "mins"): difftime 객체를 우리가 원하는 단위(여기서는 '분')의 숫자로 변환합니다. units에는 "secs", "hours", "days" 등을 사용할 수 있습니다.
  3. 분석: 지연 시간이 숫자형으로 계산된 후에는 group_by()summarise()를 사용하여 항공사별 평균을 구하는 표준적인 dplyr 분석 파이프라인을 따릅니다. 음수 지연 시간은 조기 출발/도착을 의미합니다.

271. 블로그 게시물 스크레이핑 및 메타데이터 분석

문제 상황: 당신은 콘텐츠 전략가입니다. 경쟁사 블로그의 게시물 목록을 스크레이핑하여 어떤 주제의 글이 어떤 요일에 주로 게시되는지 분석하고자 합니다. 게시 날짜가 "November 25, 2023"과 같은 비표준 형식으로 되어 있어 추가적인 파싱이 필요합니다.

과제 지시 사항:

  1. rvest를 사용하여 RStudio 블로그 (https://www.rstudio.com/blog/)에 접속하세요.
  2. 각 게시물의 제목, 게시 날짜, 카테고리 정보를 추출하세요.
    • 제목: .post-title a
    • 날짜: .post-date
    • 카테고리: .post-category
  3. 추출한 정보를 하나의 데이터프레임으로 만드세요.
  4. post_date 컬럼(예: "November 25, 2023")을 lubridate를 사용하여 date 객체로 변환하세요.
  5. 각 게시물이 게시된 요일(day_of_week)을 계산하고, 요일별 게시물 수를 집계하여 출력하세요.

정답 코드

library(rvest)
library(dplyr)
library(lubridate)
library(stringr)

# 1. URL 접속
url <- "https://posit.co/blog/" # URL이 RStudio에서 Posit으로 변경됨
webpage <- read_html(url)

# 2. 정보 추출
titles <- webpage %>%
  html_nodes(".post-title a") %>%
  html_text(trim = TRUE)

dates <- webpage %>%
  html_nodes(".post-date") %>%
  html_text(trim = TRUE)

categories <- webpage %>%
  html_nodes(".post-category") %>%
  html_text(trim = TRUE)

# 3. 데이터프레임 생성 (길이가 다를 수 있으므로, 최소 길이에 맞춤)
min_len <- min(length(titles), length(dates), length(categories))
blog_posts <- tibble(
  title = titles[1:min_len],
  post_date_str = dates[1:min_len],
  category = categories[1:min_len]
)

# 4. 날짜 파싱 및 5. 요일별 분석
posts_analysis <- blog_posts %>%
  # lubridate::mdy()는 "Month Day, Year" 형식을 파싱
  mutate(post_date = mdy(post_date_str)) %>% 
  mutate(day_of_week = wday(post_date, label = TRUE, week_start = 1)) %>%
  count(day_of_week, sort = TRUE)

print(posts_analysis)

해설

이 문제는 웹 스크레이핑과 비표준 날짜 형식 처리를 결합합니다. 실세계의 날짜 데이터는 매우 다양한 형식으로 존재하기 때문에 유연한 파싱 능력이 중요합니다.

  1. 웹 스크레이핑: 266번 문제와 유사하게, 특정 CSS 클래스를 가진 요소들을 html_nodes()로 선택하고 html_text()로 텍스트를 추출합니다. 웹사이트 구조는 변경될 수 있으므로, 실제 실행 시에는 개발자 도구(F12)를 사용하여 현재 CSS 선택자를 확인하는 것이 좋습니다.
  2. 데이터프레임 생성: 스크레이핑 시 각 요소(제목, 날짜, 카테고리)의 개수가 다를 수 있습니다. 이 경우, tibble 생성 시 에러가 발생할 수 있으므로 min() 함수로 가장 짧은 벡터의 길이를 찾아 그 길이에 맞춰 데이터를 잘라내는 방어적인 코드를 작성했습니다.
  3. lubridate::mdy(): lubridate의 가장 강력한 기능 중 하나는 다양한 형식의 날짜를 지능적으로 파싱하는 것입니다.
    • ymd(): Year-Month-Day 순서 (e.g., "2023-11-25")
    • mdy(): Month-Day-Year 순서 (e.g., "November 25, 2023")
    • dmy(): Day-Month-Year 순서 (e.g., "25-Nov-2023")
    • mdy() 함수는 "November 25, 2023"과 같은 일반적인 영어 날짜 형식을 자동으로 인식하여 올바른 date 객체로 변환해 줍니다.
  4. 요일 분석: wday() 함수를 사용하여 date 객체에서 요일 정보를 추출하고, count()를 통해 요일별 포스팅 빈도를 분석하여 콘텐츠 게시 전략에 대한 인사이트를 도출합니다.

272. 고객 리뷰 데이터: 텍스트 정제 및 단어 빈도 분석

문제 상황: 당신은 전자상거래 사이트의 데이터 분석가입니다. 고객들이 남긴 제품 리뷰 텍스트를 분석하여 핵심 키워드를 파악하고자 합니다. 이를 위해, 리뷰 텍스트를 단어 단위로 분리하고, 불필요한 단어(불용어)를 제거한 후, 단어 빈도를 계산해야 합니다.

과제 지시 사항:

  1. 주어진 reviews 데이터프레임의 review_text를 모두 소문자로 변환하고, 구두점(마침표, 쉼표 등)을 제거하세요.
  2. 각 리뷰를 단어 단위로 분리하여 'long' 포맷의 데이터(한 행에 한 단어)를 만드세요. (tidyr::unnest_tokens 또는 str_splitunnest 조합 사용)
  3. 주어진 stop_words 벡터에 포함된 단어들(불용어)을 데이터에서 제거하세요.
  4. 남아있는 단어들의 빈도를 계산하여 가장 많이 등장한 상위 10개 단어를 출력하세요.
# 초기 데이터
library(dplyr)
library(stringr)
library(tidyr)

reviews <- tibble(
  review_id = 1:4,
  review_text = c(
    "This product is amazing! I love it, the quality is great.",
    "A very good product. Delivery was fast and the quality is impressive.",
    "Not what I expected. The product is not great, it broke after a week.",
    "I would recommend this to everyone. A great purchase!"
  )
)

stop_words <- c("i", "me", "my", "a", "an", "the", "and", "is", "it", "to", "was", "this", "in", "on", "not")

정답 코드

library(dplyr)
library(stringr)
library(tidyr)
library(tidytext) # unnest_tokens 함수를 위해

# 1. 텍스트 정제 (소문자 변환, 구두점 제거)
reviews_clean <- reviews %>%
  mutate(
    review_text = str_to_lower(review_text),
    review_text = str_replace_all(review_text, "[[:punct:]]", "")
  )

# 2. 단어 단위로 분리 (Tokenization)
word_tokens <- reviews_clean %>%
  unnest_tokens(output = word, input = review_text)

# 3. 불용어(Stop words) 제거
word_tokens_filtered <- word_tokens %>%
  anti_join(tibble(word = stop_words), by = "word")

# 4. 단어 빈도 계산
word_frequency <- word_tokens_filtered %>%
  count(word, sort = TRUE) %>%
  head(10)

print(word_frequency)

해설

이 문제는 자연어 처리(NLP)의 가장 기본적인 단계인 텍스트 전처리 및 단어 빈도 분석(Term Frequency)을 다룹니다. tidytext 패키지는 이러한 작업을 Tidyverse 생태계 안에서 매우 편리하게 수행하도록 돕습니다.

  1. 텍스트 정제:
    • str_to_lower(): 모든 문자를 소문자로 변환하여 "Product"와 "product"가 같은 단어로 취급되도록 합니다.
    • str_replace_all(review_text, "[[:punct:]]", ""): 정규표현식 [[:punct:]]는 모든 구두점 문자를 의미합니다. 이를 빈 문자열("")로 대체하여 제거합니다.
  2. 토큰화 (Tokenization):
    • tidytext::unnest_tokens(output = word, input = review_text): 이 함수는 텍스트 분석의 핵심입니다. review_text 컬럼을 입력받아 텍스트를 단어(기본값) 단위로 쪼갠 후, word라는 새로운 컬럼에 저장하며 데이터를 long 포맷으로 펼쳐줍니다. separate_rows와 유사하지만 텍스트 분석에 최적화되어 있습니다.
  3. 불용어 제거:
    • anti_join(): 두 데이터프레임에서 첫 번째 데이터프레임에만 존재하는 행을 남기는 dplyr의 함수입니다. word_tokens 데이터와 stop_words를 담은 tibbleword 컬럼 기준으로 anti_join하면, word_tokens에서 불용어에 해당하는 단어들이 모두 제거됩니다. 이는 분석에 의미 없는 단어(a, the, is 등)를 걸러내는 중요한 과정입니다.
  4. 빈도 계산: 불용어가 제거된 단어 목록을 대상으로 count() 함수를 실행하면, 리뷰에서 사람들이 가장 많이 언급하는 의미 있는 키워드가 무엇인지 파악할 수 있습니다.

273. IoT 센서 데이터: 시계열 리샘플링 및 결측치 처리

문제 상황: 당신은 스마트 팩토리의 데이터 분석가입니다. 생산 라인의 온도 센서로부터 1분 간격으로 데이터가 수집되지만, 가끔 네트워크 문제로 데이터가 누락되기도 합니다. 안정적인 분석을 위해, 이 데이터를 10분 간격으로 리샘플링(resampling)하고, 누락된 값은 바로 이전 값으로 채워넣어야 합니다.

과제 지시 사항:

  1. 주어진 sensor_datatimestamp를 datetime 객체로 변환하세요.
  2. lubridate::floor_date()를 사용하여 timestamp를 10분 단위로 내림(floor)하여 timestamp_10min 컬럼을 생성하세요.
  3. timestamp_10min 그룹별로 평균 온도를 계산하여 10분 간격 데이터로 요약하세요.
  4. 요약된 데이터에서 누락된 10분 간격이 있다면 이를 채워주고, NA로 표시된 온도는 바로 이전 시간대의 값으로 채우세요. (tidyr::completetidyr::fill 사용)
# 초기 데이터
library(dplyr)
library(lubridate)
library(tidyr)

sensor_data <- tibble(
  timestamp = ymd_hms("2023-11-27 10:00:00") + minutes(c(0, 1, 3, 10, 12, 14, 31, 33)),
  temperature = c(25.1, 25.2, 25.3, 26.0, 26.1, 26.2, 28.5, 28.6)
)

정답 코드

library(dplyr)
library(lubridate)
library(tidyr)

# 1, 2, 3. 10분 단위로 리샘플링 및 평균 계산
resampled_data <- sensor_data %>%
  mutate(timestamp_10min = floor_date(timestamp, "10 minutes")) %>%
  group_by(timestamp_10min) %>%
  summarise(avg_temp = mean(temperature), .groups = 'drop')

# 4. 누락된 시간 간격 채우기 및 결측치 보간
completed_data <- resampled_data %>%
  # timestamp_10min의 전체 시간 시퀀스를 생성하고, 누락된 행을 NA로 채움
  complete(timestamp_10min = seq(min(timestamp_10min), max(timestamp_10min), by = "10 min")) %>%
  # avg_temp 컬럼의 NA 값을 바로 위(이전)의 값으로 채움
  fill(avg_temp, .direction = "down")

print("리샘플링된 데이터:")
print(resampled_data)
print("결측치 처리 완료된 데이터:")
print(completed_data)

해설

이 문제는 시계열 데이터 처리에서 매우 중요한 리샘플링과 결측치 보간(imputation)을 다룹니다.

  1. 리샘플링 (Resampling):
    • lubridate::floor_date(timestamp, "10 minutes"): 이 함수는 시간을 지정된 단위로 '내림'합니다. 예를 들어, 10:01, 10:03은 모두 10:00으로, 10:12, 10:1410:10으로 변환됩니다. 이를 통해 데이터를 더 큰 시간 단위로 그룹화할 수 있습니다. ceiling_date (올림), round_date (반올림)도 있습니다.
    • group_by()summarise()를 사용하여 floor_date로 생성된 그룹별 평균을 계산함으로써, 불규칙한 원본 데이터를 규칙적인 10분 간격 데이터로 요약(리샘플링)합니다.
  2. 결측치 처리:
    • tidyr::complete(): 이 함수는 데이터에 명시적으로 존재하지 않는 그룹 조합을 만들어주는 강력한 도구입니다. complete(timestamp_10min = seq(min(...), max(...), by = "10 min"))는 데이터의 최소 시간부터 최대 시간까지 "10분" 간격의 모든 타임스탬프를 생성하고, 데이터에 원래 없던 타임스탬프에 대해서는 NA로 행을 채워줍니다. (예: 10:20:00이 원본에 없었으므로 NA로 채워진 행이 생성됨)
    • tidyr::fill(): 지정된 컬럼의 NA 값을 비어있지 않은 가장 가까운 값으로 채웁니다.
      • .direction = "down" (기본값): NA를 바로 위(이전)의 값으로 채웁니다.
      • .direction = "up": NA를 바로 아래(이후)의 값으로 채웁니다.
      • 이를 통해 센서 값이 일시적으로 누락되었더라도, 이전 상태가 유지되었다고 가정하고 데이터를 연속적으로 만들어 분석의 안정성을 높일 수 있습니다.

274. 부서별 데이터 통합 및 정제 후 시계열 분석

문제 상황: 당신은 회사의 중앙 데이터팀 소속 분석가입니다. 영업팀과 고객지원팀으로부터 각각 매출 데이터와 고객 문의 데이터를 받았습니다. 두 데이터의 customer_id 형식이 다르고, 데이터 형식이 분석에 적합하지 않습니다. 두 데이터를 통합하고 정제하여, 특정 기간 동안의 '문의당 평균 매출액'을 계산해야 합니다.

과제 지시 사항:

  1. sales_datasale_datetickets_dataticket_date를 date 객체로 변환하세요.
  2. sales_datacustomer_id (예: CUST-101)와 tickets_datacust_id (예: 101) 형식을 통일하여 join이 가능한 공통 ID 컬럼 common_id를 생성하세요.
  3. 두 데이터를 common_id와 날짜를 기준으로 join하세요. (한 고객이 같은 날 여러 번 구매하거나 문의할 수 있음을 고려)
  4. 2023년 11월 한 달 동안, 전체 '총 매출액'을 '총 문의 건수'로 나눈 '문의당 평균 매출액'을 계산하세요.
# 초기 데이터
library(dplyr)
library(stringr)
library(lubridate)

sales_data <- tibble(
  sale_id = 1:5,
  customer_id = c("CUST-101", "CUST-102", "CUST-101", "CUST-103", "CUST-102"),
  sale_amount = c(150, 200, 50, 300, 250),
  sale_date = c("2023.11.05", "2023.11.10", "2023.11.15", "2023.11.20", "2023.12.01")
)

tickets_data <- tibble(
  ticket_id = 101:105,
  cust_id = c(101, 103, 101, 102, 101),
  ticket_date = c("23-11-05", "23-11-19", "23-11-25", "23-12-01", "23-12-05")
)

정답 코드

library(dplyr)
library(stringr)
library(lubridate)

# 1. 날짜 형식 변환
sales_data <- sales_data %>%
  mutate(date = ymd(sale_date))

tickets_data <- tickets_data %>%
  mutate(date = ymd(ticket_date))

# 2. ID 형식 통일
sales_data_clean <- sales_data %>%
  mutate(common_id = as.integer(str_remove(customer_id, "CUST-")))

tickets_data_clean <- tickets_data %>%
  mutate(common_id = cust_id)

# 3. 데이터 통합 (날짜별로 집계 후 join)
sales_by_day <- sales_data_clean %>%
  group_by(date) %>%
  summarise(total_sales = sum(sale_amount))

tickets_by_day <- tickets_data_clean %>%
  group_by(date) %>%
  summarise(total_tickets = n())

# full_join을 사용하여 한 쪽에만 데이터가 있는 날도 포함
combined_data <- full_join(sales_by_day, tickets_by_day, by = "date") %>%
  # join 후 NA는 0으로 처리
  mutate(
    total_sales = ifelse(is.na(total_sales), 0, total_sales),
    total_tickets = ifelse(is.na(total_tickets), 0, total_tickets)
  )

# 4. 2023년 11월 '문의당 평균 매출액' 계산
report <- combined_data %>%
  filter(floor_date(date, "month") == ymd("2023-11-01")) %>%
  summarise(
    grand_total_sales = sum(total_sales),
    grand_total_tickets = sum(total_tickets)
  ) %>%
  mutate(
    avg_sales_per_ticket = grand_total_sales / grand_total_tickets
  )

print(report)

해설

이 문제는 실무에서 빈번하게 발생하는 '서로 다른 출처의 지저분한 데이터 통합' 시나리오를 다룹니다.

  1. 날짜 파싱: lubridate::ymd()는 "2023.11.05"나 "23-11-05"와 같이 구분자가 다르거나 연도가 2자리인 경우에도 똑똑하게 날짜를 인식합니다.
  2. ID 정제:
    • str_remove(customer_id, "CUST-"): sales_datacustomer_id에서 "CUST-" 접두사를 제거하여 숫자 부분만 남깁니다.
    • as.integer(): 문자열로 남은 ID를 정수형으로 변환하여 tickets_datacust_id와 데이터 타입을 일치시킵니다. 이는 join의 안정성을 높입니다.
  3. 데이터 통합 전략:
    • 고객 ID와 날짜를 모두 키로 join할 수도 있지만, 문제의 최종 목표는 '총 매출'과 '총 문의'이므로, 각 데이터를 날짜별로 먼저 집계(group_by(date) %>% summarise(...))하는 것이 더 효율적입니다.
    • full_join(): 두 데이터프레임의 모든 행을 유지하는 join 방식입니다. 특정 날짜에 매출만 있거나 문의만 있는 경우에도 해당 날짜의 정보가 사라지지 않도록 합니다. left_join이나 inner_join을 사용하면 한 쪽에 데이터가 없는 날은 누락될 수 있습니다.
    • ifelse(is.na(...), 0, ...): full_join의 결과로 생긴 NA (해당 날짜에 매출 또는 문의가 없었음)를 0으로 대체하여 이후의 합산 계산에 문제가 없도록 합니다.
  4. 최종 분석:
    • filter(floor_date(date, "month") == ymd("2023-11-01")): floor_date를 사용하여 각 날짜를 해당 월의 1일로 변환합니다. 이를 통해 "2023년 11월에 속하는 모든 날짜"를 간결하게 필터링할 수 있습니다.
    • 필터링된 데이터를 최종적으로 summarise하여 총 매출과 총 문의를 구하고, 이를 나누어 핵심 지표인 '문의당 평균 매출액'을 도출합니다.

275. 화성 탐사 로버 로그 데이터 종합 분석

문제 상황: 당신은 NASA의 화성 탐사 로버 'Perseverance'의 데이터 분석팀원입니다. 로버로부터 전송된 로그 데이터는 타임스탬프, 시스템 상태, 과학 측정값이 혼합된 복잡한 텍스트 형식입니다. 이 로그를 파싱하여 로버의 일일 활동 패턴과 주요 과학적 발견을 요약하는 보고서를 작성해야 합니다.

과제 지시 사항:

  1. 주어진 rover_log 데이터를 정규표현식을 사용하여 sol (화성의 하루 단위), timestamp, system, status_code, details 컬럼으로 파싱하여 데이터프레임을 만드세요.
  2. timestamplubridatehms 함수를 사용하여 시간(period) 객체로 변환하세요.
  3. details 컬럼에서 "Temp: -XX.XC" 형식의 온도 데이터와 "RockType: XXXXX" 형식의 암석 종류 데이터를 추출하여 각각 temperaturerock_type 컬럼을 새로 만드세요.
  4. sol별로 로버의 활동 시간(마지막 로그 시간 - 첫 로그 시간)을 계산하세요.
  5. sol 354 동안 발견된 암석 종류(rock_type)별 개수를 세어보세요.
# 초기 데이터
rover_log <- c(
  "Sol354-08:15:30|SYSTEM|STATUS:OK|Drive sequence initiated.",
  "Sol354-08:45:10|SCIENCE|STATUS:RUNNING|Laser spectroscopy in progress. Temp: -65.5C, RockType: Basalt",
  "Sol354-09:30:00|SYSTEM|STATUS:OK|Drive sequence complete. Reached Waypoint-A.",
  "Sol354-14:20:45|SCIENCE|STATUS:RUNNING|Atmospheric analysis. Temp: -20.1C",
  "Sol354-15:05:00|SCIENCE|STATUS:COMPLETE|Analysis finished. RockType: Mudstone",
  "Sol355-08:20:00|SYSTEM|STATUS:OK|Solar panel alignment.",
  "Sol355-09:00:15|SYSTEM|STATUS:WARN|Dust level high on sensor array.",
  "Sol355-13:30:00|SCIENCE|STATUS:IDLE|Awaiting commands."
)

정답 코드

library(dplyr)
library(tidyr)
library(stringr)
library(lubridate)

# 1. 로그 데이터 파싱
log_df <- tibble(raw_log = rover_log) %>%
  extract(
    raw_log,
    into = c("sol", "timestamp_str", "system", "status_code", "details"),
    regex = "Sol(\\d+)-([\\d:]+)\\|(.*?)\\|STATUS:(.*?)\\|(.*)",
    convert = TRUE # sol을 자동으로 numeric으로 변환
  )

# 2. timestamp 변환 및 3. 세부 정보 추출
parsed_data <- log_df %>%
  mutate(
    timestamp = hms(timestamp_str),
    temperature = as.numeric(str_extract(details, "(?<=Temp: )[-]?\\d+\\.\\d+")),
    rock_type = str_extract(details, "(?<=RockType: )\\w+")
  )

# 4. Sol별 활동 시간 계산
activity_duration <- parsed_data %>%
  group_by(sol) %>%
  summarise(
    first_log = min(timestamp),
    last_log = max(timestamp),
    duration = last_log - first_log # difftime 객체
  ) %>%
  select(sol, duration)

# 5. Sol 354의 암석 종류별 발견 횟수
rock_summary_sol354 <- parsed_data %>%
  filter(sol == 354 & !is.na(rock_type)) %>%
  count(rock_type, name = "count")

print("파싱 및 분석된 데이터:")
print(select(parsed_data, sol, timestamp, system, status_code, temperature, rock_type))
print("Sol별 활동 시간:")
print(activity_duration)
print("Sol 354 암석 발견 요약:")
print(rock_summary_sol354)

해설

이 문제는 지금까지 다룬 모든 기술(문자열 파싱, 정규표현식, 데이터프레임 변환, lubridate 시간 처리, dplyr 집계)을 총망라하는 종합 응용 문제입니다.

  1. 로그 파싱 정규표현식: Sol(\\d+)-([\\d:]+)\\|(.*?)\\|STATUS:(.*?)\\|(.*)
    • Sol(\\d+): "Sol" 뒤에 오는 숫자들을 첫 번째 그룹으로 캡처 (sol).
    • -([\\d:]+): 하이픈 뒤, | 앞에 오는 숫자와 콜론의 조합을 두 번째 그룹으로 캡처 (timestamp_str).
    • \\|(.*?): | 뒤, 다음 | 앞에 오는 모든 문자를 세 번째 그룹으로 캡처 (system). *?는 비탐욕적(non-greedy) 매칭으로, 가장 가까운 다음 패턴에서 멈춥니다.
    • \\|STATUS:(.*?)\\|: "STATUS:" 뒤, 다음 | 앞에 오는 문자들을 네 번째 그룹으로 캡처 (status_code).
    • (.*): 마지막 | 뒤의 모든 문자를 다섯 번째 그룹으로 캡처 (details).
    • convert = TRUE: extract의 유용한 인수로, 캡처된 그룹이 숫자처럼 보이면 자동으로 숫자형으로 변환해줍니다.
  2. 시간 및 세부 정보 처리:
    • hms(): "HH:MM:SS" 형식의 문자열을 lubridate의 'period' 객체로 변환합니다. 날짜 정보가 없으므로 ymd_hms가 아닌 hms를 사용합니다. Period 객체는 덧셈/뺄셈 등 시간 계산에 유용합니다.
    • str_extract와 후방탐색 (?<=...)을 다시 사용하여 details 문자열에서 온도와 암석 종류라는 특정 정보만 정확히 추출합니다.
  3. 활동 시간 계산:
    • group_by(sol)sol별로 데이터를 묶습니다.
    • min(timestamp)max(timestamp)를 사용하여 각 sol의 첫 로그 시간과 마지막 로그 시간을 찾습니다.
    • 두 시간의 차이를 계산하면 difftime 객체로 활동 지속 시간이 나옵니다.
  4. 과학 데이터 요약: filter를 사용하여 분석 대상을 sol == 354rock_typeNA가 아닌 경우로 한정한 뒤, count 함수로 간단하게 암석 종류별 빈도를 계산하여 과학적 발견을 요약합니다. 이 결과는 임무 보고서의 핵심 내용이 될 수 있습니다.

R 프로그래밍 고급 문제 (276 ~ 285)

주제: 고급 통계 및 머신러닝: mlr3 프레임워크 기초, 클러스터링, 분류/회귀 하이퍼파라미터 튜닝


276. mlr3 첫걸음: Task와 Learner, Resampling 정의하기

문제 상황 설명

당신은 최신 머신러닝 프레임워크인 mlr3를 사용하여 붓꽃(iris) 품종 분류 모델을 구축하려는 데이터 과학자입니다. mlr3는 객체 지향적인 설계를 통해 머신러닝 파이프라인을 체계적으로 구성할 수 있도록 돕습니다. 첫 단계로, 데이터를 mlr3가 이해할 수 있는 형태로 변환하고, 사용할 학습 알고리즘(Learner)과 검증 방법(Resampling)을 정의해야 합니다.

과제 지시 사항

  1. mlr3mlr3learners 라이브러리를 로드하세요.
  2. iris 데이터셋을 사용하여 분류(Classification) Task를 생성하세요. Task의 이름(id)은 "iris_classif"로, 타겟 변수는 "Species"로 지정하세요.
  3. 의사결정나무(Decision Tree) 분류기인 "classif.rpart"Learner로 지정하세요.
  4. 3-겹 교차 검증(3-fold Cross-Validation)을 수행할 Resampling 객체를 생성하세요.
  5. 생성된 Task, Learner, Resampling 객체의 기본 정보를 각각 출력하여 확인하세요.

정답 코드

# 필요한 라이브러리 로드
library(mlr3)
library(mlr3learners)

# 1. Task 생성
# Task는 데이터와 메타데이터(타겟 변수, 문제 유형 등)를 캡슐화합니다.
task_iris <- TaskClassif$new(id = "iris_classif",
                             backend = iris,
                             target = "Species")

# 2. Learner 생성
# Learner는 특정 머신러닝 알고리즘을 나타냅니다.
learner_rpart <- lrn("classif.rpart")

# 3. Resampling 전략 생성
# Resampling은 모델 성능을 평가하기 위한 데이터 분할 방법을 정의합니다.
resampling_cv3 <- rsmp("cv", folds = 3)

# 4. 생성된 객체 정보 확인
print(task_iris)
print(learner_rpart)
print(resampling_cv3)

# Task의 일부 데이터 확인
task_iris$head()

해설

이 문제는 mlr3 프레임워크의 가장 기본적인 세 가지 구성 요소인 Task, Learner, Resampling을 이해하고 생성하는 과정을 다룹니다. mlr3는 이 객체들을 조립하여 전체 머신러닝 워크플로우를 구축하는 모듈식 접근 방식을 취합니다.

  1. Task: TaskClassif$new()를 사용하여 분류 문제를 정의합니다.

    • id: Task에 부여하는 고유한 이름입니다.
    • backend: 분석에 사용할 실제 데이터 (iris 데이터프레임)를 지정합니다. mlr3는 대용량 데이터를 효율적으로 처리하기 위해 다양한 백엔드(data.frame, data.table, DB 등)를 지원합니다.
    • target: 예측하고자 하는 종속 변수("Species")를 지정합니다. 문제 유형(분류, 회귀 등)에 따라 TaskClassif, TaskRegr 등 적절한 Task 생성자를 사용해야 합니다.
  2. Learner: lrn() 함수는 mlr3에 등록된 수많은 학습 알고리즘 중 하나를 불러오는 편리한 방법입니다.

    • "classif.rpart"는 분류(classif)를 위한 rpart(Recursive Partitioning and Regression Trees) 알고리즘을 의미합니다. mlr3"문제유형.알고리즘이름" 형식의 명명 규칙을 따릅니다. 사용 가능한 Learner 목록은 as.data.table(mlr_learners) 명령어로 확인할 수 있습니다.
  3. Resampling: rsmp() 함수는 다양한 데이터 분할 및 검증 전략을 생성합니다.

    • "cv"는 교차 검증(Cross-Validation)을 의미합니다.
    • folds = 3는 데이터를 3개의 부분(fold)으로 나누어, 2개는 훈련에, 1개는 검증에 사용하는 과정을 3번 반복하겠다는 의미입니다. 이는 모델의 일반화 성능을 보다 안정적으로 추정하는 데 도움을 줍니다.

이 세 가지 객체를 정의하는 것은 mlr3를 사용한 모든 머신러닝 프로젝트의 출발점이며, 이들을 조합하여 다음 단계인 모델 훈련 및 평가로 나아갈 수 있습니다.


277. mlr3 벤치마킹: 여러 모델 성능 비교하기

문제 상황 설명

당신은 유방암 진단 데이터를 분석하여 악성(malignant)과 양성(benign)을 예측하는 모델을 개발하고 있습니다. mlr3를 사용하여 두 가지 다른 분류 모델, 로지스틱 회귀와 의사결정나무의 성능을 교차 검증을 통해 비교하고, 어떤 모델이 더 나은 성능을 보이는지 객관적인 지표로 평가하고자 합니다.

과제 지시 사항

  1. mlr3verse 패키지를 로드하고, mlbench 패키지의 Sonar 데이터를 사용하세요.
  2. Sonar 데이터를 사용하여 "Class"를 타겟으로 하는 분류 Task를 생성하세요.
  3. 두 개의 Learner를 생성하세요: 로지스틱 회귀("classif.log_reg")와 의사결정나무("classif.rpart").
  4. 분류 정확도("classif.acc")와 AUC("classif.auc")를 평가 지표(Measure)로 설정하세요.
  5. 10-겹 교차 검증(10-fold CV)을 Resampling 전략으로 설정하세요.
  6. benchmark() 함수를 사용하여 위에서 정의한 Task, Learner들, Resampling 전략을 기반으로 벤치마크 실험을 설계하고 실행하세요.
  7. 벤치마크 결과를 집계(aggregate())하여 두 모델의 평균 정확도와 AUC를 비교 분석하세요.

정답 코드

# mlr3 전체 생태계 로드
library(mlr3verse)
library(mlbench)

# 1. 데이터 로드 및 Task 생성
data("Sonar", package = "mlbench")
task_sonar <- TaskClassif$new(id = "Sonar", backend = Sonar, target = "Class")

# 2. Learner 리스트 생성
learners <- list(
  lrn("classif.log_reg", predict_type = "prob"), # AUC 계산을 위해 확률 예측 필요
  lrn("classif.rpart", predict_type = "prob")
)

# 3. Resampling 전략 생성
resampling_cv10 <- rsmp("cv", folds = 10)

# 4. 평가 지표 설정
measures <- list(
  msr("classif.acc"),
  msr("classif.auc")
)

# 5. 벤치마크 설계
design <- benchmark_grid(
  tasks = task_sonar,
  learners = learners,
  resamplings = resampling_cv10
)

# 6. 벤치마크 실행
# set.seed for reproducibility of resampling splits
set.seed(123)
bmr <- benchmark(design)

# 7. 결과 집계 및 분석
# measure 인자를 지정하여 원하는 지표만 집계
aggregated_results <- bmr$aggregate(measures)
print(aggregated_results[, .(learner_id, classif.acc, classif.auc)])

해설

이 문제는 mlr3의 강력한 기능 중 하나인 benchmark를 사용하여 여러 모델의 성능을 체계적으로 비교하는 방법을 다룹니다.

  1. mlr3verse: mlr3, mlr3learners, mlr3vizmlr3 생태계의 핵심 패키지들을 한 번에 로드해주는 편리한 패키지입니다.
  2. predict_type = "prob": Learner를 생성할 때 이 옵션을 추가했습니다. AUC(Area Under the ROC Curve)를 계산하기 위해서는 각 클래스에 속할 확률값이 필요하기 때문입니다. predict_type의 기본값은 "response"로, 가장 확률이 높은 클래스만 예측합니다.
  3. benchmark_grid(): 실험의 모든 조합을 정의하는 함수입니다. tasks, learners, resamplings에 각각 하나 이상의 객체를 리스트 형태로 전달하면 모든 가능한 조합(이 경우, Task 1개 x Learner 2개 x Resampling 1개 = 총 2개의 실험)을 생성해줍니다.
  4. benchmark(): benchmark_grid()로 생성된 실험 설계(design)를 실제로 실행하는 함수입니다. 내부적으로 각 Learner에 대해 10-겹 교차 검증을 수행하고 모든 예측 결과를 저장합니다. set.seed()를 사용하여 교차 검증 시 데이터를 나누는 방식을 고정하여 실험의 재현성을 보장합니다.
  5. bmr$aggregate(): benchmark의 결과 객체(bmr)에는 각 fold에서의 성능이 모두 저장되어 있습니다. aggregate() 메소드는 이 결과들을 집계하여 각 모델의 평균 성능 지표를 계산해줍니다. 결과는 data.table 형식으로 반환되며, 이를 통해 로지스틱 회귀와 의사결정나무 중 어떤 모델이 평균적으로 더 높은 정확도와 AUC를 보이는지 명확하게 비교할 수 있습니다. 이는 모델 선택 과정에서 매우 중요한 근거 자료가 됩니다.

278. mlr3 회귀 분석: 보스턴 주택 가격 예측하기

문제 상황 설명

당신은 부동산 데이터 분석가로, 보스턴 지역의 여러 환경 및 사회경제적 요인을 바탕으로 주택 가격의 중간값(medv)을 예측하는 회귀 모델을 만들고자 합니다. mlr3를 사용하여 선형 회귀 모델과 랜덤 포레스트 회귀 모델의 성능을 비교 평가하려고 합니다.

과제 지시 사항

  1. mlbench 패키지의 BostonHousing 데이터를 사용하세요.
  2. medv를 타겟으로 하는 회귀(Regression) Task를 생성하세요.
  3. 두 개의 Learner를 생성하세요: 선형 회귀("regr.lm")와 랜덤 포레스트("regr.ranger").
  4. 성능 평가 지표로 평균 제곱근 오차(RMSE, Root Mean Squared Error)를 사용하세요. ("regr.rmse")
  5. Holdout(홀드아웃) 방법을 Resampling 전략으로 사용하되, 훈련 데이터와 테스트 데이터를 8:2 비율로 나누세요.
  6. resample() 함수를 사용하여 각 모델을 개별적으로 훈련하고 평가한 후, 각 모델의 RMSE를 출력하세요.
  7. 두 모델의 RMSE를 비교하여 어떤 모델이 주택 가격을 더 잘 예측하는지 설명하세요.

정답 코드

# 필요한 라이브러리 로드
library(mlr3verse)
library(mlbench)
library(ranger) # regr.ranger learner에 필요

# 1. 데이터 로드 및 Task 생성
data("BostonHousing", package = "mlbench")
task_boston <- TaskRegr$new(id = "BostonHousing",
                            backend = BostonHousing,
                            target = "medv")

# 2. Learner 생성
learner_lm <- lrn("regr.lm")
learner_ranger <- lrn("regr.ranger")

# 3. Resampling 전략 생성 (Holdout)
resampling_holdout <- rsmp("holdout", ratio = 0.8)

# 4. 평가 지표 설정
measure_rmse <- msr("regr.rmse")

# 5. 각 모델별로 Resample 실행
# set.seed for reproducibility
set.seed(123)

# 선형 회귀 모델 평가
rr_lm <- resample(task = task_boston,
                  learner = learner_lm,
                  resampling = resampling_holdout)

# 랜덤 포레스트 모델 평가
rr_ranger <- resample(task = task_boston,
                      learner = learner_ranger,
                      resampling = resampling_holdout)

# 6. 결과 확인
cat("Linear Model RMSE:", rr_lm$aggregate(measure_rmse), "\n")
cat("Random Forest RMSE:", rr_ranger$aggregate(measure_rmse), "\n")

# 상세 점수 확인
# rr_ranger$score(measure_rmse)

해설

이 문제는 분류 문제에 이어 mlr3를 회귀 문제에 적용하는 방법을 보여줍니다.

  1. TaskRegr: 회귀 문제를 다루기 때문에 TaskClassif 대신 TaskRegr$new()를 사용합니다. target 변수 역시 연속형 변수인 medv로 지정합니다.
  2. "regr.lm", "regr.ranger": Learner를 지정할 때 문제 유형에 맞게 regr. 접두사가 붙은 회귀 모델을 사용합니다. ranger는 C++로 구현되어 매우 빠른 랜덤 포레스트 알고리즘입니다.
  3. rsmp("holdout", ratio = 0.8): 홀드아웃은 데이터를 한 번만 나누어 훈련과 테스트를 수행하는 가장 간단한 검증 방법입니다. ratio = 0.8은 전체 데이터의 80%를 훈련 세트로 사용하겠다는 의미입니다.
  4. resample(): benchmark()가 여러 실험 조합을 한 번에 실행하는 데 특화되어 있다면, resample()은 단일 Task, 단일 Learner, 단일 Resampling 조합에 대한 평가를 수행하는 데 사용됩니다. 내부적으로는 resampling 전략에 따라 learner를 훈련($train())하고 예측($predict())하는 과정을 반복합니다.
  5. RMSE (Root Mean Squared Error): 회귀 모델의 성능을 평가하는 대표적인 지표입니다. 실제값과 예측값의 차이(오차)를 제곱하여 평균을 낸 후, 다시 제곱근을 취한 값입니다. 오차의 크기를 원래 데이터의 단위와 맞춰주므로 해석이 용이합니다. 수식은 다음과 같습니다. $$ RMSE = \sqrt{\frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2} $$ 여기서 $n$은 샘플의 수, $y_i$는 실제값, $\hat{y}_i$는 모델의 예측값입니다. RMSE는 작을수록 모델의 예측 성능이 좋다는 것을 의미합니다.

실행 결과, 일반적으로 랜덤 포레스트 모델의 RMSE가 선형 모델보다 훨씬 낮게 나타납니다. 이는 랜덤 포레스트가 변수 간의 비선형적이고 복잡한 상호작용을 더 잘 포착할 수 있기 때문이며, 보스턴 주택 가격 데이터의 특성을 더 잘 학습했음을 시사합니다.


279. 게임 유저 성향 분석을 위한 K-평균 클러스터링

문제 상황 설명

당신은 게임 회사 'Cosmic Forge Games'의 데이터 분석가입니다. 최근 출시한 게임의 유저 데이터를 바탕으로 유저들을 몇 개의 그룹으로 나누어 타겟 마케팅 전략을 수립하고자 합니다. 유저 데이터는 '총 플레이 시간(play_time)', '게임 내 재화 사용량(cash_spent)', '소셜 활동 지수(social_index)'로 구성되어 있습니다. K-평균(K-Means) 클러스터링을 사용하여 유저들을 3개의 그룹으로 군집화하고 각 그룹의 특징을 파악해야 합니다.

과제 지시 사항

  1. 아래와 같은 가상의 유저 데이터 data.frame을 생성하세요.
  2. K-평균 클러스터링은 변수들의 스케일에 민감하므로, 분석에 사용할 3개의 변수를 표준화(Standardization) 하세요. (scale() 함수 사용)
  3. 표준화된 데이터를 사용하여 K-평균 클러스터링을 수행하세요. 클러스터의 개수(k)는 3으로 설정하세요.
  4. factoextra 패키지의 fviz_cluster() 함수를 사용하여 클러스터링 결과를 시각화하세요.
  5. 원본 데이터에 클러스터링 결과를 추가하고, 각 클러스터별로 세 변수의 평균값을 계산하여 각 그룹이 어떤 특징을 가진 유저들인지 해석하세요. (예: '헤비 유저 그룹', '과금 유저 그룹' 등)

정답 코드

# 필요한 라이브러리 로드
library(tidyverse)
library(factoextra)

# 1. 가상 데이터 생성
set.seed(42)
user_data <- data.frame(
  user_id = 1:100,
  play_time = rnorm(100, mean = 150, sd = 50),
  cash_spent = rgamma(100, shape = 2, scale = 20),
  social_index = rnorm(100, mean = 50, sd = 15)
)
# 분석에 사용할 데이터 선택
user_features <- user_data[, c("play_time", "cash_spent", "social_index")]

# 2. 데이터 표준화
scaled_features <- scale(user_features)

# 3. K-평균 클러스터링 수행 (k=3)
# nstart: 다른 초기 중심점으로 알고리즘을 여러 번 실행하여 최적의 결과를 찾음
set.seed(42)
kmeans_result <- kmeans(scaled_features, centers = 3, nstart = 25)

# 4. 클러스터링 결과 시각화
fviz_cluster(kmeans_result, data = scaled_features,
             geom = "point",
             ellipse.type = "convex",
             ggtheme = theme_bw())

# 5. 클러스터별 특징 분석
user_data_clustered <- user_data %>%
  mutate(cluster = kmeans_result$cluster)

cluster_summary <- user_data_clustered %>%
  group_by(cluster) %>%
  summarise(
    count = n(),
    avg_play_time = mean(play_time),
    avg_cash_spent = mean(cash_spent),
    avg_social_index = mean(social_index)
  )

print(cluster_summary)

# 해석 예시:
# Cluster 1: play_time이 매우 높고, cash_spent는 낮은 '하드코어 유저'
# Cluster 2: cash_spent가 압도적으로 높고, play_time도 준수한 '핵심 과금 유저 (VIP)'
# Cluster 3: social_index가 높고, 다른 수치는 평균적인 '소셜/캐주얼 유저'

해설

이 문제는 비지도 학습의 대표적인 알고리즘인 K-평균 클러스터링을 실용적인 데이터 분석 시나리오에 적용하는 방법을 다룹니다.

  1. 데이터 표준화의 중요성: K-평균 알고리즘은 유클리드 거리(Euclidean distance)를 기반으로 각 데이터 포인트가 어떤 클러스터 중심(centroid)에 가장 가까운지를 계산합니다. 만약 변수들의 단위나 범위(scale)가 크게 다르면(예: 플레이 시간은 0-500, 재화 사용량은 0-50), 범위가 큰 play_time 변수가 거리 계산을 거의 전적으로 지배하게 되어 cash_spent 변수의 영향력이 무시될 수 있습니다. scale() 함수는 각 변수를 평균이 0, 표준편차가 1인 표준정규분포로 변환(Z-score 표준화)하여 모든 변수가 거리 계산에 동등하게 기여하도록 만듭니다. $$ Z = \frac{X - \mu}{\sigma} $$ 여기서 $X$는 원본 데이터, $\mu$는 평균, $\sigma$는 표준편차입니다.

  2. kmeans() 함수: R의 기본 통계 함수로 K-평균 클러스터링을 수행합니다.

    • centers = 3: 클러스터의 개수(k)를 3으로 지정합니다.
    • nstart = 25: K-평균 알고리즘은 초기 중심점을 무작위로 선택하기 때문에, 어떤 초기값을 선택하느냐에 따라 최종 결과가 달라질 수 있습니다(local minimum 문제). nstart = 25는 무작위 초기 중심점으로 25번 알고리즘을 실행한 후, 클러스터 내 오차 제곱합(within-cluster sum of squares)이 가장 작은 최적의 결과를 반환하라는 의미입니다.
  3. fviz_cluster(): factoextra 패키지는 클러스터링 결과를 매우 쉽게 시각화해주는 강력한 도구입니다. 이 함수는 주성분 분석(PCA)을 이용해 고차원(여기서는 3차원) 데이터를 2차원 평면에 시각화하여 클러스터들이 어떻게 분포하는지 직관적으로 보여줍니다.

  4. 클러스터 해석: 클러스터링의 최종 목표는 단순히 그룹을 나누는 것이 아니라, 각 그룹이 어떤 의미를 갖는지 해석하는 것입니다. dplyr을 사용하여 원본 데이터 기준으로 각 클러스터의 변수별 평균을 계산하면, "1번 클러스터는 플레이 시간은 길지만 돈은 잘 안 쓰는 유저", "2번 클러스터는 돈을 많이 쓰는 VIP 유저"와 같이 비즈니스적으로 의미 있는 인사이트를 도출할 수 있습니다. 이 해석을 바탕으로 각 그룹에 맞는 맞춤형 이벤트나 프로모션을 기획할 수 있습니다.


280. 최적의 클러스터 개수(k) 찾기: 엘보우 및 실루엣 방법

문제 상황 설명

279번 문제에 이어, 당신은 유저들을 3개의 그룹으로 나눈 것이 과연 최적의 선택이었는지 의문을 갖게 되었습니다. 클러스터의 개수(k)를 결정하는 것은 K-평균 클러스터링에서 가장 중요한 단계 중 하나입니다. 당신은 데이터에 기반한 객관적인 방법으로 최적의 k를 찾기 위해 엘보우(Elbow) 방법과 실루엣(Silhouette) 방법을 사용하기로 했습니다.

과제 지시 사항

  1. 279번 문제에서 사용한 표준화된 유저 데이터(scaled_features)를 그대로 사용하세요.
  2. factoextra 패키지의 fviz_nbclust() 함수를 사용하여 엘보우 방법을 시각화하고, 그래프에서 '엘보우(팔꿈치)'에 해당하는 지점을 찾아 최적의 k를 추정하세요. (검증 방법 method = "wss")
  3. 같은 함수를 사용하여 실루엣 방법을 시각화하고, 평균 실루엣 너비가 가장 큰 지점을 찾아 최적의 k를 추정하세요. (검증 방법 method = "silhouette")
  4. 두 방법의 결과를 종합하여, 이 데이터에 가장 적합하다고 생각되는 클러스터 개수(k)를 선택하고 그 이유를 설명하세요.

정답 코드

# 279번 문제의 데이터와 전처리를 그대로 사용한다고 가정
# 필요한 라이브러리 로드
library(factoextra)
library(cluster) # 실루엣 계산에 필요할 수 있음

# 279번 코드에서 생성된 scaled_features가 있다고 가정
# 만약 없다면 아래 코드 실행
set.seed(42)
user_data <- data.frame(
  user_id = 1:100,
  play_time = rnorm(100, mean = 150, sd = 50),
  cash_spent = rgamma(100, shape = 2, scale = 20),
  social_index = rnorm(100, mean = 50, sd = 15)
)
user_features <- user_data[, c("play_time", "cash_spent", "social_index")]
scaled_features <- scale(user_features)

# 1. 엘보우 방법 (Elbow Method)
# WSS: Within-cluster Sum of Squares
set.seed(42)
p_elbow <- fviz_nbclust(scaled_features, kmeans, method = "wss") +
  labs(subtitle = "Elbow Method")
print(p_elbow)

# 2. 실루엣 방법 (Silhouette Method)
set.seed(42)
p_silhouette <- fviz_nbclust(scaled_features, kmeans, method = "silhouette") +
  labs(subtitle = "Silhouette Method")
print(p_silhouette)

# 3. 결과 해석
# 엘보우 방법: 그래프가 팔꿈치처럼 급격히 꺾이는 지점을 찾습니다.
# 이 지점 이후로는 클러스터 개수를 늘려도 WSS 감소폭이 둔화되므로,
# 비용-효율적인 k로 간주할 수 있습니다.
#
# 실루엣 방법: 평균 실루엣 너비가 최대가 되는 지점을 최적의 k로 선택합니다.
# 실루엣 계수는 클러스터 내 응집도와 클러스터 간 분리도를 모두 고려한 지표입니다.

해설

이 문제는 K-평균 클러스터링의 핵심 과제인 최적의 클러스터 개수 $k$를 결정하는 두 가지 대표적인 방법을 다룹니다.

  1. 엘보우 방법 (Elbow Method):

    • 개념: 클러스터의 개수 $k$를 1부터 점차 늘려가면서, 각 $k$에 대한 클러스터 내 총 제곱합(Total Within-cluster Sum of Squares, WSS)을 계산하여 그래프로 나타내는 방법입니다.
    • WSS의 수식: $$ WSS = \sum_{j=1}^{k} \sum_{i \in C_j} ||x_i - \mu_j||^2 $$ 여기서 $k$는 클러스터 개수, $C_j$$j$번째 클러스터에 속한 데이터 포인트 집합, $x_i$는 데이터 포인트, $\mu_j$$j$번째 클러스터의 중심(centroid)입니다. WSS는 클러스터 내 데이터들이 얼마나 촘촘하게 모여있는지(응집도)를 나타내는 지표로, 작을수록 좋습니다.
    • 해석: $k$가 증가하면 WSS는 항상 감소합니다. 하지만 $k$가 최적 지점을 지나면 WSS의 감소율이 급격히 둔화됩니다. 이 지점이 그래프 상에서 마치 팔꿈치(Elbow)처럼 꺾인 모양을 보이기 때문에 엘보우 방법이라고 부릅니다. 이 "팔꿈치" 지점이 비용(클러스터 개수) 대비 효율(WSS 감소)이 가장 좋은 지점으로 해석됩니다. fviz_nbclust는 이 과정을 자동화하여 시각화해줍니다.
  2. 실루엣 방법 (Silhouette Method):

    • 개념: 각 데이터 포인트가 자신이 속한 클러스터에 얼마나 잘 속해 있고, 다른 클러스터와는 얼마나 잘 구분되는지를 측정하는 방법입니다.
    • 실루엣 계수($s(i)$) 계산: 데이터 포인트 $i$에 대해,
      • $a(i)$: $i$와 자신이 속한 클러스터 내의 다른 모든 점들 간의 평균 거리 (응집도). 작을수록 좋습니다.
      • $b(i)$: $i$와 가장 가까운 다른 클러스터(neighboring cluster) 내의 모든 점들 간의 평균 거리 (분리도). 클수록 좋습니다.
      • 실루엣 계수 $s(i) = \frac{b(i) - a(i)}{\max(a(i), b(i))}$. 이 값은 -1에서 1 사이의 값을 가집니다.
    • 해석:
      • $s(i) \approx 1$: $i$가 자신의 클러스터에 매우 잘 속해 있음을 의미.
      • $s(i) \approx 0$: $i$가 클러스터 경계에 위치함을 의미.
      • $s(i) \approx -1$: $i$가 잘못된 클러스터에 할당되었을 가능성이 높음을 의미.
    • 최적의 k: 모든 데이터의 실루엣 계수 평균값이 가장 커지는 $k$를 최적의 클러스터 개수로 선택합니다. 엘보우 방법보다 계산은 복잡하지만, 클러스터의 응집도와 분리도를 모두 고려하므로 더 신뢰성 있는 기준으로 여겨지는 경우가 많습니다.

fviz_nbclust 함수는 이 두 가지 방법을 매우 간단하게 시각화해주므로, 데이터 과학자는 그래프를 보고 직관적으로 최적의 $k$를 판단할 수 있습니다. 두 방법이 항상 동일한 $k$를 제시하지는 않으므로, 분석가는 두 결과를 종합하고 도메인 지식을 활용하여 최종 $k$를 결정해야 합니다.


281. 고객 세분화를 위한 계층적 클러스터링과 덴드로그램

문제 상황 설명

당신은 한 온라인 쇼핑몰의 마케팅 팀 소속 데이터 분석가입니다. 최근 1년간의 VIP 고객 데이터를 바탕으로 고객들을 세분화하려고 합니다. 데이터는 '연간 총 구매액(total_purchase)'과 '웹사이트 평균 체류 시간(avg_session_time)'으로 구성되어 있습니다. K-평균 클러스터링과 달리, 클러스터의 개수를 미리 정하지 않고 데이터의 구조를 탐색적으로 확인하기 위해 계층적 클러스터링(Hierarchical Clustering)을 사용하고자 합니다.

과제 지시 사항

  1. 아래와 같은 가상의 VIP 고객 데이터를 생성하세요.
  2. 데이터를 표준화하고, dist() 함수를 사용하여 데이터 포인트 간의 유클리드 거리를 계산한 거리 행렬(distance matrix)을 만드세요.
  3. hclust() 함수를 사용하여 '완전 연결법(complete linkage)'을 기준으로 응집형(agglomerative) 계층적 클러스터링을 수행하세요.
  4. 결과를 덴드로그램(Dendrogram)으로 시각화하세요.
  5. 덴드로그램을 보고, 적절한 높이에서 잘라(cut) 3개의 클러스터를 생성하세요. cutree() 함수를 사용하고, 원본 데이터에 클러스터 결과를 병합하여 각 클러스터의 특징을 분석하세요.

정답 코드

# 필요한 라이브러리 로드
library(tidyverse)
library(ggdendro)

# 1. 가상 데이터 생성
set.seed(101)
vip_customers <- data.frame(
  customer_id = 1:50,
  total_purchase = rnorm(50, mean = 5000, sd = 1200),
  avg_session_time = rnorm(50, mean = 30, sd = 8)
)
customer_features <- vip_customers[, -1]
rownames(customer_features) <- vip_customers$customer_id # 시각화를 위해 ID 부여

# 2. 데이터 표준화 및 거리 행렬 계산
scaled_features <- scale(customer_features)
dist_matrix <- dist(scaled_features, method = "euclidean")

# 3. 계층적 클러스터링 수행
hclust_result <- hclust(dist_matrix, method = "complete")

# 4. 덴드로그램 시각화 (기본 plot)
plot(hclust_result, cex = 0.6, hang = -1, main = "Dendrogram of VIP Customers")
# 원하는 클러스터 개수에 맞춰 사각형 그리기
rect.hclust(hclust_result, k = 3, border = 2:4)

# (선택) ggplot2를 이용한 더 미려한 시각화
ggdendrogram(hclust_result, rotate = TRUE, theme_dendro = FALSE) +
    labs(title="Dendrogram using ggplot2")

# 5. 클러스터 생성 및 분석
# 덴드로그램에서 높이(height)를 기준으로 자를 수도 있고, k를 지정할 수도 있음
clusters <- cutree(hclust_result, k = 3)

vip_customers_clustered <- vip_customers %>%
  mutate(cluster = clusters)

cluster_summary <- vip_customers_clustered %>%
  group_by(cluster) %>%
  summarise(
    count = n(),
    avg_purchase = mean(total_purchase),
    avg_session = mean(avg_session_time)
  )

print(cluster_summary)

해설

이 문제는 또 다른 주요 클러스터링 기법인 계층적 클러스터링을 다룹니다.

  1. 계층적 클러스터링 (Hierarchical Clustering):

    • K-평균과 달리 클러스터 개수 $k$를 사전에 지정할 필요가 없습니다.
    • 응집형(Agglomerative): 모든 데이터 포인트를 각각 하나의 클러스터로 시작하여, 가장 가까운 클러스터들을 점차적으로 병합해나가 최종적으로 하나의 큰 클러스터가 될 때까지 반복하는 방식입니다. (Bottom-up)
    • 분리형(Divisive): 하나의 큰 클러스터에서 시작하여, 가장 이질적인 그룹으로 점차 분리해나가는 방식입니다. (Top-down) 일반적으로 응집형이 더 많이 사용됩니다.
  2. 거리 행렬과 연결법(Linkage Method):

    • dist(): 클러스터링의 첫 단계는 모든 데이터 포인트 쌍 간의 거리를 계산하는 것입니다. method = "euclidean"은 유클리드 거리를 사용하겠다는 의미입니다.
    • hclust(method = "complete"): 클러스터 간의 거리를 어떻게 정의할지를 결정하는 것이 '연결법'입니다.
      • 완전 연결법(Complete Linkage): 두 클러스터에 속한 데이터 포인트 쌍들 중 가장 거리를 두 클러스터 간의 거리로 정의합니다. 군집이 구형에 가까운 형태를 띠도록 만듭니다.
      • 단일 연결법(Single Linkage): 가장 가까운 거리를 사용합니다. 길게 늘어선 형태의 군집도 잘 찾아내지만, 노이즈에 민감할 수 있습니다.
      • 평균 연결법(Average Linkage): 모든 데이터 포인트 쌍들의 평균 거리를 사용합니다.
      • Ward 연결법(Ward's Method): 두 클러스터를 병합했을 때 클러스터 내 분산(variance) 증가량을 최소화하는 방향으로 병합합니다. 일반적으로 성능이 좋다고 알려져 있습니다.
  3. 덴드로그램 (Dendrogram):

    • 계층적 클러스터링의 병합 과정을 시각적으로 표현한 나무 구조의 그림입니다.
    • 가로축은 데이터 포인트를, 세로축은 클러스터가 병합될 때의 거리(또는 비유사도)를 나타냅니다.
    • 분석가는 덴드로그램을 보고 데이터의 구조를 파악할 수 있습니다. 세로축의 거리가 긴 구간에서 덴드로그램을 수평으로 자르면, 그 선과 교차하는 가지의 수가 클러스터의 개수가 됩니다. 이는 시각적으로 최적의 클러스터 개수를 탐색하는 데 도움을 줍니다.
  4. cutree(): hclust 객체와 원하는 클러스터 개수 k (또는 자를 높이 h)를 입력받아 각 데이터 포인트가 어떤 클러스터에 속하는지를 나타내는 벡터를 반환합니다. 이 결과를 원본 데이터와 결합하여 각 클러스터의 특성을 분석할 수 있습니다.


282. mlr3 하이퍼파라미터 튜닝 기초: 그리드 탐색 (Grid Search)

문제 상황 설명

당신은 mlr3를 사용하여 타이타닉 생존자 예측 모델을 만들고 있습니다. 의사결정나무(rpart) 모델을 사용하기로 했는데, 모델의 성능은 cp(Complexity Parameter)나 minsplit(분기를 위한 최소 샘플 수) 같은 하이퍼파라미터 값에 따라 크게 달라집니다. 최적의 하이퍼파라미터 조합을 찾기 위해 가장 기본적인 튜닝 방법인 그리드 탐색을 수행하고자 합니다.

과제 지시 사항

  1. mlr3verse, mlr3tuning 패키지와 titanic 데이터를 로드하세요. 데이터 전처리를 간단히 수행합니다.
  2. titanic 데이터를 사용하여 생존 여부(Survived)를 예측하는 분류 Task를 생성하세요.
  3. "classif.rpart" Learner를 생성하세요.
  4. 튜닝할 하이퍼파라미터의 탐색 공간(SearchSpace)을 정의하세요.
    • cp: 0.01, 0.05, 0.1 세 가지 값을 탐색
    • minsplit: 5, 10, 20 세 가지 값을 탐색
  5. 튜닝 전략으로 그리드 탐색("grid_search")을, 성능 평가 지표로 분류 정확도("classif.acc")를, 검증 방법으로 3-겹 교차 검증("cv", folds=3)을 사용하세요.
  6. tune() 함수를 사용하여 하이퍼파라미터 튜닝을 실행하고, 최적의 하이퍼파라미터 조합과 그 때의 성능을 출력하세요.

정답 코드

# 필요한 라이브러리 로드
library(mlr3verse)
library(mlr3tuning)
library(titanic) # 데이터셋
library(paradox) # SearchSpace 정의

# 1. 데이터 로드 및 전처리
titanic_data <- as.data.frame(titanic_train)
titanic_data$Survived <- as.factor(titanic_data$Survived)
titanic_data <- titanic_data[, c("Survived", "Pclass", "Sex", "Age", "SibSp", "Parch", "Fare")]
# 결측치 간단히 처리
titanic_data$Age[is.na(titanic_data$Age)] <- median(titanic_data$Age, na.rm = TRUE)

# 2. Task 생성
task_titanic <- TaskClassif$new(id = "titanic", backend = titanic_data, target = "Survived")

# 3. Learner 생성
learner_rpart <- lrn("classif.rpart")

# 4. 탐색 공간(SearchSpace) 정의
search_space <- ps(
  cp = p_dbl(lower = 0.001, upper = 0.1),
  minsplit = p_int(lower = 1, upper = 20)
)
# 그리드 탐색을 위한 SearchSpace 설정
# 이 방법 대신 아래 tune 함수에서 resolution을 지정하는 것이 더 일반적임
# 하지만 여기서는 명시적으로 값을 지정하는 그리드 탐색을 보여줌
search_space_grid <- ps(
  cp = p_fct(c(0.01, 0.05, 0.1)),
  minsplit = p_fct(c(5, 10, 20))
)

# 5. 튜닝 인스턴스(TuningInstanceSingleCrit) 설정
instance <- TuningInstanceSingleCrit$new(
  task = task_titanic,
  learner = learner_rpart,
  resampling = rsmp("cv", folds = 3),
  measure = msr("classif.acc"),
  search_space = search_space_grid,
  terminator = trm("evals", n_evals = 9) # 3(cp) * 3(minsplit) = 9
)

# 튜너(Tuner) 설정
tuner_grid <- tnr("grid_search", resolution = 3) # 이 방법이 더 선호됨

# 튜닝 실행
set.seed(123)
tuner_grid$optimize(instance)

# 6. 최적 결과 확인
cat("Best Hyperparameters:\n")
print(instance$result_learner_param_vals)

cat("\nBest Performance (Accuracy):\n")
print(instance$result_y)

# 튜닝 아카이브 확인
# instance$archive$data

해설

이 문제는 mlr3tuning 패키지를 사용하여 머신러닝 모델의 성능을 극대화하는 핵심 과정인 하이퍼파라미터 튜닝의 기초를 다룹니다.

  1. 하이퍼파라미터(Hyperparameter): 모델이 학습을 시작하기 전에 사용자가 직접 설정해야 하는 값입니다. 예를 들어, 의사결정나무의 cp는 트리의 복잡도를 제어하고, minsplit은 노드를 분기하기 위한 최소 데이터 수를 결정합니다. 이 값들은 모델의 과적합(overfitting)과 과소적합(underfitting)에 큰 영향을 미칩니다.

  2. paradox 패키지와 SearchSpace: mlr3 생태계에서 하이퍼파라미터의 탐색 범위를 정의하는 역할을 합니다.

    • ps(): 탐색 공간(Parameter Set)을 생성하는 함수입니다.
    • p_dbl(): 연속형(double) 파라미터의 범위를 지정합니다. lower, upper로 최소/최대값을 설정합니다.
    • p_int(): 정수형(integer) 파라미터의 범위를 지정합니다.
    • p_fct(): 범주형(factor) 파라미터로, 탐색할 값들을 직접 나열합니다. 그리드 탐색에서는 이처럼 명시적인 값들을 지정하는 것이 일반적입니다.
  3. TuningInstanceSingleCrit: 튜닝에 필요한 모든 요소(Task, Learner, Resampling, Measure, SearchSpace, Terminator)를 하나로 묶는 객체입니다. SingleCrit은 단일 평가 지표(classif.acc)를 기준으로 최적화를 수행한다는 의미입니다.

  4. Terminator: 튜닝 과정을 언제 멈출지를 결정하는 조건입니다. trm("evals", n_evals = 9)는 총 9번의 하이퍼파라미터 조합을 평가하면 튜닝을 종료하라는 의미입니다. 그리드 탐색에서는 탐색할 점의 개수(3x3=9)와 정확히 일치합니다.

  5. Tuner와 그리드 탐색 ("grid_search"):

    • tnr(): 실제 튜닝을 수행할 알고리즘(튜너)을 지정합니다.
    • 그리드 탐색은 사용자가 지정한 모든 하이퍼파라미터 값의 조합을 하나도 빠짐없이 모두 평가하는 방법입니다. 간단하고 결과를 이해하기 쉽지만, 탐색할 파라미터가 많아지거나 값의 개수가 늘어나면 계산 비용이 기하급수적으로 증가하는 단점이 있습니다.
  6. $optimize(instance): 이 메소드를 호출하면 튜너가 튜닝 인스턴스의 설정에 따라 최적화 과정을 시작합니다. 과정이 끝나면 instance 객체 내에 최적의 파라미터 조합($result_learner_param_vals)과 그 때의 성능($result_y)이 저장됩니다.


283. 효율적인 하이퍼파라미터 튜닝: 랜덤 탐색 (Random Search)

문제 상황 설명

그리드 탐색은 모든 조합을 평가하기 때문에 비효율적일 수 있습니다. 특히 중요하지 않은 하이퍼파라미터에 많은 시간을 낭비할 수 있습니다. 당신은 랜덤 포레스트(ranger) 모델의 하이퍼파라미터를 튜닝하려고 하는데, 튜닝할 파라미터가 여러 개(num.trees, mtry, min.node.size)입니다. 제한된 시간 안에 합리적인 수준의 최적해를 찾기 위해 랜덤 탐색을 사용하기로 결정했습니다.

과제 지시 사항

  1. 독일 신용 평가 데이터(mlbenchGermanCredit)를 사용하세요. Class를 예측하는 분류 Task를 생성합니다.
  2. "classif.ranger" Learner를 생성하고, AUC 계산을 위해 predict_type"prob"로 설정하세요.
  3. 튜닝할 하이퍼파라미터의 탐색 공간을 정의하세요.
    • num.trees: 50에서 500 사이의 정수
    • mtry: 1에서 10 사이의 정수
    • min.node.size: 1에서 10 사이의 정수
  4. 튜닝 전략으로 랜덤 탐색("random_search")을, 성능 평가 지표로 AUC("classif.auc")를, 검증 방법으로 홀드아웃("holdout", 80% 훈련)을 사용하세요.
  5. 총 20번의 평가(n_evals = 20)를 수행하도록 Terminator를 설정하고 튜닝을 실행하세요.
  6. 최적의 하이퍼파라미터 조합과 그 때의 AUC 값을 출력하고, 그리드 탐색과 비교하여 랜덤 탐색의 장점을 설명하세요.

정답 코드

# 필요한 라이브러리 로드
library(mlr3verse)
library(mlr3tuning)
library(mlbench)
library(paradox)

# 1. 데이터 로드 및 Task 생성
data("GermanCredit", package = "mlbench")
# mlr3는 factor형 target을 요구하므로 변환
GermanCredit$Class <- as.factor(GermanCredit$Class)
task_credit <- TaskClassif$new(id = "GermanCredit", backend = GermanCredit, target = "Class")

# 2. Learner 생성
learner_ranger <- lrn("classif.ranger", predict_type = "prob")

# 3. 탐색 공간(SearchSpace) 정의
search_space <- ps(
  num.trees = p_int(lower = 50, upper = 500),
  mtry = p_int(lower = 1, upper = 10),
  min.node.size = p_int(lower = 1, upper = 10)
)

# 4. 튜닝 인스턴스 설정
instance <- TuningInstanceSingleCrit$new(
  task = task_credit,
  learner = learner_ranger,
  resampling = rsmp("holdout", ratio = 0.8),
  measure = msr("classif.auc"),
  search_space = search_space,
  terminator = trm("evals", n_evals = 20)
)

# 5. 튜너 설정 및 튜닝 실행
tuner_random <- tnr("random_search")

set.seed(123)
tuner_random$optimize(instance)

# 6. 최적 결과 확인
cat("Best Hyperparameters (Random Search):\n")
print(instance$result_learner_param_vals)

cat("\nBest Performance (AUC):\n")
print(instance$result_y)

해설

이 문제는 그리드 탐색보다 효율적인 하이퍼파라미터 튜닝 방법인 랜덤 탐색을 다룹니다.

  1. 랜덤 탐색 (Random Search):

    • 그리드 탐색처럼 모든 조합을 평가하는 대신, 사용자가 정의한 탐색 공간 내에서 지정된 횟수(n_evals)만큼 하이퍼파라미터 조합을 무작위로 샘플링하여 평가하는 방법입니다.
    • 장점:
      • 효율성: 그리드 탐색에 비해 훨씬 적은 평가 횟수로도 좋은 성능을 내는 조합을 찾을 확률이 높습니다. 이는 모델 성능에 영향을 미치는 중요한 파라미터와 그렇지 않은 파라미터가 섞여 있을 때 더욱 두드러집니다. 랜덤 탐색은 모든 파라미터에 대해 다양한 값을 탐색할 기회를 갖지만, 그리드 탐색은 중요하지 않은 파라미터의 몇몇 값에 고정되어 많은 평가를 낭비할 수 있습니다.
      • 유연성: 튜닝에 사용할 수 있는 시간이나 계산 리소스에 맞춰 평가 횟수(n_evals)를 자유롭게 조절할 수 있습니다.
    • 단점: 무작위로 탐색하기 때문에 최적해를 찾을 것이라는 보장은 없으며, 실행할 때마다 결과가 달라질 수 있습니다(재현을 위해 set.seed() 사용이 필수적입니다).
  2. 탐색 공간 설정:

    • 랜덤 탐색에서는 p_dbl이나 p_int를 사용하여 탐색할 값의 '범위'를 지정하는 것이 일반적입니다. 튜너는 이 범위 내에서 무작위로 값을 추출합니다. 이는 특정 값들만 시도하는 그리드 탐색과의 큰 차이점입니다.
  3. 튜닝 프로세스:

    • TuningInstanceSingleCrit를 설정하는 과정은 그리드 탐색과 동일합니다.
    • tnr("random_search")를 통해 튜너만 변경해주면 mlr3가 알아서 랜덤 탐색을 수행합니다.
    • terminator = trm("evals", n_evals = 20)는 전체 탐색 공간의 크기와 무관하게, 딱 20번만 시도하고 멈추라는 의미입니다. 이는 계산 예산을 제어하는 데 매우 유용합니다.

랜덤 탐색은 실제로 많은 데이터 과학자들이 선호하는 실용적인 튜닝 방법이며, 복잡한 모델이나 많은 하이퍼파라미터를 가진 모델을 다룰 때 그리드 탐색보다 훨씬 효과적인 대안이 될 수 있습니다.


284. 지능적인 하이퍼파라미터 튜닝: 베이지안 최적화 (Bayesian Optimization)

문제 상황 설명

당신은 XGBoost와 같이 훈련 시간이 오래 걸리는 모델의 하이퍼파라미터를 튜닝해야 합니다. 그리드 탐색이나 랜덤 탐색은 이전 평가 결과를 전혀 활용하지 않고 다음 탐색 지점을 결정하기 때문에, 한 번 평가하는 데 비용이 많이 드는 모델에는 비효율적입니다. 당신은 이전 평가 결과를 바탕으로 다음 탐색할 만한 '유망한' 지점을 '지능적으로' 찾아나서는 베이지안 최적화를 사용하기로 했습니다.

과제 지시 사항

  1. mlr3mboxgboost 패키지를 설치하고 로드하세요. mlr3mbomlr3에서 베이지안 최적화를 구현하는 패키지입니다.
  2. mlr3에 내장된 pima (피마 인디언 당뇨병) 데이터셋을 사용하세요. diabetes를 예측하는 분류 Task를 생성합니다.
  3. "classif.xgboost" Learner를 생성하고, predict_type"prob"로 설정하세요.
  4. 튜닝할 하이퍼파라미터의 탐색 공간을 정의하세요.
    • eta (learning rate): 0.01에서 0.3 사이의 실수
    • max_depth: 3에서 10 사이의 정수
    • nrounds: 50에서 200 사이의 정수
  5. 튜너로 베이지안 최적화("mbo")를, 평가 지표로 AUC("classif.auc")를, 검증 방법으로 3-겹 교차 검증을 사용하세요.
  6. 총 15번의 평가를 수행하도록 설정하고 튜닝을 실행한 후, 최적 결과를 출력하세요.
  7. 베이지안 최적화가 랜덤 탐색과 어떻게 다른지, 특히 '대리 모델(Surrogate Model)'과 '획득 함수(Acquisition Function)'의 개념을 사용하여 설명하세요.

정답 코드

# 필요한 라이브러리 설치 및 로드
# install.packages("mlr3mbo")
# install.packages("xgboost")
library(mlr3verse)
library(mlr3tuning)
library(mlr3mbo) # 베이지안 최적화
library(xgboost)
library(paradox)

# 1. Task 생성
task_pima <- tsk("pima")

# 2. Learner 생성
learner_xgb <- lrn("classif.xgboost", predict_type = "prob")

# 3. 탐색 공간(SearchSpace) 정의
search_space <- ps(
  eta = p_dbl(lower = 0.01, upper = 0.3),
  max_depth = p_int(lower = 3, upper = 10),
  nrounds = p_int(lower = 50, upper = 200)
)

# 4. 튜닝 인스턴스 설정
instance <- TuningInstanceSingleCrit$new(
  task = task_pima,
  learner = learner_xgb,
  resampling = rsmp("cv", folds = 3),
  measure = msr("classif.auc"),
  search_space = search_space,
  terminator = trm("evals", n_evals = 15)
)

# 5. 튜너 설정 및 튜닝 실행
tuner_mbo <- tnr("mbo")

set.seed(123)
tuner_mbo$optimize(instance)

# 6. 최적 결과 확인
cat("Best Hyperparameters (Bayesian Optimization):\n")
print(instance$result_learner_param_vals)

cat("\nBest Performance (AUC):\n")
print(instance$result_y)

해설

이 문제는 랜덤 탐색보다 한 단계 더 발전된, 지능적인 탐색 기법인 베이지안 최적화를 다룹니다.

베이지안 최적화 (Bayesian Optimization) 는 평가 비용이 높은 블랙박스 함수(black-box function, 여기서는 하이퍼파라미터 조합을 입력하면 모델 성능을 출력하는 함수)의 최댓값 또는 최솟값을 찾는 데 사용되는 강력한 최적화 기법입니다. 랜덤 탐색과 근본적인 차이는 '과거의 정보를 활용한다' 는 점에 있습니다. 이는 두 가지 핵심 요소로 구성됩니다.

  1. 대리 모델 (Surrogate Model):

    • 실제 목표 함수(하이퍼파라미터-성능 관계)는 평가 비용이 매우 비쌉니다. 따라서 베이지안 최적화는 이 비싼 함수를 근사하는 간단한 확률 모델을 만드는데, 이를 '대리 모델'이라고 합니다.
    • 주로 가우시안 프로세스(Gaussian Process)가 사용됩니다. 이 모델은 지금까지 관측된 점들 (평가된 하이퍼파라미터 조합과 그 성능)을 바탕으로, 아직 평가해보지 않은 지점의 성능이 어떠한 분포를 가질지 (평균과 불확실성)를 예측합니다.
  2. 획득 함수 (Acquisition Function):

    • 대리 모델의 예측(평균과 불확실성)을 바탕으로, '다음 번에 어떤 하이퍼파라미터 조합을 평가하는 것이 가장 이득일까?'를 결정하는 함수입니다.
    • 획득 함수는 두 가지 전략 사이에서 균형을 맞춥니다.
      • 탐색 (Exploration): 아직 평가해보지 않은, 불확실성이 높은 영역을 탐색합니다. 예상치 못한 '대박'을 찾으려는 시도입니다.
      • 활용 (Exploitation): 지금까지 관측된 결과 중 성능이 좋았던 영역 주변을 더 집중적으로 탐색합니다. 현재의 최고점을 개선하려는 시도입니다.
    • 대표적인 획득 함수로는 EI(Expected Improvement), UCB(Upper Confidence Bound) 등이 있습니다. mlr3mbo는 이 과정을 자동으로 처리해줍니다.

전체 과정:

  1. 초기에 몇 개의 점을 무작위로 평가합니다.
  2. 평가된 점들을 바탕으로 대리 모델을 구축합니다.
  3. 획득 함수를 최대화하는 다음 탐색 지점을 찾습니다.
  4. 해당 지점에서 실제 목표 함수(모델 성능)를 평가합니다.
  5. 새로운 평가 결과를 기존 데이터에 추가하여 대리 모델을 업데이트합니다.
  6. 지정된 평가 횟수(n_evals)가 다할 때까지 3-5번 과정을 반복합니다.

이러한 지능적인 접근 방식 덕분에, 베이지안 최적화는 랜덤 탐색보다 훨씬 적은 평가 횟수로도 더 좋은 하이퍼파라미터 조합을 찾아낼 수 있으며, 특히 XGBoost, 딥러닝 모델처럼 한 번 훈련하는 데 수 분에서 수 시간이 걸리는 모델의 튜닝에 매우 효과적입니다.


285. mlr3 최종 실습: AutoTuner를 이용한 자동화된 모델 튜닝 파이프라인

문제 상황 설명

당신은 이제 mlr3Task, Learner, Resampling, Tuning 개념에 익숙해졌습니다. 이제는 이 모든 것을 하나로 묶어, 마치 하나의 Learner처럼 작동하는 '자동 튜닝되는 학습기'를 만들고 싶습니다. mlr3AutoTuner는 이러한 작업을 매우 우아하게 처리해줍니다. AutoTuner를 사용하여 서포트 벡터 머신(SVM) 모델을 자동으로 튜닝하고, 튜닝되지 않은 기본 모델과 성능을 비교하는 전체 파이프라인을 구축해 보겠습니다.

과제 지시 사항

  1. mlr3verse, mlr3tuning, e1071 패키지를 로드하고, mlr3의 내장 데이터셋인 spam을 사용하세요.
  2. spam 데이터를 사용하여 type을 예측하는 분류 Task를 생성합니다.
  3. "classif.svm" Learner를 생성합니다.
  4. SVM의 주요 하이퍼파라미터인 costgamma에 대한 탐색 공간을 정의하세요.
    • cost: $10^{-3}$에서 $10^3$ 사이의 로그 스케일 실수
    • gamma: $10^{-3}$에서 $10^3$ 사이의 로그 스케일 실수
  5. AutoTuner를 생성하세요. 이 때 내부 튜닝 설정은 다음과 같이 합니다.
    • 튜너: 랜덤 탐색 ("random_search")
    • 내부 검증(Inner Resampling): 3-겹 교차 검증 ("cv", folds=3)
    • 성능 평가 지표: 분류 정확도 ("classif.acc")
    • 종료 조건: 20회 평가 ("evals", n_evals=20)
  6. 튜닝되지 않은 기본 SVM Learner와, AutoTuner로 감싼 튜닝되는 SVM Learner 두 가지를 준비하세요.
  7. Learner의 성능을 외부 검증(Outer Resampling)을 통해 비교하는 벤치마크를 설계하고 실행하세요. 외부 검증은 5-겹 교차 검증("cv", folds=5)을 사용합니다.
  8. 벤치마크 결과를 집계하여 두 모델의 평균 정확도를 비교하고, AutoTuner의 효용성에 대해 설명하세요.

정답 코드

# 필요한 라이브러리 로드
library(mlr3verse)
library(mlr3tuning)
library(e1071) # SVM learner에 필요
library(paradox)

# 1. Task 생성
task_spam <- tsk("spam")

# 2. 기본 Learner 생성
learner_svm <- lrn("classif.svm")

# 3. 탐색 공간(SearchSpace) 정의
# 로그 스케일로 변환하여 탐색하는 것이 효율적
search_space_svm <- ps(
  cost = p_dbl(log(1e-3), log(1e3), trafo = exp),
  gamma = p_dbl(log(1e-3), log(1e3), trafo = exp)
)

# 4. AutoTuner 생성
at <- AutoTuner$new(
  learner = learner_svm,
  resampling = rsmp("cv", folds = 3), # Inner resampling
  measure = msr("classif.acc"),
  search_space = search_space_svm,
  terminator = trm("evals", n_evals = 20),
  tuner = tnr("random_search")
)

# at 객체는 이제 하나의 Learner처럼 작동합니다.
print(at)

# 5. 벤치마크 설계
learners_to_compare <- list(
  learner_svm, # 기본 SVM
  at           # 자동 튜닝 SVM
)

# 외부 검증(Outer Resampling) 설정
outer_resampling <- rsmp("cv", folds = 5)

design <- benchmark_grid(
  tasks = task_spam,
  learners = learners_to_compare,
  resamplings = outer_resampling
)

# 6. 벤치마크 실행
set.seed(123)
bmr <- benchmark(design)

# 7. 결과 집계 및 비교
aggregated_results <- bmr$aggregate()
print(aggregated_results[, .(learner_id, classif.acc)])

해설

이 문제는 mlr3의 고급 기능인 AutoTuner를 활용하여 중첩 교차 검증(Nested Cross-Validation)을 구현하고, 모델 튜닝의 효과를 객관적으로 평가하는 방법을 다룹니다.

  1. AutoTuner의 개념:

    • AutoTunerLearner 객체를 감싸서(wrap), 훈련($train()) 과정에 하이퍼파라미터 튜닝을 자동으로 포함시키는 객체입니다.
    • AutoTuner로 감싸진 at 객체는 겉보기에는 일반 Learner와 똑같이 보이고 동일한 메소드($train, $predict)를 가집니다. 하지만 내부적으로 $train이 호출되면, 주어진 데이터(훈련 세트)에 대해 지정된 튜닝 절차(랜덤 탐색, 3-겹 교차 검증 등)를 수행하여 최적의 하이퍼파라미터를 찾고, 그 최적의 하이퍼파라미터로 전체 훈련 데이터에 대해 모델을 다시 학습시킨 후 최종 모델을 반환합니다.
  2. 중첩 교차 검증 (Nested Cross-Validation):

    • 이 문제의 벤치마크 과정은 중첩 교차 검증의 구조를 가집니다.
    • 외부 루프 (Outer Loop): outer_resampling (5-겹 CV)이 모델의 최종 일반화 성능을 평가하기 위해 데이터를 5개로 나눕니다. (4개는 훈련/튜닝용, 1개는 최종 평가용)
    • 내부 루프 (Inner Loop): 외부 루프에서 받은 훈련/튜닝용 데이터(전체의 4/5) 내에서, AutoTuner가 하이퍼파라미터를 찾기 위해 inner_resampling (3-겹 CV)을 수행합니다.
    • 이러한 구조는 하이퍼파라미터 튜닝 과정과 모델 성능 평가 과정에 사용되는 데이터를 엄격하게 분리하여, 튜닝으로 인해 성능이 낙관적으로 추정되는 것을 방지합니다. 즉, 모델의 일반화 성능을 매우 객관적으로 측정하는 방법입니다.
  3. 로그 스케일 탐색 (trafo = exp):

    • costgamma와 같은 파라미터는 1, 10, 100과 같이 곱셈 단위로 스케일이 변할 때 성능에 영향을 미치는 경우가 많습니다.
    • p_dbl(log(1e-3), log(1e3), trafo = exp)는 탐색 공간을 로그 변환하여 [-3*log(10), 3*log(10)] 범위에서 균등하게 샘플링한 후, trafo = exp (지수 함수)를 통해 원래 스케일로 되돌리는 것을 의미합니다. 이렇게 하면 0.01, 0.1, 1, 10, 100과 같은 지점들이 좀 더 균등하게 탐색될 확률이 높아져 효율적인 탐색이 가능합니다.

벤치마크 결과, classif.svm.tuned (AutoTuner)의 정확도가 기본 classif.svm보다 높게 나타나는 것을 확인할 수 있습니다. 이는 적절한 하이퍼파라미터 튜닝이 모델의 예측 성능을 크게 향상시킬 수 있음을 명확히 보여줍니다. AutoTuner는 이러한 복잡한 튜닝 및 평가 파이프라인을 매우 간결하고 재사용 가능한 코드로 구현할 수 있게 해주는 mlr3의 핵심적인 장점 중 하나입니다.

네, 알겠습니다. 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 사용자의 요구에 맞춰 텍스트 마이닝 및 시계열 분석을 주제로 한 고급 R 프로그래밍 문제들을 생성하겠습니다. 각 문제는 흥미로운 시나리오, 명확한 과제, 상세한 코드 해설 및 깊이 있는 개념 설명을 포함합니다.


286. 비디오 게임 리뷰 감성 분석: Tidytext를 이용한 제품 반응 분석

문제 상황: 당신은 최근 출시된 대작 비디오 게임 "Cybernia 2088"의 데이터 분석가입니다. 게임 출시 후 온라인 커뮤니티에는 수많은 유저 리뷰가 쏟아지고 있습니다. 경영진은 유저들의 전반적인 반응이 긍정적인지, 부정적인지, 그리고 어떤 부분에서 주로 긍정/부정 의견이 나오는지 파악하여 다음 업데이트 방향을 결정하고자 합니다.

당신에게는 리뷰 텍스트와 각 리뷰가 어떤 측면(예: 그래픽, 스토리, 게임플레이)에 대한 것인지 분류된 데이터셋이 주어졌습니다. tidytext 패키지를 활용하여 리뷰의 감성을 분석하고, 각 카테고리별 감성 점수를 비교 분석하는 것이 당신의 임무입니다.

데이터 예시 (game_reviews):

tibble::tribble(
  ~review_id, ~category, ~text,
  1, "Graphics", "The visual effects are absolutely breathtaking. Best graphics I've ever seen.",
  2, "Gameplay", "Combat is clunky and unresponsive. It feels like a step back from their last game.",
  3, "Story", "A truly compelling narrative with unforgettable characters. The plot twists were amazing.",
  4, "Graphics", "Character models look dated and the world feels empty and lifeless.",
  5, "Gameplay", "I love the new crafting system. It's intuitive and adds so much depth.",
  6, "Story", "The main quest was too short and the ending felt rushed and unsatisfying.",
  7, "Gameplay", "Driving mechanics are terrible. It's frustrating to navigate the city."
)

과제 지시 사항:

  1. 주어진 game_reviews 데이터프레임을 생성하세요.
  2. tidytext 패키지의 unnest_tokens() 함수를 사용하여 리뷰 텍스트를 단어 단위로 토큰화하세요.
  3. bing 감성 사전을 사용하여 각 단어에 긍정(positive) 또는 부정(negative) 감성을 할당하세요. (감성 사전에 없는 단어는 분석에서 제외됩니다.)
  4. category별로 긍정 단어 수와 부정 단어 수를 계산하세요.
  5. category의 '순 감성 점수'(Net Sentiment Score = 긍정 단어 수 - 부정 단어 수)를 계산하고, 이 점수를 막대그래프로 시각화하여 어떤 카테고리가 가장 좋은 반응을 얻고 있는지 보여주세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("tidyverse", "tidytext"))
library(tidyverse)
library(tidytext)

# 1. 데이터프레임 생성
game_reviews <- tibble::tribble(
  ~review_id, ~category, ~text,
  1, "Graphics", "The visual effects are absolutely breathtaking. Best graphics I've ever seen.",
  2, "Gameplay", "Combat is clunky and unresponsive. It feels like a step back from their last game.",
  3, "Story", "A truly compelling narrative with unforgettable characters. The plot twists were amazing.",
  4, "Graphics", "Character models look dated and the world feels empty and lifeless.",
  5, "Gameplay", "I love the new crafting system. It's intuitive and adds so much depth.",
  6, "Story", "The main quest was too short and the ending felt rushed and unsatisfying.",
  7, "Gameplay", "Driving mechanics are terrible. It's frustrating to navigate the city."
)

# 2. 단어 단위 토큰화
review_words <- game_reviews %>%
  unnest_tokens(word, text)

# 3. 'bing' 감성 사전을 이용한 감성 할당
bing_sentiments <- get_sentiments("bing")

review_sentiments <- review_words %>%
  inner_join(bing_sentiments, by = "word")

# 4. 카테고리별 긍정/부정 단어 수 계산
sentiment_counts <- review_sentiments %>%
  count(category, sentiment) %>%
  pivot_wider(names_from = sentiment, values_from = n, values_fill = 0)

# 5. 순 감성 점수 계산 및 시각화
net_sentiment <- sentiment_counts %>%
  mutate(net_sentiment = positive - negative) %>%
  arrange(desc(net_sentiment))

ggplot(net_sentiment, aes(x = reorder(category, net_sentiment), y = net_sentiment, fill = net_sentiment > 0)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  labs(
    title = "Cybernia 2088: 리뷰 카테고리별 순 감성 점수",
    x = "게임 카테고리",
    y = "순 감성 점수 (긍정 - 부정)"
  ) +
  theme_minimal() +
  scale_fill_manual(values = c("TRUE" = "steelblue", "FALSE" = "firebrick"))

해설

이 문제는 tidytext를 사용한 기본적인 감성 분석 워크플로우를 다룹니다. Tidy data 원칙을 텍스트 데이터에 적용하여 복잡한 텍스트 분석을 dplyr과 같은 친숙한 도구로 수행할 수 있게 하는 것이 tidytext의 핵심 철학입니다.

  1. unnest_tokens(word, text): 이 함수는 tidytext의 심장과도 같습니다. text 열에 있는 문장들을 word라는 새로운 열에 한 단어씩 포함된 행들로 분리(토큰화)합니다. 이 과정을 통해 "하나의 행이 하나의 관측치"라는 Tidy data 원칙에 맞게 데이터가 변환됩니다.
  2. get_sentiments("bing"): tidytext는 "bing", "nrc", "afinn" 등 여러 감성 사전을 내장하고 있습니다. bing 사전은 각 단어를 'positive' 또는 'negative'로 분류하는 가장 간단한 사전 중 하나입니다.
  3. inner_join(bing_sentiments, by = "word"): 토큰화된 단어 목록과 감성 사전을 word를 기준으로 결합합니다. inner_join을 사용했기 때문에, 감성 사전에 존재하는 단어들만 결과에 남게 됩니다. 이는 감성 분석에 필요 없는 단어(불용어 등)를 자연스럽게 제거하는 효과도 있습니다.
  4. count()pivot_wider(): count()를 사용해 각 카테고리 내에서 'positive'와 'negative' 단어의 빈도를 계산합니다. 이후 pivot_wider()를 사용하여 'long' 포맷의 데이터를 'wide' 포맷으로 변환합니다. 이렇게 하면 각 카테고리별로 positive 열과 negative 열을 갖게 되어 후속 계산이 용이해집니다.
  5. 순 감성 점수(Net Sentiment Score): mutate(net_sentiment = positive - negative)를 통해 간단하지만 직관적인 지표를 만듭니다. 이 점수가 양수이면 긍정적 의견이, 음수이면 부정적 의견이 우세함을 의미합니다.
  6. 시각화 (ggplot2): geom_col()을 사용한 막대그래프로 결과를 시각화합니다. reorder(category, net_sentiment)는 순 감성 점수 순으로 카테고리를 정렬하여 결과를 쉽게 해석할 수 있도록 돕습니다. fill = net_sentiment > 0 조건은 긍정적인 카테고리와 부정적인 카테고리를 다른 색으로 칠해 시각적 구분을 명확하게 합니다.

이 분석을 통해 경영진은 '스토리'가 가장 긍정적인 평가를 받는 반면, '게임플레이'는 심각한 문제점을 안고 있음을 한눈에 파악하고 다음 업데이트의 우선순위를 정할 수 있습니다.


287. 고전 문학 작품의 핵심 단어 추출: TF-IDF 분석

문제 상황: 당신은 디지털 인문학 연구자입니다. 제인 오스틴의 "오만과 편견(Pride and Prejudice)"과 허버트 조지 웰스의 "우주 전쟁(The War of the Worlds)" 두 작품을 비교 분석하여 각 작품을 가장 잘 대표하는 핵심 단어가 무엇인지 알아보고자 합니다. 단순히 단어 빈도만 계산하면 'the', 'a', 'is'와 같은 불용어(stop words)가 상위를 차지하여 의미 있는 분석이 어렵습니다.

이때 TF-IDF(Term Frequency-Inverse Document Frequency) 기법을 사용하면, 특정 문서(작품)에서는 자주 나타나지만 다른 문서들에서는 드물게 나타나는 단어에 높은 가중치를 부여하여 각 작품의 주제를 잘 드러내는 핵심 단어를 추출할 수 있습니다. gutenbergrtidytext 패키지를 사용하여 두 작품의 TF-IDF를 계산하고 비교 분석하는 것이 당신의 임무입니다.

과제 지시 사항:

  1. gutenbergr 패키지를 사용하여 "Pride and Prejudice"(Gutenberg ID: 1342)와 "The War of the Worlds"(Gutenberg ID: 36)의 텍스트를 다운로드하세요.
  2. unnest_tokens()를 사용하여 텍스트를 단어 단위로 토큰화하고, anti_join()stop_words 데이터를 사용하여 일반적인 불용어를 제거하세요.
  3. count()를 사용하여 각 작품(문서)별로 단어의 빈도(Term Frequency)를 계산하세요.
  4. bind_tf_idf() 함수를 사용하여 각 단어의 TF, IDF, TF-IDF 값을 계산하세요.
  5. 각 작품별로 TF-IDF 값이 가장 높은 상위 10개 단어를 추출하고, 이를 ggplot2를 사용하여 시각화하여 두 작품의 핵심 주제어 차이를 명확하게 보여주세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("tidyverse", "tidytext", "gutenbergr"))
library(tidyverse)
library(tidytext)
library(gutenbergr)

# 1. 구텐베르크 프로젝트에서 작품 다운로드
titles <- c("Pride and Prejudice", "The War of the Worlds")
gutenberg_ids <- c(1342, 36)

books <- gutenberg_download(gutenberg_ids, mirror = "http://gutenberg.readingroo.ms/") %>%
  left_join(gutenberg_metadata, by = "gutenberg_id") %>%
  select(title, text)

# 2. 토큰화 및 불용어 제거
data(stop_words)

book_words <- books %>%
  unnest_tokens(word, text) %>%
  anti_join(stop_words, by = "word")

# 3. 작품별 단어 빈도 계산
word_counts <- book_words %>%
  count(title, word, sort = TRUE)

# 4. TF-IDF 계산
book_tf_idf <- word_counts %>%
  bind_tf_idf(term = word, document = title, n = n) %>%
  arrange(desc(tf_idf))

# 5. 작품별 상위 10개 TF-IDF 단어 추출 및 시각화
top_tf_idf_words <- book_tf_idf %>%
  group_by(title) %>%
  slice_max(tf_idf, n = 10) %>%
  ungroup()

ggplot(top_tf_idf_words, aes(x = reorder_within(word, tf_idf, title), y = tf_idf, fill = title)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  facet_wrap(~title, scales = "free_y") +
  scale_x_reordered() +
  labs(
    title = "고전 문학 작품별 상위 TF-IDF 단어",
    subtitle = "'오만과 편견' vs '우주 전쟁'",
    x = "단어 (Term)",
    y = "TF-IDF"
  ) +
  theme_minimal()

해설

이 문제는 텍스트 마이닝의 핵심 개념인 TF-IDF를 실제로 계산하고 해석하는 과정을 다룹니다.

  1. TF (Term Frequency, 단어 빈도): 특정 문서 내에서 특정 단어가 얼마나 자주 등장하는지를 나타내는 지표입니다. $$ \text{tf}(t, d) = \frac{\text{문서 } d \text{에 있는 단어 } t \text{의 수}}{\text{문서 } d \text{에 있는 총 단어 수}} $$

  2. IDF (Inverse Document Frequency, 역문서 빈도): 특정 단어가 전체 문서 집합에서 얼마나 희귀한지를 나타내는 지표입니다. 많은 문서에 공통적으로 나타나는 단어(예: a, the)는 낮은 IDF 값을 갖고, 특정 문서에만 집중적으로 나타나는 단어는 높은 IDF 값을 갖습니다. $$ \text{idf}(t, D) = \log\left(\frac{\text{전체 문서의 수}}{\text{단어 } t \text{를 포함하는 문서의 수}}\right) $$ 로그를 취하는 이유는 문서 수가 기하급수적으로 늘어날 때 IDF 값이 과도하게 커지는 것을 방지하기 위함입니다.

  3. TF-IDF: 이 두 값을 곱한 것입니다. 즉, 특정 문서에 자주 나타나면서(TF가 높음) 다른 문서에서는 잘 나타나지 않는(IDF가 높음) 단어일수록 높은 TF-IDF 값을 갖게 됩니다. $$ \text{tf-idf}(t, d, D) = \text{tf}(t, d) \times \text{idf}(t, D) $$

코드 해설:

  • gutenberg_download(): gutenbergr 패키지의 함수로, 구텐베르크 프로젝트의 공개 도서 텍스트를 쉽게 다운로드할 수 있게 해줍니다.
  • anti_join(stop_words): stop_words 데이터셋에 포함된 단어들을 book_words에서 제거합니다. 이는 분석의 노이즈를 줄이는 중요한 전처리 과정입니다.
  • bind_tf_idf(term = word, document = title, n = n): tidytext의 마법 같은 함수입니다. 단어(term), 문서(document), 그리고 단어 빈도(n)를 나타내는 열을 지정해주기만 하면 TF, IDF, TF-IDF 값을 자동으로 계산하여 새로운 열로 추가해줍니다. 내부적으로 위에서 설명한 수식을 기반으로 계산이 이루어집니다.
  • slice_max(tf_idf, n = 10): 각 그룹(title) 내에서 tf_idf 값이 가장 큰 10개의 행을 선택합니다.
  • reorder_within()scale_x_reordered(): facet_wrap()을 사용하여 각 작품별로 플롯을 그릴 때, 각 플롯 내부에서 단어를 TF-IDF 값에 따라 독립적으로 정렬하기 위한 tidytext의 유용한 시각화 기법입니다.

결과 그래프를 보면, "오만과 편견"에서는 'darcy', 'elizabeth', 'bennet' 등 등장인물의 이름과 관련된 단어들이 높은 TF-IDF 값을 보이는 반면, "우주 전쟁"에서는 'martians', 'heat', 'ray', 'cylinder' 등 SF 소설의 특징을 나타내는 단어들이 높게 나타납니다. 이를 통해 TF-IDF가 각 작품의 고유한 주제와 내용을 효과적으로 추출해냈음을 알 수 있습니다.


288. 뉴스 기사 토픽 모델링: LDA를 이용한 숨겨진 주제 찾기

문제 상황: 당신은 AP 통신(Associated Press)의 데이터 저널리스트입니다. 수천 개의 뉴스 기사 데이터셋을 가지고 있으며, 이 기사들이 어떤 주제들로 구성되어 있는지 자동으로 분류하고 싶습니다. 예를 들어, 어떤 기사들은 '정치', 어떤 기사들은 '경제', 또 다른 기사들은 '스포츠'에 관한 것일 수 있습니다.

이처럼 문서 집합에 내재된 숨겨진 주제(Topic)들을 발견하기 위해 비지도 학습 기법인 **잠재 디리클레 할당(Latent Dirichlet Allocation, LDA)**을 사용하기로 했습니다. topicmodels 패키지와 tidytext를 연동하여 AP 통신 기사 데이터에서 4개의 주요 토픽을 찾아내고, 각 토픽을 대표하는 단어와 각 문서가 어떤 토픽에 속할 확률이 높은지 분석하는 것이 당신의 임무입니다.

과제 지시 사항:

  1. topicmodels 패키지에 내장된 AssociatedPress 데이터셋을 로드하고, tidytext를 사용하여 DocumentTermMatrix (DTM) 객체를 Tidy 포맷의 데이터프레임으로 변환하세요.
  2. cast_dtm() 함수를 사용하여 Tidy 데이터를 다시 DTM 객체로 변환하세요. LDA 모델 학습에는 DTM 형식이 필요합니다. (이 과정은 LDA 모델링을 위한 데이터 형식 변환 연습입니다.)
  3. topicmodels::LDA() 함수를 사용하여 k=4(4개의 토픽)로 설정한 LDA 모델을 학습시키세요. 재현 가능성을 위해 seed를 설정하세요.
  4. 학습된 LDA 모델에서 토픽별 단어 확률(beta)을 나타내는 행렬을 tidy()를 이용해 추출하세요.
  5. 각 토픽에서 가장 등장 확률이 높은 상위 10개 단어를 시각화하여 4개 토픽의 정체가 무엇인지 해석해보세요. (예: '정부', '대통령' 단어가 많으면 '정치' 토픽으로 해석)

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("tidyverse", "tidytext", "topicmodels"))
library(tidyverse)
library(tidytext)
library(topicmodels)

# 1. AssociatedPress 데이터셋 로드 및 Tidy 포맷으로 변환
data("AssociatedPress")

# tidy() 함수는 DTM 객체를 tidy 포맷으로 바로 변환해줌
ap_tidy <- tidy(AssociatedPress)

# 2. Tidy 데이터를 DTM 객체로 다시 변환
ap_dtm <- ap_tidy %>%
  cast_dtm(document, term, count)

# 3. LDA 모델 학습 (k=4)
set.seed(1234) # 재현 가능성을 위해 시드 설정
ap_lda <- LDA(ap_dtm, k = 4, control = list(seed = 1234))

# 4. 토픽별 단어 확률(beta) 추출
ap_topics <- tidy(ap_lda, matrix = "beta")

# 5. 토픽별 상위 단어 시각화 및 해석
top_terms <- ap_topics %>%
  group_by(topic) %>%
  slice_max(beta, n = 10) %>%
  ungroup() %>%
  arrange(topic, -beta)

ggplot(top_terms, aes(x = reorder_within(term, beta, topic), y = beta, fill = factor(topic))) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~ topic, scales = "free") +
  coord_flip() +
  scale_x_reordered() +
  labs(
    title = "AP 통신 기사 LDA 토픽 모델링 결과",
    subtitle = "각 토픽을 대표하는 상위 10개 단어",
    x = "단어 (Term)",
    y = "단어 생성 확률 (Beta)"
  )

해설

이 문제는 비지도 학습 기반의 토픽 모델링 기법인 LDA를 R에서 구현하는 방법을 다룹니다. LDA는 문서들이 여러 주제의 혼합으로 이루어져 있으며, 각 주제는 특정 단어들의 확률 분포로 표현된다고 가정하는 확률 모델입니다.

  1. DTM (Document-Term Matrix): 토픽 모델링의 입력 데이터 형식으로, 행은 문서, 열은 단어를 나타내며 각 셀에는 해당 문서에서 해당 단어가 나타난 빈도가 들어있는 행렬입니다. tidytext에서는 cast_dtm()을 통해 Tidy 포맷에서 DTM으로 쉽게 변환할 수 있습니다.
  2. LDA(ap_dtm, k = 4, ...): topicmodels 패키지의 핵심 함수입니다. DTM 객체와 찾고자 하는 토픽의 수(k)를 입력받아 LDA 모델을 학습시킵니다. k 값은 분석가가 사전에 지정해야 하는 하이퍼파라미터로, 적절한 k를 찾는 것 자체가 중요한 분석 과제일 수 있습니다.
  3. Beta (β)와 Gamma (γ): LDA 모델은 두 가지 주요 결과물을 내놓습니다.
    • Beta (β): 토픽-단어 확률 분포입니다. 즉, 각 토픽이 어떤 단어들로 구성될 확률이 높은지를 나타냅니다. tidy(ap_lda, matrix = "beta")로 추출할 수 있습니다. 이 문제에서는 Beta를 분석하여 각 토픽의 의미를 해석했습니다.
    • Gamma (γ): 문서-토픽 확률 분포입니다. 즉, 각 문서가 어떤 토픽에 속할 확률이 높은지를 나타냅니다. tidy(ap_lda, matrix = "gamma")로 추출하여 각 문서의 주제를 분류하는 데 사용할 수 있습니다.

코드 해설:

  • data("AssociatedPress"): topicmodels 패키지에 포함된 예제 DTM 데이터입니다.
  • tidy(AssociatedPress): tidytexttidy()는 다양한 모델 객체(DTM, LDA 모델 등)를 Tidy 데이터프레임으로 변환해주는 매우 유용한 일반 함수(generic function)입니다.
  • cast_dtm(...): tidy()의 반대 과정으로, Tidy 포맷을 DTM 객체로 변환합니다.
  • tidy(ap_lda, matrix = "beta"): 학습된 LDA 모델 객체에서 토픽-단어 확률(beta) 정보를 Tidy 포맷으로 추출합니다. 결과 데이터프레임은 topic, term, beta 세 개의 열을 갖습니다.
  • 시각화: 이전 문제와 마찬가지로 reorder_withinscale_x_reordered를 사용하여 각 토픽(facet) 내에서 단어들을 beta 값 기준으로 정렬하여 시각화합니다.

결과 그래프를 통해 우리는 각 토픽의 정체를 추론할 수 있습니다. 예를 들어, 한 토픽에서 'percent', 'million', 'market', 'company' 같은 단어가 많이 나온다면 '경제' 토픽으로, 다른 토픽에서 'soviet', 'government', 'president' 같은 단어가 많이 나온다면 '정치/국제 관계' 토픽으로 해석할 수 있습니다. 이처럼 LDA는 대규모 문서 컬렉션의 전체적인 구조와 주제를 파악하는 데 매우 강력한 도구입니다.


289. 대통령 연설문 분석: N-gram과 네트워크 그래프를 이용한 핵심 구문 시각화

문제 상황: 당신은 정치 데이터 분석가입니다. 특정 정치인의 연설문을 분석하여 그가 자주 사용하는 핵심 어구(key phrase)와 단어들의 연결 관계를 파악하고자 합니다. 개별 단어 분석만으로는 '백악관(White House)'이나 '경제 성장(economic growth)'과 같은 중요한 연관 표현을 놓치기 쉽습니다.

이 문제를 해결하기 위해 N-gram 분석을 사용하기로 했습니다. 특히 두 단어로 이루어진 **바이그램(bigram)**을 분석하여 연설문에 나타난 단어 간의 관계를 파악하고, 이를 네트워크 그래프로 시각화하여 어떤 단어들이 중심적인 역할을 하는지 직관적으로 보여주는 것이 당신의 임무입니다. janeaustenr 패키지의 텍스트를 대통령 연설문 데이터라고 가정하고 분석을 진행합니다.

과제 지시 사항:

  1. janeaustenr 패키지의 prideprejudice 텍스트를 불러와 연설문 데이터로 사용하고, 책의 챕터를 연설의 구분 단위로 간주합니다.
  2. unnest_tokens() 함수의 token = "ngrams", n = 2 옵션을 사용하여 텍스트를 바이그램 단위로 토큰화하세요.
  3. 생성된 바이그램에서 불용어가 포함된 바이그램을 제거하세요. (예: "of the", "to be"). 이를 위해 바이그램을 두 단어로 분리하고, 각 단어가 불용어인지 확인한 후 필터링해야 합니다.
  4. 가장 빈번하게 등장하는 상위 20개의 바이그램을 찾아 네트워크 그래프로 시각화하세요. igraph 패키지를 사용하여 그래프 객체를 생성하고, ggraph 패키지를 사용하여 ggplot2 스타일로 미려하게 시각화하세요. 노드(단어)의 크기는 연결 중심성(degree)에 비례하도록 설정하세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("tidyverse", "tidytext", "janeaustenr", "igraph", "ggraph"))
library(tidyverse)
library(tidytext)
library(janeaustenr)
library(igraph)
library(ggraph)

# 1. 데이터 준비 (Pride and Prejudice 텍스트를 연설문으로 가정)
austen_bigrams <- austen_books() %>%
  filter(book == "Pride & Prejudice") %>%
  mutate(chapter = cumsum(str_detect(text, regex("^chapter [\\divxlc]", ignore_case = TRUE)))) %>%
  filter(chapter > 0) %>%
  select(chapter, text)

# 2. 바이그램으로 토큰화
bigrams <- austen_bigrams %>%
  unnest_tokens(bigram, text, token = "ngrams", n = 2)

# 3. 불용어 제거
data(stop_words)

bigrams_separated <- bigrams %>%
  separate(bigram, c("word1", "word2"), sep = " ")

bigrams_filtered <- bigrams_separated %>%
  filter(!word1 %in% stop_words$word) %>%
  filter(!word2 %in% stop_words$word)

# 바이그램 빈도 계산
bigram_counts <- bigrams_filtered %>%
  count(word1, word2, sort = TRUE)

# 4. 상위 20개 바이그램으로 네트워크 그래프 생성 및 시각화
bigram_graph <- bigram_counts %>%
  filter(n > 3) %>% # 너무 복잡하지 않게 빈도가 3 초과인 바이그램만 선택
  graph_from_data_frame()

set.seed(2023)

ggraph(bigram_graph, layout = "fr") +
  geom_edge_link(aes(edge_alpha = n), show.legend = FALSE,
                 arrow = arrow(length = unit(2, 'mm')), end_cap = circle(3, 'mm')) +
  geom_node_point(color = "lightblue", size = 5) +
  geom_node_text(aes(label = name), vjust = 1, hjust = 1) +
  labs(
    title = "연설문 핵심 구문 네트워크 분석 (바이그램)",
    subtitle = "단어 간의 연결 관계 시각화"
  ) +
  theme_void()

해설

이 문제는 단어와 단어 사이의 관계를 분석하는 N-gram과 네트워크 분석을 결합한 고급 텍스트 마이닝 기법을 다룹니다.

  1. N-gram: 연속된 n개의 단어 시퀀스를 의미합니다. n=2일 때 바이그램, n=3일 때 트라이그램이라고 합니다. unnest_tokens(..., token = "ngrams", n = 2) 옵션을 통해 텍스트를 쉽게 바이그램으로 분리할 수 있습니다.
  2. 불용어 처리: 바이그램 분석 시에는 각 단어를 따로 처리해야 합니다. separate() 함수는 한 열을 여러 열로 분리하는 데 유용합니다. 이를 이용해 bigram 열을 word1word2로 분리한 후, 각각에 대해 불용어 필터링을 적용합니다.
  3. 네트워크 그래프: 단어와 그들 간의 관계를 시각화하는 강력한 도구입니다.
    • 노드(Node/Vertex): 개별 단어를 나타냅니다.
    • 엣지(Edge/Link): 단어 간의 관계(바이그램 등장)를 나타냅니다. 엣지의 두께나 색상은 관계의 강도(바이그램 빈도)를 표현하는 데 사용될 수 있습니다.
  4. igraphggraph:
    • igraph는 R에서 네트워크 분석을 위한 핵심 패키지입니다. graph_from_data_frame() 함수는 엣지 리스트(여기서는 word1, word2, n을 포함한 데이터프레임)로부터 그래프 객체를 쉽게 생성해줍니다.
    • ggraphigraph 객체를 ggplot2 문법으로 시각화할 수 있게 해주는 패키지입니다. ggplot2의 유연성과 미적 우수성을 네트워크 시각화에 그대로 적용할 수 있습니다.
    • layout = "fr": Fruchterman-Reingold 알고리즘을 사용하여 노드들을 배치합니다. 이 레이아웃은 서로 연결된 노드들은 가깝게, 연결되지 않은 노드들은 멀리 밀어내어 그래프의 구조를 직관적으로 보여주는 경향이 있습니다.

코드 해설:

  • separate(bigram, c("word1", "word2"), sep = " "): bigram 열을 공백을 기준으로 word1word2 두 개의 열로 나눕니다.
  • graph_from_data_frame(): word1을 시작 노드, word2를 끝 노드로 하는 방향성 엣지를 생성합니다. 나머지 열(여기서는 n)은 엣지의 속성(attribute)이 됩니다.
  • ggraph(...): ggplot()과 유사하게 그래프 시각화의 기본 틀을 설정합니다.
  • geom_edge_link(): 엣지를 그립니다. edge_alpha = n은 빈도가 높을수록 엣지를 진하게 표시하도록 합니다.
  • geom_node_point()geom_node_text(): 노드를 점으로 그리고, 그 위에 단어 텍스트(레이블)를 표시합니다.

이 분석을 통해 'miss darcy', 'lady catherine'과 같이 자주 함께 등장하는 인물 관계나 특정 표현들을 한눈에 파악할 수 있으며, 이는 연설의 핵심 메시지나 화자의 언어 습관을 이해하는 데 중요한 단서를 제공합니다.


290. 전자상거래 상품 리뷰 분석: 감성 점수와 평점의 상관관계 규명

문제 상황: 당신은 대규모 온라인 쇼핑몰의 데이터 분석팀 소속입니다. 고객들이 남긴 상품 리뷰 텍스트와 별점(1~5점) 데이터를 가지고 있습니다. 경영진은 "고객이 텍스트 리뷰에 쓴 내용과 실제 매긴 별점 사이에 얼마나 강한 연관성이 있는가?"를 궁금해하고 있습니다. 만약 텍스트의 감성과 별점이 일치하지 않는 상품 카테고리가 있다면, 이는 고객의 기대와 실제 경험 사이에 괴리가 있음을 시사할 수 있습니다.

당신은 tidytext를 사용하여 리뷰 텍스트로부터 감성 점수를 계산하고, 이 점수가 실제 고객이 부여한 평점과 어떤 상관관계를 갖는지 카테고리별로 분석하여 보고해야 합니다.

데이터 예시 (product_reviews):

tibble::tribble(
  ~product_id, ~category, ~rating, ~text,
  101, "Electronics", 5, "Amazing product! Excellent performance and beautiful design. Absolutely love it.",
  102, "Electronics", 1, "Terrible. It broke after just one week. Very disappointing and frustrating.",
  103, "Books", 4, "A wonderful story, but the ending was a bit predictable. Still, a great read.",
  104, "Books", 5, "This is a masterpiece. The author is a genius. I couldn't put it down.",
  105, "Electronics", 3, "It's an okay device. The battery life is not great, but the screen is good.",
  106, "Clothing", 2, "The material is cheap and uncomfortable. The color is also different from the picture.",
  107, "Clothing", 5, "Perfect fit and great quality. I'm very happy with this purchase."
)

과제 지시 사항:

  1. 주어진 product_reviews 데이터프레임을 생성하세요.
  2. 리뷰 텍스트를 단어 단위로 토큰화하고, afinn 감성 사전을 사용하여 각 단어에 감성 점수(-5 ~ +5)를 부여하세요. afinn 사전은 긍정/부정의 강도를 수치로 제공하여 더 세밀한 분석이 가능합니다.
  3. 각 리뷰(product_id 기준)별로 모든 단어의 감성 점수를 합산하여 '리뷰 텍스트 감성 점수(sentiment_score)'를 계산하세요.
  4. 원본 product_reviews 데이터에 위에서 계산한 sentiment_score를 결합하세요.
  5. 상품 category별로 '평균 평점(avg_rating)'과 '평균 텍스트 감성 점수(avg_sentiment_score)'를 계산하세요.
  6. 계산된 카테고리별 평균 평점과 평균 텍스트 감성 점수 간의 상관계수를 계산하고, 스캐터 플롯(산점도)으로 시각화하여 두 변수 간의 관계를 보여주세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
library(tidyverse)
library(tidytext)

# 1. 데이터프레임 생성
product_reviews <- tibble::tribble(
  ~product_id, ~category, ~rating, ~text,
  101, "Electronics", 5, "Amazing product! Excellent performance and beautiful design. Absolutely love it.",
  102, "Electronics", 1, "Terrible. It broke after just one week. Very disappointing and frustrating.",
  103, "Books", 4, "A wonderful story, but the ending was a bit predictable. Still, a great read.",
  104, "Books", 5, "This is a masterpiece. The author is a genius. I couldn't put it down.",
  105, "Electronics", 3, "It's an okay device. The battery life is not great, but the screen is good.",
  106, "Clothing", 2, "The material is cheap and uncomfortable. The color is also different from the picture.",
  107, "Clothing", 5, "Perfect fit and great quality. I'm very happy with this purchase."
)

# 2. 토큰화 및 'afinn' 감성 점수 부여
afinn <- get_sentiments("afinn")

review_sentiment_scores <- product_reviews %>%
  unnest_tokens(word, text) %>%
  inner_join(afinn, by = "word")

# 3. 리뷰별 텍스트 감성 점수 합산
total_sentiment_scores <- review_sentiment_scores %>%
  group_by(product_id) %>%
  summarise(sentiment_score = sum(value))

# 4. 원본 데이터와 감성 점수 결합
reviews_with_sentiment <- product_reviews %>%
  left_join(total_sentiment_scores, by = "product_id") %>%
  # 감성 단어가 없는 리뷰는 NA가 되므로 0으로 처리
  mutate(sentiment_score = replace_na(sentiment_score, 0))

# 5. 카테고리별 평균 평점 및 평균 감성 점수 계산
category_summary <- reviews_with_sentiment %>%
  group_by(category) %>%
  summarise(
    avg_rating = mean(rating),
    avg_sentiment_score = mean(sentiment_score)
  )

# 6. 상관계수 계산 및 시각화
correlation <- cor(category_summary$avg_rating, category_summary$avg_sentiment_score)
print(paste("상관계수:", round(correlation, 3)))

ggplot(category_summary, aes(x = avg_sentiment_score, y = avg_rating, color = category)) +
  geom_point(size = 5) +
  geom_text(aes(label = category), vjust = -1) +
  geom_smooth(method = "lm", se = FALSE, color = "gray50", linetype = "dashed") +
  labs(
    title = "카테고리별 리뷰 텍스트 감성 점수와 평점의 관계",
    subtitle = paste("피어슨 상관계수:", round(correlation, 3)),
    x = "평균 텍스트 감성 점수",
    y = "평균 별점"
  ) +
  theme_minimal() +
  theme(legend.position = "none")

해설

이 문제는 텍스트 데이터에서 추출한 정량적 정보(감성 점수)와 다른 정량적 변수(평점)를 결합하여 분석하는 실용적인 예제입니다.

  1. afinn 감성 사전: bing 사전과 달리, afinn은 각 단어에 -5(매우 부정)부터 +5(매우 긍정)까지의 정수 점수(value)를 부여합니다. 이를 통해 감성의 '강도'를 측정할 수 있어, 단순히 긍정/부정을 세는 것보다 더 섬세한 분석이 가능합니다.
  2. group_by(product_id) %>% summarise(sentiment_score = sum(value)): 이 부분이 핵심입니다. 각 리뷰(product_id)를 그룹으로 묶은 뒤, 그 리뷰에 포함된 모든 감성 단어의 점수(value)를 합산합니다. 이렇게 함으로써 리뷰 텍스트 전체의 종합적인 감성 지표를 생성할 수 있습니다.
  3. left_join: 원본 데이터에 감성 점수를 합칠 때 left_join을 사용합니다. 만약 어떤 리뷰에 afinn 사전에 있는 감성 단어가 하나도 없었다면, total_sentiment_scores 데이터프레임에 해당 product_id가 없을 것입니다. left_join은 원본 데이터의 모든 행을 유지하고, 일치하는 product_id가 없을 경우 sentiment_scoreNA로 채워줍니다. replace_na(sentiment_score, 0)를 통해 이들을 0점으로 처리하여 분석의 일관성을 유지합니다.
  4. 상관 분석:
    • cor(): 두 벡터 간의 피어슨 상관계수를 계산합니다. 이 값은 -1에서 1 사이의 값을 가지며, 1에 가까울수록 강한 양의 선형 관계, -1에 가까울수록 강한 음의 선형 관계, 0에 가까울수록 선형 관계가 없음을 의미합니다.
    • 스캐터 플롯 (geom_point): 두 연속형 변수 간의 관계를 시각화하는 가장 기본적인 방법입니다. 각 점은 하나의 카테고리를 나타냅니다.
    • geom_smooth(method = "lm"): 데이터에 대한 선형 회귀선(추세선)을 추가하여 전반적인 관계의 방향성을 시각적으로 보여줍니다.

이 분석을 통해 "텍스트 리뷰에서 긍정적인 단어를 많이 쓴 카테고리는 실제로 평균 별점도 높은 경향이 있다"는 가설을 데이터로 확인할 수 있습니다. 만약 특정 카테고리가 추세선에서 멀리 벗어나 있다면(예: 텍스트 감성은 높은데 별점은 낮음), 해당 카테고리의 상품들이 가진 특이한 문제점을 파고드는 추가 분석의 실마리가 될 수 있습니다.


291. 항공 승객 데이터 시계열 분해: 추세, 계절성, 잔차 분석

문제 상황: 당신은 한 항공사의 수요 예측 분석가입니다. 과거 월별 국제 항공 승객 수 데이터를 사용하여 미래 수요를 예측하는 모델을 구축해야 합니다. 모델링에 앞서, 데이터가 어떤 패턴을 가지고 있는지 파악하는 것이 매우 중요합니다. 시계열 데이터는 보통 추세(Trend), 계절성(Seasonality), 잔차(Remainder/Irregular) 세 가지 구성 요소로 분해할 수 있습니다.

R에 내장된 AirPassengers 데이터셋을 사용하여 시계열 분해를 수행하고, 각 구성 요소를 시각화하여 데이터의 구조적 특징을 파악하는 것이 당신의 임무입니다.

과제 지시 사항:

  1. R 내장 데이터셋인 AirPassengers를 로드하세요. 이 데이터는 ts (time series) 객체 형식입니다.
  2. 데이터에 로그 변환(log transformation)을 적용하세요. 시계열 데이터에서 분산이 시간에 따라 증가하는 경우, 로그 변환을 통해 분산을 안정화(variance stabilizing)시켜 분석을 용이하게 할 수 있습니다.
  3. decompose() 함수를 사용하여 시계열을 덧셈 모델(additive model) 방식으로 분해하세요.
  4. forecast 패키지의 autoplot() 또는 기본 plot() 함수를 사용하여 원본 시계열(로그 변환된)과 분해된 세 가지 요소(Trend, Seasonal, Remainder)를 한 번에 시각화하세요.
  5. 각 구성 요소가 무엇을 의미하는지 해석하세요. (예: 추세는 어떻게 변하는가? 계절성의 패턴은 어떠한가?)

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages("forecast")
library(forecast)
library(ggplot2)

# 1. AirPassengers 데이터셋 로드
data("AirPassengers")
ts_data <- AirPassengers

# 2. 로그 변환 적용
log_ts_data <- log(ts_data)

# 3. decompose() 함수를 이용한 시계열 분해 (덧셈 모델)
# decompose는 기본적으로 덧셈 모델을 가정함
decomposed_data <- decompose(log_ts_data, type = "additive")

# 4. 분해 결과 시각화
# 방법 1: forecast 패키지의 autoplot 사용
autoplot(decomposed_data) +
  labs(
    title = "항공 승객 데이터 시계열 분해 (로그 변환, 덧셈 모델)",
    subtitle = "stl() 함수를 사용하면 더 강건한 분해가 가능합니다."
  ) +
  theme_minimal()

# 방법 2: 기본 plot 함수 사용 (위와 동일한 결과)
# plot(decomposed_data)

# 5. 해석 (해설 부분에서 상세히 설명)

해설

이 문제는 시계열 분석의 가장 기본적인 단계인 시계열 분해(Time Series Decomposition)를 다룹니다. 시계열 $Y_t$는 다음과 같은 모델로 표현될 수 있습니다.

  • 덧셈 모델(Additive Model): $Y_t = T_t + S_t + R_t$
  • 곱셈 모델(Multiplicative Model): $Y_t = T_t \times S_t \times R_t$

여기서 $T_t$는 추세, $S_t$는 계절성, $R_t$는 잔차(불규칙 요소)를 의미합니다. 계절성의 변동폭이 추세 수준과 관계없이 일정하면 덧셈 모델, 추세가 증가함에 따라 계절성의 변동폭도 함께 커지면 곱셈 모델이 적합합니다.

코드 및 개념 해설:

  1. 로그 변환: 원본 AirPassengers 데이터를 보면, 시간이 지남에 따라 승객 수가 증가하면서 계절적 변동의 폭도 함께 커지는 것을 볼 수 있습니다. 이는 전형적인 곱셈 모델의 특징입니다. 곱셈 모델에 로그를 취하면 다음과 같이 덧셈 모델로 변환할 수 있습니다. $$ \log(Y_t) = \log(T_t \times S_t \times R_t) = \log(T_t) + \log(S_t) + \log(R_t) $$ 이것이 분석 초기에 로그 변환을 적용한 이유입니다. 분산을 안정화시켜 덧셈 모델을 더 적합하게 만들어줍니다.

  2. decompose(): R의 기본 시계열 분해 함수입니다. 이동 평균(moving average)을 사용하여 추세를 추출하고, 원본 시계열에서 추세를 빼서 계절성을 계산하는 비교적 간단한 방식을 사용합니다. type 인자를 "additive" 또는 "multiplicative"로 설정할 수 있습니다.

  3. 시각화 결과 해석:

    • observed (또는 data): 로그 변환된 원본 시계열입니다.
    • trend: 장기적인 증가 추세를 명확하게 보여줍니다. 데이터의 전반적인 방향성을 나타냅니다.
    • seasonal: 12개월 주기의 뚜렷한 계절적 패턴을 보여줍니다. 매년 특정 월(여름 휴가철)에 승객 수가 급증하고, 특정 월에 감소하는 패턴이 반복됩니다.
    • random (또는 remainder): 추세와 계절성으로 설명되지 않는 나머지 불규칙한 변동을 나타냅니다. 이상적으로는 이 잔차는 특별한 패턴이 없는 백색 잡음(white noise) 형태여야 합니다.

고급 정보: decompose()는 간단하지만 몇 가지 단점(예: 시계열의 시작과 끝부분에서 추정 불가, 특정 이상치에 민감)이 있습니다. 실제 분석에서는 LOESS를 사용하는 더 강건한(robust) 분해 방법인 stl() (Seasonal and Trend decomposition using Loess) 함수를 사용하는 것이 더 권장됩니다. forecast::mstl()은 다중 계절성까지 처리할 수 있는 더 발전된 함수입니다.


292. 주가 데이터의 정상성 확보 및 ARIMA 모델 식별: ACF와 PACF 분석

문제 상황: 당신은 퀀트 분석가로, 특정 기업의 주가 데이터를 분석하여 미래 주가를 예측하는 ARIMA 모델을 구축하고자 합니다. ARIMA 모델을 적용하기 위한 핵심 전제 조건은 시계열 데이터가 **정상성(Stationarity)**을 만족해야 한다는 것입니다. 정상 시계열은 시간의 추이와 상관없이 평균, 분산, 공분산이 일정한 시계열을 의미합니다.

대부분의 주가 데이터와 같은 금융 시계열은 추세를 가지므로 비정상(non-stationary) 시계열입니다. 따라서 모델링 전에 데이터를 정상 시계열로 변환하는 과정이 필수적입니다. 이 변환 과정으로 **차분(differencing)**이 널리 사용됩니다.

quantmod 패키지를 사용하여 애플(AAPL)의 주가 데이터를 가져와 정상성을 확인하고, 차분을 통해 정상성을 확보한 뒤, **ACF(자기상관함수)**와 PACF(부분자기상관함수) 그래프를 분석하여 적절한 ARIMA(p, d, q) 모델의 차수(p, q)를 식별하는 것이 당신의 임무입니다.

과제 지시 사항:

  1. quantmod 패키지를 사용하여 2020-01-01부터 2022-12-31까지의 애플(AAPL) 종가(adjusted close) 데이터를 가져오세요.
  2. 원본 시계열 데이터의 그래프를 그리고, ACF 그래프를 그려 비정상성을 시각적으로 확인하세요.
  3. 1차 차분(1st differencing)을 적용하여 수익률 시계열을 만드세요. 차분된 시계열의 그래프와 ACF/PACF 그래프를 그려 정상성이 확보되었는지 확인하세요.
  4. 차분된 시계열의 ACF와 PACF 그래프를 해석하여, AR(p) 모델과 MA(q) 모델 중 어떤 것이 더 적합할지, 그리고 적절한 차수 p와 q는 얼마일지 제안하세요. (ARIMA(p, d, q)에서 d는 차분 횟수이므로 1입니다.)

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("quantmod", "forecast"))
library(quantmod)
library(forecast)

# 1. 애플(AAPL) 주가 데이터 가져오기
getSymbols("AAPL", src = "yahoo", from = "2020-01-01", to = "2022-12-31")
aapl_close <- Ad(AAPL)

# 2. 원본 시계열의 비정상성 확인
par(mfrow=c(2,1))
plot(aapl_close, main = "AAPL 종가 (비정상 시계열)")
Acf(aapl_close, main = "AAPL 종가 ACF")
# ACF가 천천히 감소하는 전형적인 비정상 시계열의 패턴을 보임

# 3. 1차 차분을 통한 정상성 확보 및 ACF/PACF 분석
# diff() 함수는 차분을 수행. na.omit()으로 첫 NA 값 제거
aapl_returns <- na.omit(diff(log(aapl_close))) # 로그 수익률 계산이 일반적
# 또는 aapl_returns <- na.omit(diff(aapl_close)) 로 단순 차분

par(mfrow=c(3,1))
plot(aapl_returns, main = "AAPL 로그 수익률 (1차 차분, 정상 시계열)")
Acf(aapl_returns, main = "로그 수익률 ACF")
Pacf(aapl_returns, main = "로그 수익률 PACF")
par(mfrow=c(1,1)) # plot 레이아웃 복원

해설

이 문제는 ARIMA 모델링의 가장 중요한 사전 단계인 정상성 확보와 모델 식별 과정을 다룹니다.

  1. 정상성(Stationarity): 시계열의 확률적 특성(평균, 분산 등)이 시간에 따라 변하지 않는 성질입니다.

    • 비정상 시계열의 특징: 원본 주가 그래프처럼 뚜렷한 추세(평균이 시간에 따라 변함)가 존재합니다. ACF 그래프를 보면, 상관계수가 매우 천천히, 거의 선형적으로 감소하는 패턴을 보입니다. 이는 오늘의 주가가 어제의 주가와 매우 강하게 연관되어 있음을 의미하며, 이러한 시계열은 예측 모델링에 직접 사용하기 어렵습니다.
  2. 차분(Differencing): 비정상 시계열을 정상 시계열로 변환하는 가장 일반적인 방법입니다. 1차 차분은 현재 시점의 값에서 바로 이전 시점의 값을 빼주는 것입니다. $$ \Delta Y_t = Y_t - Y_{t-1} $$ 주가($Y_t$)를 차분하면 주가 변화량, 즉 수익률($\Delta Y_t$)이 됩니다. 주가 자체는 비정상이지만, 일일 수익률은 보통 평균이 0에 가까운 정상 시계열의 특성을 보입니다. 코드에서는 diff(log(aapl_close))를 사용했는데, 이는 로그 수익률을 계산하는 것으로 변동성을 안정화하는 효과가 있어 금융 데이터 분석에서 더 선호됩니다.

  3. ACF와 PACF를 이용한 모델 식별 (Box-Jenkins 방법론):

    • ACF (Autocorrelation Function, 자기상관함수): 시점 $t$의 데이터와 $k$ 시점 이전($t-k$)의 데이터 간의 상관관계를 나타냅니다. 즉, $Y_t$$Y_{t-k}$의 상관관계입니다.
    • PACF (Partial Autocorrelation Function, 부분자기상관함수): $Y_t$$Y_{t-k}$ 사이의 상관관계를 계산할 때, 그 사이에 있는 모든 값들($Y_{t-1}, Y_{t-2}, ..., Y_{t-k+1}$)의 영향을 제거한 후의 순수한 상관관계를 나타냅니다.

    차분된 시계열(수익률)의 ACF와 PACF 그래프를 해석하는 규칙은 다음과 같습니다.

    • MA(q) 모델 특징: ACF가 특정 시점 $q$ 이후에 급격히 0으로 절단(cut off)되고, PACF는 점진적으로 감소합니다.
    • AR(p) 모델 특징: PACF가 특정 시점 $p$ 이후에 급격히 0으로 절단되고, ACF는 점진적으로 감소합니다.
    • ARMA(p, q) 모델 특징: ACF와 PACF 모두 점진적으로 감소합니다.

결과 해석: 차분된 AAPL 수익률의 ACF와 PACF 그래프를 보면, 두 그래프 모두 파란색 점선(신뢰구간)을 벗어나는 유의미한 상관계수가 거의 없습니다. 이는 차분된 시계열이 특별한 자기상관 구조가 없는 **백색 잡음(white noise)**에 가깝다는 것을 시사합니다. 이 경우, 가장 적합한 모델은 ARIMA(0, 1, 0)일 수 있습니다. 이는 "내일의 주가 예측값은 오늘의 주가와 같다"는 랜덤 워크(Random Walk) 모델과 같습니다. 만약 ACF나 PACF에서 특정 시점(lag)에 삐죽 튀어나온 막대가 있다면, 이를 기반으로 AR 또는 MA 항을 추가하여 ARIMA(p, 1, q) 모델을 고려해볼 수 있습니다. 예를 들어, PACF의 lag 1에서만 유의미한 값이 보인다면 ARIMA(1, 1, 0)을, ACF의 lag 1에서만 유의미한 값이 보인다면 ARIMA(0, 1, 1)을 시험해볼 수 있습니다.


293. 소매점 매출 예측: auto.arima를 이용한 최적 모델링 및 예측

문제 상황: 당신은 한 소매 체인의 데이터 분석가입니다. 과거 5년간의 월별 매출 데이터를 바탕으로 향후 12개월의 매출을 예측하여 재고 관리 및 마케팅 전략 수립에 활용하고자 합니다. 이전 문제에서 ARIMA 모델 식별을 위해 ACF/PACF를 수동으로 분석하는 법을 배웠지만, 실제 현업에서는 이 과정을 자동화하여 최적의 (p, d, q) 조합을 찾아주는 도구를 사용하는 경우가 많습니다.

forecast 패키지의 auto.arima() 함수는 AIC(Akaike Information Criterion)와 같은 정보 기준을 최소화하는 최적의 ARIMA 모델을 자동으로 찾아주는 강력한 도구입니다. 이 함수를 사용하여 최적의 ARIMA 모델을 구축하고 미래 매출을 예측하는 것이 당신의 임무입니다.

과제 지시 사항:

  1. 가상의 월별 매출 데이터를 생성하세요. 데이터는 뚜렷한 추세와 계절성을 포함하도록 만듭니다. (5년 = 60개월)
  2. 생성된 데이터를 ts 객체로 변환하세요. (주기=12)
  3. auto.arima() 함수를 사용하여 주어진 데이터에 가장 적합한 ARIMA 모델을 찾으세요. summary() 함수를 통해 찾은 모델의 상세 정보를 확인하세요.
  4. forecast() 함수를 사용하여 향후 12개월의 매출을 예측하세요.
  5. autoplot() 또는 기본 plot() 함수를 사용하여 원본 데이터, 예측값, 그리고 80% 및 95% 신뢰구간을 함께 시각화하여 보고서를 작성하세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages("forecast")
library(forecast)
library(ggplot2)

# 1. 가상 월별 매출 데이터 생성 (추세 + 계절성 + 노이즈)
set.seed(42)
n_months <- 60 # 5년
time <- 1:n_months
trend <- 0.5 * time
seasonality <- 10 * sin(2 * pi * time / 12)
noise <- rnorm(n_months, mean = 0, sd = 5)
sales_data_vector <- 100 + trend + seasonality + noise

# 2. ts 객체로 변환
sales_ts <- ts(sales_data_vector, start = c(2018, 1), frequency = 12)

# 3. auto.arima()를 이용한 최적 모델 탐색 및 확인
fit <- auto.arima(sales_ts)
summary(fit)

# 4. 향후 12개월 예측
fc <- forecast(fit, h = 12)

# 5. 예측 결과 시각화
autoplot(fc) +
  labs(
    title = "월별 매출 예측 (ARIMA 모델)",
    subtitle = paste("선택된 모델:", fc$method),
    x = "연도",
    y = "매출"
  ) +
  theme_minimal()

# 예측값 확인
print(fc)

해설

이 문제는 실제 시계열 분석 프로젝트에서 널리 사용되는 auto.arima() 함수의 활용법을 다룹니다.

  1. 데이터 생성: 실제와 유사한 시계열 데이터를 만들기 위해 trend, seasonality, noise 세 요소를 조합했습니다. sin() 함수는 주기적인 패턴을 만드는 데 유용하며, 2 * pi * time / 12는 주기가 12인 사인파를 생성합니다.

  2. ts() 객체: R에서 시계열 분석을 하려면 데이터를 ts 객체로 만드는 것이 표준적입니다. start는 데이터의 시작 시점, frequency는 단위 시간당 관측치 수를 의미합니다. 월별 데이터이므로 frequency=12로 설정합니다. 이 frequency 정보는 모델이 계절성을 파악하는 데 매우 중요합니다.

  3. auto.arima()의 작동 원리: 이 함수는 내부적으로 다음과 같은 과정을 거쳐 최적의 모델을 찾습니다.

    • 차분 횟수(d) 결정: KPSS 검정(Kwiatkowski-Phillips-Schmidt-Shin test)과 같은 단위근 검정(unit root test)을 반복적으로 수행하여 시계열을 정상성으로 만들기 위한 차분 횟수 d를 결정합니다. 계절성이 있는 데이터의 경우 계절 차분 횟수 D도 결정합니다.
    • p와 q 탐색: 결정된 차분 횟수를 바탕으로, 다양한 (p, q) 및 (P, Q) 조합(계절성 ARIMA의 경우)에 대해 모델을 적합시킵니다.
    • 모델 선택: 각 모델의 AIC(Akaike Information Criterion), AICc(Corrected AIC), 또는 **BIC(Bayesian Information Criterion)**를 계산합니다. 이 값들은 모델의 적합도와 복잡도 사이의 균형을 맞추는 지표로, 값이 낮을수록 더 좋은 모델로 간주됩니다. auto.arima()는 기본적으로 AICc를 최소화하는 (p,d,q)(P,D,Q) 조합을 최종 모델로 선택합니다.
    • summary(fit) 결과에서 ARIMA(p,d,q)(P,D,Q)[m] 형태의 모델을 확인할 수 있습니다. 소문자는 비계절성 부분, 대문자는 계절성 부분, [m]은 계절성의 주기를 나타냅니다.
  4. forecast() 함수: auto.arima()로 적합된 모델 객체를 입력받아 미래 시점의 값을 예측합니다. h는 예측할 기간(horizon)을 의미합니다. 이 함수는 점 예측(point forecast)뿐만 아니라, 예측의 불확실성을 나타내는 **신뢰구간(confidence interval)**도 함께 제공합니다.

  5. 시각화 (autoplot): forecast 패키지의 autoplot() 함수는 forecast 객체를 시각화하는 데 특화되어 있습니다. 원본 데이터, 예측값, 그리고 80%와 95% 신뢰구간(각각 진한 회색과 연한 회색 음영 영역)을 자동으로 그려주어 결과를 매우 직관적으로 전달할 수 있습니다. 신뢰구간이 미래로 갈수록 넓어지는 것은 미래 예측의 불확실성이 시간이 지남에 따라 커진다는 것을 의미합니다.


294. 웹사이트 트래픽 예측: Prophet을 이용한 휴일 효과 모델링

문제 상황: 당신은 한 이커머스 기업의 마케팅 분석가입니다. 일별 웹사이트 방문자 수 데이터를 분석하여 미래 트래픽을 예측하고, 특히 블랙 프라이데이, 크리스마스와 같은 주요 프로모션 기간(휴일)이 트래픽에 미치는 영향을 정량적으로 분석하고자 합니다.

전통적인 ARIMA 모델은 휴일 효과나 여러 주기의 계절성(주별, 연별)을 다루기 까다로운 반면, Facebook에서 개발한 prophet 패키지는 이러한 요소들을 직관적이고 유연하게 모델링할 수 있도록 설계되었습니다. prophet을 사용하여 휴일 효과를 포함한 트래픽 예측 모델을 구축하는 것이 당신의 임무입니다.

과제 지시 사항:

  1. 2년간의 가상 일별 웹사이트 트래픽 데이터를 생성하세요. 데이터는 주별 계절성, 연간 계절성, 그리고 특정 날짜(예: 블랙 프라이데이)에 급증하는 패턴을 포함해야 합니다.
  2. prophet 모델에 입력하기 위해 데이터프레임의 열 이름을 ds(날짜)와 y(값)로 변경하세요.
  3. 프로모션 효과를 모델링하기 위한 holidays 데이터프레임을 생성하세요. 이 데이터프레임은 holiday(이벤트명), ds(날짜), lower_window, upper_window 열을 포함해야 합니다. (예: 블랙 프라이데이 이벤트는 당일뿐만 아니라 그 전후 며칠간 영향을 미칠 수 있음)
  4. prophet() 함수를 사용하여 모델을 생성하고, holidays 인자에 3번에서 만든 데이터프레임을 전달하여 휴일 효과를 포함시키세요.
  5. make_future_dataframe()predict() 함수를 사용하여 향후 90일간의 트래픽을 예측하세요.
  6. plot() 함수로 예측 결과를 시각화하고, prophet_plot_components() 함수를 사용하여 모델이 학습한 추세, 계절성, 휴일 효과를 개별적으로 시각화하여 분석하세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages("prophet")
library(prophet)
library(dplyr)
library(ggplot2)

# 1. 가상 일별 트래픽 데이터 생성
set.seed(123)
ds <- seq(as.Date('2021-01-01'), as.Date('2022-12-31'), by = 'd')
x <- 1:length(ds)
yearly_seasonality <- 1500 * sin(2 * pi * x / 365.25)
weekly_seasonality <- 500 * sin(2 * pi * as.numeric(format(ds, "%w")) / 7) # 일요일(0)에 낮음
trend <- 50 * x
noise <- rnorm(length(ds), 0, 300)
y <- 5000 + trend + yearly_seasonality + weekly_seasonality + noise

# 블랙 프라이데이 효과 추가 (11월 넷째 주 금요일)
bf_2021 <- as.Date("2021-11-26")
bf_2022 <- as.Date("2022-11-25")
y[ds == bf_2021] <- y[ds == bf_2021] + 8000
y[ds == bf_2022] <- y[ds == bf_2022] + 9000

df <- data.frame(ds, y)

# 2. Prophet 입력 형식으로 변경 (이미 ds, y로 생성됨)

# 3. 휴일(프로모션) 데이터프레임 생성
holidays <- tibble(
  holiday = 'black_friday',
  ds = as.Date(c('2021-11-26', '2022-11-25', '2023-11-24')),
  lower_window = -2, # 이벤트 2일 전부터
  upper_window = 1   # 이벤트 1일 후까지 영향
)

# 4. Prophet 모델 생성 및 학습
m <- prophet(holidays = holidays)
m <- fit.prophet(m, df)

# 5. 미래 예측
future <- make_future_dataframe(m, periods = 90)
forecast <- predict(m, future)

# 6. 결과 시각화
# 전체 예측 결과
plot(m, forecast) +
  labs(title = "웹사이트 트래픽 예측 (Prophet)", x = "날짜", y = "방문자 수")

# 모델 구성 요소 시각화
prophet_plot_components(m, forecast)

해설

이 문제는 시계열 분석의 현대적인 접근법 중 하나인 prophet 패키지의 사용법을 다룹니다. prophet은 ARIMA와 같은 전통적인 통계 모델과 달리, 시계열을 곡선 피팅 문제로 간주하는 일반화 덧셈 모델(Generalized Additive Model, GAM)에 기반합니다.

$$ y(t) = g(t) + s(t) + h(t) + \epsilon_t $$

  • $g(t)$: 추세(trend) 함수. 기본적으로 조각별 선형(piecewise linear) 모델을 사용하며, 자동으로 추세 변화점(changepoint)을 감지합니다.
  • $s(t)$: 계절성(seasonality) 함수. 푸리에 급수(Fourier series)를 사용하여 주별, 연별 등 복수의 주기적 패턴을 모델링합니다.
  • $h(t)$: 휴일(holiday) 효과. 분석가가 지정한 특별한 이벤트의 영향을 모델링합니다.
  • $\epsilon_t$: 오차항. 정규분포를 따르는 노이즈로 가정합니다.

코드 해설:

  1. 데이터 준비: prophet은 입력 데이터프레임의 열 이름이 ds(datestamp)와 y(value)로 정해져 있어야 합니다. dsDate 또는 POSIXct 형식이어야 합니다.
  2. holidays 데이터프레임: 이것이 prophet의 강력한 기능 중 하나입니다.
    • holiday: 이벤트의 이름입니다. 같은 이름의 이벤트는 동일한 효과를 공유하는 것으로 모델링됩니다.
    • ds: 이벤트가 발생한 날짜입니다.
    • lower_window, upper_window: 이벤트의 영향이 미치는 기간을 설정합니다. lower_window = -2는 이벤트 2일 전부터, upper_window = 1은 이벤트 1일 후까지 영향을 고려하라는 의미입니다.
  3. prophet()fit.prophet(): prophet() 함수로 모델 객체의 설정을 초기화하고, fit.prophet() 함수에 데이터프레임을 전달하여 실제 모델을 학습시킵니다.
  4. make_future_dataframe()predict(): forecast()와 유사한 역할을 합니다. make_future_dataframe()은 학습 데이터의 날짜에 더해 지정된 기간(periods)만큼의 미래 날짜를 포함하는 데이터프레임을 생성합니다. predict()는 이 미래 데이터프레임을 입력받아 각 날짜에 대한 예측값(yhat), 불확실성 구간(yhat_lower, yhat_upper), 그리고 각 구성 요소의 예측값을 반환합니다.
  5. prophet_plot_components(): 모델이 분해한 시계열의 각 구성 요소를 시각화해주는 매우 유용한 함수입니다.
    • trend: 데이터의 장기적인 성장 추세를 보여줍니다.
    • holidays: 'black_friday' 이벤트가 트래픽에 약 8000 정도의 추가적인 긍정적 효과를 가져왔음을 정량적으로 보여줍니다.
    • weekly: 주별 계절성 패턴을 보여줍니다. (예: 주말에 트래픽이 감소하고 주중에 증가)
    • yearly: 연간 계절성 패턴을 보여줍니다. (예: 특정 계절에 트래픽이 높음)

이처럼 prophet은 모델의 각 구성 요소를 명확하게 분리하여 해석할 수 있게 해주므로, "왜" 예측이 그렇게 나왔는지 설명하기 용이하여 비즈니스 현장에서 매우 유용하게 사용됩니다.


295. 금융 시계열의 변동성 예측: GARCH 모델링

문제 상황: 당신은 금융 리스크 관리팀의 퀀트 분석가입니다. 주식 수익률 데이터는 종종 변동성 군집(volatility clustering) 현상을 보입니다. 즉, 변동성이 큰 시기(수익률의 등락이 심함)와 변동성이 작은 시기(수익률이 안정적임)가 번갈아 나타나는 경향이 있습니다. 이러한 '조건부 이분산성(conditional heteroscedasticity)'을 모델링하는 것은 리스크 측정(예: VaR, Value at Risk)에 매우 중요합니다.

ARIMA와 같은 모델은 시계열의 조건부 평균(conditional mean)을 모델링하지만, 조건부 분산(conditional variance), 즉 변동성은 모델링하지 못합니다. GARCH(Generalized Autoregressive Conditional Heteroskedasticity) 모델은 바로 이 시간에 따라 변하는 변동성을 모델링하기 위해 고안되었습니다.

S&P 500 지수 수익률 데이터에 대해 ARIMA 모델로 평균을 모델링하고, 그 잔차(residual)에 대해 GARCH 모델을 적용하여 변동성을 모델링하고 예측하는 것이 당신의 임무입니다.

과제 지시 사항:

  1. quantmod를 사용하여 2015-01-01부터 2022-12-31까지 S&P 500 지수(^GSPC) 데이터를 가져오고, 로그 수익률을 계산하세요.
  2. auto.arima()를 사용하여 수익률 시계열에 대한 최적의 ARMA 모델을 찾으세요. (수익률은 이미 차분된 데이터이므로 ARIMA(p,0,q) 즉, ARMA(p,q) 모델을 찾게 됩니다.)
  3. 적합된 ARMA 모델의 잔차(residuals)를 추출하고, 잔차의 제곱에 대해 Ljung-Box 검정을 수행하여 자기상관(ARCH 효과)이 존재하는지 통계적으로 확인하세요.
  4. rugarch 패키지를 사용하여 표준 GARCH(1,1) 모델을 지정하고(ugarchspec), 수익률 데이터에 적합시키세요(ugarchfit). ARMA(p,q) 부분도 함께 모델링하도록 armaOrder를 설정합니다.
  5. 적합된 GARCH 모델을 사용하여 향후 10일간의 조건부 표준편차(변동성)를 예측(ugarchforecast)하고, 결과를 시각화하세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("quantmod", "forecast", "rugarch"))
library(quantmod)
library(forecast)
library(rugarch)

# 1. S&P 500 데이터 로드 및 로그 수익률 계산
getSymbols("^GSPC", src = "yahoo", from = "2015-01-01", to = "2022-12-31")
gspc_close <- Ad(GSPC)
gspc_returns <- na.omit(diff(log(gspc_close)))

# 2. 수익률에 대한 ARMA 모델 적합
# allowdrift=FALSE, stepwise=FALSE 로 더 정밀하게 탐색
mean_model_fit <- auto.arima(gspc_returns, stationary = TRUE, seasonal = FALSE, 
                             allowdrift = FALSE, stepwise = FALSE)
summary(mean_model_fit)
# auto.arima가 찾은 ARMA 차수를 확인 (e.g., ARMA(1,1))
arma_order <- arimaorder(mean_model_fit)
p <- arma_order["p"]
q <- arma_order["q"]

# 3. 잔차의 ARCH 효과 검정
residuals <- residuals(mean_model_fit)
squared_residuals <- residuals^2
Box.test(squared_residuals, lag = 10, type = "Ljung-Box")
# p-value가 매우 작으므로 귀무가설(자기상관이 없다)을 기각. ARCH 효과가 존재함.

# 4. GARCH(1,1) 모델 지정 및 적합
# 표준 GARCH(1,1) 모델, 정규분포 가정
# armaOrder를 auto.arima 결과로 설정
spec <- ugarchspec(
  variance.model = list(model = "sGARCH", garchOrder = c(1, 1)),
  mean.model = list(armaOrder = c(p, q), include.mean = TRUE),
  distribution.model = "norm"
)

garch_fit <- ugarchfit(spec = spec, data = gspc_returns)
print(garch_fit)

# 5. 변동성 예측 및 시각화
garch_forecast <- ugarchforecast(garch_fit, n.ahead = 10)

# 예측된 조건부 표준편차(sigma)
volatility_forecast <- sigma(garch_forecast)

plot(volatility_forecast, main = "10일간의 변동성(Conditional Sigma) 예측")

해설

이 문제는 고급 금융 시계열 분석 기법인 GARCH 모델링을 다룹니다.

  1. 변동성 군집(Volatility Clustering): 수익률 그래프를 보면, 수익률의 변동이 큰 기간(예: 2020년 코로나 팬데믹 초기)과 작은 기간이 뭉쳐서 나타나는 것을 볼 수 있습니다. 이는 분산이 시간에 따라 일정하지 않다는 '이분산성'의 증거입니다.

  2. ARCH 효과 검정: GARCH 모델링이 필요한지 확인하는 단계입니다.

    • 먼저 ARMA 모델로 시계열의 평균을 모델링하고, 그 오차항(잔차)을 얻습니다.
    • 만약 변동성에 군집 현상이 있다면, 오늘의 큰 변동은 어제의 큰 변동과 관련이 있을 것입니다. 즉, 잔차의 제곱($\epsilon_t^2$) 값들 사이에 자기상관이 존재할 것입니다.
    • Ljung-Box 검정을 잔차의 제곱에 적용하여 이 자기상관이 통계적으로 유의미한지 확인합니다. p-value가 유의수준(예: 0.05)보다 작으면 ARCH 효과가 존재한다고 판단하고 GARCH 모델링을 진행합니다.
  3. GARCH(1,1) 모델: GARCH 모델은 현재의 조건부 분산($\sigma_t^2$)이 과거의 정보에 의존한다고 가정합니다. GARCH(p,q) 모델의 일반식은 다음과 같습니다. $$ \sigma_t^2 = \omega + \sum_{i=1}^{q} \alpha_i \epsilon_{t-i}^2 + \sum_{j=1}^{p} \beta_j \sigma_{t-j}^2 $$

    • $\epsilon_{t-i}^2$: ARCH 항. 과거의 충격(잔차의 제곱)이 현재 변동성에 미치는 영향.
    • $\sigma_{t-j}^2$: GARCH 항. 과거의 조건부 분산 자체가 현재 변동성에 미치는 영향 (변동성의 지속성).
    • 가장 널리 사용되는 GARCH(1,1) 모델의 식은 다음과 같습니다. $$ \sigma_t^2 = \omega + \alpha_1 \epsilon_{t-1}^2 + \beta_1 \sigma_{t-1}^2 $$
    • $\alpha_1$: 어제의 충격이 오늘 변동성에 미치는 영향의 크기.
    • $\beta_1$: 어제의 변동성이 오늘 변동성으로 이어지는 지속성의 크기.
    • $\alpha_1 + \beta_1$ 값이 1에 가까울수록 변동성 충격이 오래 지속됨을 의미합니다.
  4. rugarch 패키지: R에서 GARCH 모델링을 위한 표준 패키지입니다.

    • ugarchspec(): 모델의 명세(specification)를 정의합니다. variance.model에서 GARCH 모델의 종류와 차수를, mean.model에서 ARMA 모델의 차수를, distribution.model에서 잔차의 분포(정규분포, t-분포 등)를 설정합니다.
    • ugarchfit(): 정의된 명세와 데이터를 바탕으로 모델의 계수($\omega, \alpha_1, \beta_1$ 등)를 최우추정법(Maximum Likelihood Estimation)으로 추정합니다.
    • ugarchforecast(): 적합된 모델을 사용하여 미래의 조건부 평균과 조건부 분산(또는 표준편차)을 예측합니다.

결과 플롯은 향후 10일간의 일별 변동성(조건부 표준편차) 예측치를 보여줍니다. 이 정보는 옵션 가격 결정, 파생상품 헤징, 리스크 관리 등 다양한 금융 분야에서 핵심적인 입력값으로 사용됩니다.

네, 알겠습니다. 세계 최고의 데이터 과학자이자 R 프로그래밍 교육 전문가로서, 사용자의 요구에 맞춰 최종 종합 프로젝트 레벨의 심도 있는 R 프로그래밍 문제들을 생성해 드리겠습니다. 데이터 전처리부터 모델링, 시각화까지 전 과정을 아우르는 실전적인 문제들입니다.


296. 이커머스 VIP 고객 발굴을 위한 RFM 분석 및 K-평균 군집화

문제 상황: 당신은 대규모 온라인 패션 쇼핑몰 'R-Style'의 데이터 분석 총괄 책임자입니다. 최근 마케팅팀으로부터 "충성도 높은 VIP 고객 그룹을 정의하고, 이들에게 맞춤형 프로모션을 제공하여 매출을 극대화하고 싶다"는 요청을 받았습니다. 이를 위해 고객의 구매 이력 데이터를 활용하여 고객을 여러 그룹으로 분류(Segmentation)하는 프로젝트를 진행하기로 했습니다. 가장 대표적인 고객 분석 기법인 RFM 분석과 K-평균(K-Means) 군집화 알고리즘을 사용하여 VIP 고객을 식별하고 그들의 특징을 분석하는 파이프라인을 구축해야 합니다.

과제 지시 사항:

  1. 분석에 필요한 가상의 고객 거래 데이터를 생성하세요. 데이터는 customer_id, order_date, purchase_amount 열을 포함해야 합니다.
  2. 각 고객별로 RFM(Recency, Frequency, Monetary) 지표를 계산하세요.
    • Recency: 기준일(예: 가장 최근 거래일 + 1일)로부터 마지막 구매일까지 얼마나 지났는지 (일수, 낮을수록 좋음).
    • Frequency: 총 구매 횟수 (높을수록 좋음).
    • Monetary: 총 구매 금액 (높을수록 좋음).
  3. 계산된 RFM 지표들은 서로 다른 척도(scale)를 가지므로, K-평균 군집화 모델의 성능을 높이기 위해 각 지표를 표준화(Standard Scaling)하세요.
  4. 표준화된 데이터를 사용하여 최적의 군집 수(k)를 결정하기 위해 '엘보우 방법(Elbow Method)'을 사용하고 시각화하세요.
  5. 결정된 최적의 k를 사용하여 K-평균 군집화를 수행하고, 각 고객에게 군집 번호를 할당하세요.
  6. 각 군집의 RFM 평균값을 계산하여 군집별 특성을 분석하고, 이를 시각화하여 어떤 군집이 'VIP 고객' 그룹인지 해석하세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("dplyr", "lubridate", "ggplot2", "cluster", "factoextra"))
library(dplyr)
library(lubridate)
library(ggplot2)
library(cluster)
library(factoextra)

# 1. 가상 고객 거래 데이터 생성
set.seed(42)
n_customers <- 1000
n_transactions <- 5000
customers <- 1:n_customers
transaction_data <- tibble(
  customer_id = sample(customers, n_transactions, replace = TRUE),
  order_date = as_date("2022-01-01") + days(sample(1:730, n_transactions, replace = TRUE)),
  purchase_amount = round(runif(n_transactions, 10, 300), 2)
)

# 2. RFM 지표 계산
analysis_date <- max(transaction_data$order_date) + days(1)

rfm_data <- transaction_data %>%
  group_by(customer_id) %>%
  summarise(
    Recency = as.numeric(analysis_date - max(order_date)),
    Frequency = n(),
    Monetary = sum(purchase_amount)
  ) %>%
  ungroup()

# 3. RFM 지표 표준화
# Recency는 값이 작을수록 좋으므로, -1을 곱해 다른 지표와 방향성을 맞추거나 그대로 사용.
# 여기서는 그대로 사용하고 군집 해석 시 고려.
rfm_scaled <- rfm_data %>%
  select(Recency, Frequency, Monetary) %>%
  scale() %>%
  as_tibble()

# 4. 최적의 군집 수(k) 결정 (엘보우 방법)
fviz_nbclust(rfm_scaled, kmeans, method = "wss") +
  labs(subtitle = "Elbow Method")

# 위 그래프에서 k=4 또는 k=5에서 꺾이는 지점이 보임. k=4로 결정.

# 5. K-평균 군집화 수행
k <- 4
set.seed(42)
kmeans_result <- kmeans(rfm_scaled, centers = k, nstart = 25)

# 고객 데이터에 군집 번호 할당
rfm_data_clustered <- rfm_data %>%
  mutate(Cluster = as.factor(kmeans_result$cluster))

# 6. 군집별 특성 분석 및 시각화
cluster_summary <- rfm_data_clustered %>%
  group_by(Cluster) %>%
  summarise(
    Avg_Recency = mean(Recency),
    Avg_Frequency = mean(Frequency),
    Avg_Monetary = mean(Monetary),
    Count = n()
  ) %>%
  arrange(desc(Avg_Monetary))

print(cluster_summary)

# 시각화 1: 군집별 RFM 평균
ggplot(cluster_summary, aes(x = Cluster, y = Avg_Monetary, fill = Cluster)) +
  geom_bar(stat = "identity") +
  labs(title = "Monetary Value by Cluster", y = "Average Monetary Value") +
  theme_minimal()

ggplot(cluster_summary, aes(x = Cluster, y = Avg_Frequency, fill = Cluster)) +
  geom_bar(stat = "identity") +
  labs(title = "Frequency by Cluster", y = "Average Frequency") +
  theme_minimal()

ggplot(cluster_summary, aes(x = Cluster, y = Avg_Recency, fill = Cluster)) +
  geom_bar(stat = "identity") +
  labs(title = "Recency by Cluster", y = "Average Recency (Lower is better)") +
  theme_minimal()

# 시각화 2: 군집 분포 (PCA 활용)
fviz_cluster(kmeans_result, data = rfm_scaled,
             palette = "jco",
             geom = "point",
             ellipse.type = "convex",
             ggtheme = theme_bw())

해설

이 문제는 실제 마케팅 분석에서 매우 빈번하게 사용되는 고객 세분화 프로젝트의 전 과정을 담고 있습니다.

  1. 데이터 생성: tibblesample 함수를 이용해 현실적인 거래 데이터를 시뮬레이션했습니다. set.seed(42)를 사용하여 언제 실행해도 동일한 결과가 나오도록 재현성을 보장합니다.

  2. RFM 지표 계산: dplyr 패키지의 group_bysummarise를 사용하여 고객별로 그룹화한 뒤 RFM 지표를 계산합니다.

    • Recency: 분석 기준일(analysis_date)에서 고객별 마지막 거래일(max(order_date))을 빼서 계산합니다. 이 값은 작을수록 최근 고객임을 의미합니다.
    • Frequency: n() 함수를 사용하여 각 고객 그룹의 행 수, 즉 거래 횟수를 셉니다.
    • Monetary: sum(purchase_amount)로 총 구매액을 계산합니다.
  3. 데이터 표준화: K-평균 군집화는 유클리드 거리를 기반으로 작동하기 때문에, 변수들의 척도(scale)에 매우 민감합니다. 예를 들어, Monetary(수십만 단위)가 Recency(수백 단위)보다 군집 결과에 훨씬 큰 영향을 미치게 됩니다. 이를 방지하기 위해 scale() 함수를 사용하여 모든 변수를 평균이 0, 표준편차가 1인 표준정규분포로 변환합니다. 이를 Z-score 표준화라고 하며, 수식은 다음과 같습니다. $$ Z = \frac{X - \mu}{\sigma} $$ 여기서 $X$는 원본 값, $\mu$는 변수의 평균, $\sigma$는 변수의 표준편차입니다.

  4. 최적의 k 결정: K-평균은 사전에 군집의 수(k)를 지정해야 하는 알고리즘입니다. 최적의 k를 찾는 방법 중 하나인 엘보우 방법은 k를 1부터 늘려가면서 각 k에 대한 군집 내 총 제곱합(Total Within-cluster Sum of Squares, WSS)을 계산하고, 이 값이 급격히 줄어들다가 완만해지는 지점('팔꿈치' 모양)을 최적의 k로 선택하는 방법입니다. factoextra::fviz_nbclust 함수는 이 과정을 자동화하고 시각화해줍니다.

  5. K-평균 군집화: stats::kmeans 함수를 사용해 군집화를 수행합니다. centers는 군집의 수, nstart는 무작위 초기 중심점 설정을 여러 번 반복하여 가장 좋은 결과를 선택하도록 하는 옵션입니다. 결과로 나온 kmeans_result$cluster에 각 데이터 포인트가 속한 군집 번호가 저장되어 있습니다.

  6. 결과 해석 및 시각화:

    • cluster_summary를 통해 각 군집의 RFM 평균값을 비교합니다. 예를 들어, Recency가 가장 낮고(최근 방문), Frequency와 Monetary가 가장 높은 군집이 우리가 찾던 'VIP 고객' 그룹일 가능성이 높습니다.
    • ggplot2를 이용한 막대그래프는 각 군집의 특성을 직관적으로 비교할 수 있게 해줍니다.
    • factoextra::fviz_cluster는 주성분분석(PCA)을 이용해 고차원(3D)의 RFM 데이터를 2차원 평면에 시각화하여 군집들이 어떻게 분리되었는지 보여주는 강력한 도구입니다.

이 파이프라인을 통해 마케팅팀은 특정 군집(예: VIP 그룹)의 특성을 명확히 이해하고, 이들에게만 특별 할인 쿠폰을 발송하거나 신제품 우선 체험 기회를 제공하는 등 정교한 타겟 마케팅 전략을 수립할 수 있습니다.


297. 게임 유저 이탈 예측 모델링 및 주요 이탈 원인 분석

문제 상황: 당신은 인기 모바일 게임 'R-Quest'의 데이터 분석가입니다. 최근 게임의 월간 활성 사용자(MAU)가 감소하는 추세가 보여, 경영진은 유저 이탈(Churn)을 방지하기 위한 대책 마련을 지시했습니다. 당신의 임무는 유저들의 게임 플레이 데이터를 분석하여, 어떤 유저가 앞으로 1주일 내에 이탈할지를 예측하는 머신러닝 모델을 구축하고, 어떤 요인이 이탈에 가장 큰 영향을 미치는지 분석하는 것입니다.

과제 지시 사항:

  1. 가상의 게임 유저 활동 데이터를 생성하세요. 데이터는 player_id, login_days (최근 30일간 총 로그인 일수), session_duration_avg (평균 세션 시간(분)), item_purchase_count (아이템 구매 횟수), friend_count (친구 수), 그리고 타겟 변수인 churn_next_week (다음 주 이탈 여부, 1=이탈, 0=유지)를 포함해야 합니다.
  2. 데이터를 훈련(Training) 데이터와 테스트(Test) 데이터로 7:3 비율로 분리하세요.
  3. 로지스틱 회귀(Logistic Regression) 모델을 훈련 데이터에 적합(fit)시키세요.
  4. 훈련된 모델을 사용하여 테스트 데이터의 이탈 확률을 예측하세요. 예측 확률이 0.5 이상이면 이탈(1), 미만이면 유지(0)로 분류하세요.
  5. 모델의 성능을 평가하기 위해 혼동 행렬(Confusion Matrix)을 생성하고, 이를 바탕으로 정확도(Accuracy), 정밀도(Precision), 재현율(Recall)을 계산하여 모델의 신뢰성을 평가하세요.
  6. 모델의 회귀 계수(coefficients)를 해석하여 어떤 게임 활동 변수가 유저 이탈에 가장 큰 영향을 미치는지 분석하고, 결과를 시각화하여 보고하세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("dplyr", "ggplot2", "caret", "pROC"))
library(dplyr)
library(ggplot2)
library(caret)
library(pROC)

# 1. 가상 게임 유저 활동 데이터 생성
set.seed(123)
n_players <- 2000
player_data <- tibble(
  player_id = 1:n_players,
  login_days = rpois(n_players, lambda = 15),
  session_duration_avg = rnorm(n_players, mean = 45, sd = 15),
  item_purchase_count = rpois(n_players, lambda = 5),
  friend_count = rpois(n_players, lambda = 10)
)

# 이탈(Churn) 변수 생성 (논리적 관계 부여)
# 로그인을 적게 하고, 세션 시간이 짧고, 구매/친구가 적을수록 이탈 확률 높음
prob_churn <- 1 / (1 + exp(-(
  -0.15 * player_data$login_days + 
  -0.05 * player_data$session_duration_avg +
  -0.2 * player_data$item_purchase_count + 
  -0.1 * player_data$friend_count + 5
)))
player_data$churn_next_week <- rbinom(n_players, 1, prob_churn)
player_data$churn_next_week <- factor(player_data$churn_next_week, levels = c(0, 1), labels = c("Stay", "Churn"))

# 2. 훈련/테스트 데이터 분리
set.seed(123)
train_index <- createDataPartition(player_data$churn_next_week, p = 0.7, list = FALSE)
train_data <- player_data[train_index, ]
test_data <- player_data[-train_index, ]

# 3. 로지스틱 회귀 모델 훈련
churn_model <- glm(churn_next_week ~ login_days + session_duration_avg + item_purchase_count + friend_count,
                   data = train_data,
                   family = "binomial")

summary(churn_model)

# 4. 테스트 데이터 예측
predicted_probs <- predict(churn_model, newdata = test_data, type = "response")
predicted_classes <- ifelse(predicted_probs > 0.5, "Churn", "Stay")
predicted_classes <- factor(predicted_classes, levels = c("Stay", "Churn"))

# 5. 모델 성능 평가 (혼동 행렬 및 주요 지표)
conf_matrix <- confusionMatrix(predicted_classes, test_data$churn_next_week)
print(conf_matrix)

# 정확도, 정밀도, 재현율 직접 계산
accuracy <- conf_matrix$overall['Accuracy']
precision <- conf_matrix$byClass['Pos Pred Value']
recall <- conf_matrix$byClass['Sensitivity']

cat(paste("Accuracy:", round(accuracy, 4), "\n"))
cat(paste("Precision:", round(precision, 4), "\n"))
cat(paste("Recall:", round(recall, 4), "\n"))

# ROC Curve 시각화
roc_curve <- roc(response = test_data$churn_next_week, predictor = predicted_probs)
ggroc(roc_curve) +
  ggtitle(paste0("ROC Curve (AUC = ", round(auc(roc_curve), 3), ")")) +
  theme_minimal()

# 6. 회귀 계수 분석 및 시각화
coef_summary <- summary(churn_model)$coefficients %>%
  as.data.frame() %>%
  tibble::rownames_to_column("Variable") %>%
  filter(Variable != "(Intercept)")

ggplot(coef_summary, aes(x = reorder(Variable, Estimate), y = Estimate)) +
  geom_col(fill = "skyblue") +
  geom_errorbar(aes(ymin = Estimate - `Std. Error`, ymax = Estimate + `Std. Error`), width = 0.2) +
  coord_flip() +
  labs(title = "Feature Importance in Churn Prediction",
       x = "Variables", y = "Coefficient Estimate (Log-Odds)") +
  theme_bw()

해설

이 문제는 데이터 과학의 핵심 역량 중 하나인 '분류 모델링'과 '모델 해석'을 다루는 종합적인 프로젝트입니다.

  1. 데이터 생성: 실제 게임 환경을 가정하여 이탈에 영향을 미칠 만한 변수들을 생성했습니다. 특히 churn_next_week 변수는 다른 변수들과 논리적 관계를 갖도록 plogis (로지스틱 함수 1/(1+exp(-x)))의 역함수 형태를 이용해 생성하여, 모델이 유의미한 패턴을 학습할 수 있도록 설계했습니다.

  2. 데이터 분리: caret::createDataPartition 함수는 타겟 변수의 비율을 유지하면서 데이터를 분리(Stratified Sampling)해주기 때문에, 분류 문제에서 데이터 쏠림 현상을 방지하는 데 매우 유용합니다.

  3. 로지스틱 회귀: 이탈 여부(0 또는 1)와 같은 이진 분류 문제에 가장 기본적이면서도 해석력이 뛰어난 모델입니다. glm 함수에 family = "binomial" 옵션을 주어 로지스틱 회귀를 수행합니다. 모델의 결과는 직접적으로 확률을 예측하는 것이 아니라, '로그-오즈(Log-Odds)'를 예측합니다. $$ \ln\left(\frac{P(Y=1)}{1-P(Y=1)}\right) = \beta_0 + \beta_1 X_1 + \dots + \beta_p X_p $$ 이 식을 확률 $P(Y=1)$에 대해 풀면 우리가 아는 로지스틱 함수(시그모이드 함수)가 됩니다.

  4. 예측: predict 함수에서 type = "response" 옵션을 주면 모델이 예측한 로그-오즈를 0과 1 사이의 확률 값으로 변환하여 반환해줍니다. 이 확률을 기준으로 임계값(보통 0.5)을 정해 최종 클래스를 분류합니다.

  5. 성능 평가:

    • 혼동 행렬(Confusion Matrix): 모델의 예측이 실제 값과 얼마나 일치하는지를 보여주는 표입니다. (True Positive, False Positive, True Negative, False Negative)
    • 정확도(Accuracy): 전체 예측 중 올바르게 예측한 비율. (TP+TN)/(TP+FP+TN+FN)
    • 정밀도(Precision): 모델이 '이탈'이라고 예측한 것 중 실제로 '이탈'한 비율. TP/(TP+FP). 이 지표는 모델의 예측을 얼마나 신뢰할 수 있는지를 나타냅니다. (예: 이탈 예측 유저에게 비싼 쿠폰을 줄 때 중요)
    • 재현율(Recall/Sensitivity): 실제 '이탈'한 유저 중 모델이 '이탈'이라고 예측해낸 비율. TP/(TP+FN). 이 지표는 놓치지 않고 얼마나 잘 잡아내는지를 나타냅니다. (예: 이탈 유저를 한 명이라도 더 잡아내는 것이 중요할 때)
    • ROC Curve & AUC: 모델의 분류 성능을 종합적으로 보여주는 지표입니다. 곡선이 좌측 상단에 가까울수록(AUC가 1에 가까울수록) 좋은 모델입니다.
  6. 계수 해석: 로지스틱 회귀의 가장 큰 장점은 '해석 가능성'입니다.

    • summary(churn_model)을 통해 각 변수의 계수(Estimate), 표준 오차, p-value 등을 확인할 수 있습니다.
    • 계수의 부호: 음수(-) 계수는 해당 변수 값이 증가할수록 이탈 확률(로그-오즈)이 감소함을 의미합니다. 코드 결과에서 모든 변수(login_days, session_duration_avg 등)가 음수 계수를 가지므로, 이 활동들이 활발할수록 유저는 게임에 머무를 가능성이 높다는 합리적인 결론을 내릴 수 있습니다.
    • 계수의 크기: 계수의 절대값이 클수록 이탈 확률에 더 큰 영향을 미칩니다. 시각화된 그래프를 보면 item_purchase_countlogin_days가 이탈 방지에 상대적으로 더 중요한 요소임을 알 수 있습니다. 이 결과를 바탕으로 게임 기획팀은 '아이템 구매 유도 이벤트'나 '연속 출석 보상 강화'와 같은 구체적인 전략을 수립할 수 있습니다.

298. 신약 임상시험 데이터 기반 생존 분석 (Survival Analysis)

문제 상황: 당신은 제약회사의 임상 데이터 분석팀 소속 수석 연구원입니다. 새로운 항암제 'R-mab'의 3상 임상시험이 종료되었고, 경영진은 이 약이 기존 표준 치료법(위약, Placebo) 대비 환자의 생존 기간을 유의미하게 연장시키는지를 보고받기 원합니다. 당신의 임무는 임상시험 데이터를 바탕으로 생존 분석을 수행하여 두 치료 그룹 간의 생존 곡선을 비교하고, 약물의 효과를 통계적으로 검증하는 것입니다.

과제 지시 사항:

  1. 가상의 임상시험 데이터를 생성하세요. 데이터는 patient_id, treatment_group ('R-mab', 'Placebo'), age, biomarker_level, time (추적 관찰 기간 또는 사망까지의 기간), status (1=사망(event), 0=중도 탈락/관찰 종료(censored)) 열을 포함해야 합니다.
  2. survival 패키지를 사용하여 생존 객체(Surv object)를 생성하세요.
  3. 카플란-마이어(Kaplan-Meier) 생존 곡선을 계산하고, survminer 패키지를 사용하여 두 치료 그룹의 생존 곡선을 한 플롯에 시각화하세요. 생존 확률표(survival probability table)도 함께 표시하세요.
  4. 로그-순위 검정(Log-rank test)을 수행하여 두 그룹의 생존 곡선 간에 통계적으로 유의미한 차이가 있는지 검증하세요.
  5. 콕스 비례위험 모델(Cox Proportional Hazards Model)을 구축하여, 환자의 나이(age)와 바이오마커 수치(biomarker_level)를 통제한 상태에서 치료법(treatment_group)이 생존에 미치는 영향을 분석하세요. 모델의 위험비(Hazard Ratio)를 해석하여 보고하세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("survival", "survminer", "dplyr"))
library(survival)
library(survminer)
library(dplyr)

# 1. 가상 임상시험 데이터 생성
set.seed(300)
n_patients <- 200
clinical_data <- tibble(
  patient_id = 1:n_patients,
  treatment_group = sample(c("R-mab", "Placebo"), n_patients, replace = TRUE),
  age = rnorm(n_patients, mean = 60, sd = 8),
  biomarker_level = rnorm(n_patients, mean = 100, sd = 20)
)

# 생존 데이터 생성 (약물 효과 반영)
# R-mab 그룹의 생존 기간이 더 길도록 설정
hazard_rmab <- exp(-1.5 + 0.02 * clinical_data$age[clinical_data$treatment_group == "R-mab"])
hazard_placebo <- exp(-0.5 + 0.02 * clinical_data$age[clinical_data$treatment_group == "Placebo"])

time_rmab <- rexp(sum(clinical_data$treatment_group == "R-mab"), rate = hazard_rmab)
time_placebo <- rexp(sum(clinical_data$treatment_group == "Placebo"), rate = hazard_placebo)

clinical_data$time <- 0
clinical_data$time[clinical_data$treatment_group == "R-mab"] <- time_rmab
clinical_data$time[clinical_data$treatment_group == "Placebo"] <- time_placebo

# 중도 절단(Censoring) 데이터 생성 (관찰 기간은 최대 60개월)
clinical_data <- clinical_data %>%
  mutate(
    censoring_time = runif(n(), min = 30, max = 60),
    status = ifelse(time <= censoring_time, 1, 0), # event 발생
    time = ifelse(time <= censoring_time, time, censoring_time) # 관찰 시간 업데이트
  ) %>%
  select(-censoring_time)

# 2. 생존 객체 생성
surv_obj <- Surv(time = clinical_data$time, event = clinical_data$status)

# 3. 카플란-마이어 생존 곡선 분석 및 시각화
km_fit <- survfit(surv_obj ~ treatment_group, data = clinical_data)
summary(km_fit)

ggsurvplot(
  km_fit,
  data = clinical_data,
  pval = TRUE,             # 로그-순위 검정 p-value 표시
  conf.int = TRUE,         # 신뢰 구간 표시
  risk.table = TRUE,       # 위험군 테이블 표시
  xlab = "Time in months",
  ylab = "Survival probability",
  title = "Kaplan-Meier Survival Curve for R-mab vs. Placebo",
  legend.labs = c("Placebo", "R-mab"),
  palette = c("#E7B800", "#2E9FDF")
)

# 4. 로그-순위 검정
surv_diff <- survdiff(surv_obj ~ treatment_group, data = clinical_data)
print(surv_diff)

# 5. 콕스 비례위험 모델
cox_model <- coxph(surv_obj ~ treatment_group + age + biomarker_level, data = clinical_data)
summary(cox_model)

# 모델 결과 시각화
ggforest(cox_model, data = clinical_data)

해설

이 문제는 의학 통계 및 제약 산업에서 필수적인 '생존 분석' 파이프라인을 다룹니다. 생존 분석은 단순히 '언제' 사건이 발생하는지 뿐만 아니라, 관찰 기간 동안 사건이 발생하지 않은 '중도 절단(censored)' 데이터를 효과적으로 다루는 것이 핵심입니다.

  1. 데이터 생성: rexp 함수를 이용해 지수분포에서 생존 시간을 샘플링했습니다. rate 파라미터(위험률, hazard)를 'R-mab' 그룹에서 더 낮게 설정하여 약물 효과를 시뮬레이션했습니다. status 변수는 환자가 사망(event=1)했는지, 아니면 연구 종료나 개인 사유로 추적이 중단(censored=0)되었는지를 나타내는 매우 중요한 변수입니다.

  2. 생존 객체: survival::Surv() 함수는 시간(time)과 상태(status) 정보를 결합하여 생존 분석용 특수 객체를 만듭니다. 이 객체는 이후 모든 생존 분석 함수들의 기본 입력값으로 사용됩니다.

  3. 카플란-마이어(Kaplan-Meier) 분석: 그룹별 생존 확률을 시간의 흐름에 따라 추정하는 비모수적(non-parametric) 방법입니다.

    • survfit() 함수로 K-M 모델을 적합합니다.
    • survminer::ggsurvplot()은 K-M 곡선을 매우 전문적이고 아름답게 시각화해주는 강력한 도구입니다. 그래프를 보면 'R-mab' 그룹(파란색 선)이 'Placebo' 그룹(노란색 선)보다 모든 시점에서 생존 확률이 높은 것을 직관적으로 확인할 수 있습니다. risk.table 옵션은 각 시점별로 생존해 있는 환자 수를 보여주어 해석을 돕습니다.
  4. 로그-순위 검정(Log-rank test): "두 그룹의 생존 곡선이 동일하다"는 귀무가설을 검정하는 통계적 방법입니다. survdiff() 함수로 계산하며, ggsurvplot에서 pval=TRUE 옵션을 주면 자동으로 p-value가 계산되어 플롯에 표시됩니다. p-value가 0.05보다 작으면 (예: p < 0.001) 귀무가설을 기각하고, 두 그룹 간에 통계적으로 유의미한 생존율 차이가 있다고 결론 내릴 수 있습니다.

  5. 콕스 비례위험 모델(Cox Proportional Hazards Model): 여러 변수(공변량, covariates)가 생존에 미치는 영향을 동시에 분석하는 준모수적(semi-parametric) 회귀 모델입니다.

    • coxph() 함수를 사용해 모델을 적합합니다. 모델의 기본 가정은 '공변량의 효과(위험비)는 시간에 따라 변하지 않는다(비례위험 가정)'는 것입니다.
    • 핵심 해석: summary(cox_model) 결과에서 exp(coef) 열이 바로 **위험비(Hazard Ratio, HR)**입니다.
      • treatment_groupR-mab의 HR이 1보다 작으면 (예: 0.4), 이는 'R-mab' 그룹이 Placebo 그룹에 비해 특정 시점에서의 사망 위험(hazard)이 60% 낮다는 것을 의미합니다. (1 - 0.4 = 0.6)
      • age의 HR이 1보다 크면 (예: 1.05), 나이가 1살 증가할 때마다 사망 위험이 5%씩 증가함을 의미합니다.
    • ggforest()는 각 변수의 위험비와 신뢰구간을 시각적으로 보여주는 forest plot을 그려주어, 어떤 변수가 위험 요인(HR > 1)이고 어떤 변수가 보호 요인(HR < 1)인지 한눈에 파악하게 해줍니다.

이 분석을 통해 'R-mab'이 위약 대비 통계적으로 유의하게 생존 기간을 연장시키며, 다른 요인들을 통제했을 때 사망 위험을 약 60% 감소시킨다는 강력한 증거를 제시할 수 있습니다.


299. 도시 자전거 공유 시스템 수요 예측 (시계열 분해 및 회귀 모델링)

문제 상황: 당신은 'R-Bike'라는 도시 공공 자전거 공유 시스템의 데이터 분석가입니다. 운영팀은 자전거 재고를 효율적으로 관리하고 수요가 많은 시간대에 자전거를 충분히 배치하기 위해, 시간대별 자전거 대여 수요를 예측하는 모델을 필요로 합니다. 과거 2년간의 시간대별 대여 기록, 날씨 정보, 요일 정보 등을 활용하여 정확한 수요 예측 모델을 구축해야 합니다.

과제 지시 사항:

  1. 가상의 시간대별 자전거 대여 수요 데이터를 생성하세요. 데이터는 datetime (시간), count (대여량), temp (온도), humidity (습도), windspeed (풍속)를 포함해야 합니다.
  2. datetime 변수에서 hour (시간), wday (요일), month (월), year (연도) 등 시계열 분석에 유용한 파생 변수들을 추출하세요.
  3. 시계열 데이터의 패턴(추세, 계절성, 불규칙성)을 파악하기 위해 STL(Seasonal and Trend decomposition using Loess) 분해를 수행하고 결과를 시각화하세요.
  4. 전체 데이터 중 첫 80%를 훈련 데이터로, 나머지 20%를 테스트 데이터로 분할하세요.
  5. 다중 선형 회귀(Multiple Linear Regression) 모델과 랜덤 포레스트(Random Forest) 모델을 각각 구축하여 시간대별 자전거 수요(count)를 예측하세요. 예측 변수로는 시간 관련 파생 변수들과 날씨 변수를 모두 사용합니다.
  6. 두 모델의 성능을 테스트 데이터에서 RMSE(Root Mean Square Error)와 MAE(Mean Absolute Error)를 기준으로 비교하고, 어떤 모델이 더 우수한 예측 성능을 보이는지 결론을 내리세요. 실제값과 예측값을 시각적으로 비교하는 플롯도 그려보세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("dplyr", "lubridate", "ggplot2", "forecast", "randomForest", "Metrics"))
library(dplyr)
library(lubridate)
library(ggplot2)
library(forecast)
library(randomForest)
library(Metrics)

# 1. 가상 자전거 수요 데이터 생성
set.seed(404)
start_date <- ymd_hms("2021-01-01 00:00:00")
end_date <- ymd_hms("2022-12-31 23:00:00")
datetime_seq <- seq(from = start_date, to = end_date, by = "hour")

bike_data <- tibble(datetime = datetime_seq) %>%
  mutate(
    hour = hour(datetime),
    wday = wday(datetime, label = TRUE, week_start = 1),
    month = month(datetime),
    # 계절성, 추세, 요일/시간 패턴, 날씨 효과를 반영한 수요량 생성
    base_trend = 1:n() * 0.01,
    seasonal_yearly = 50 * sin(2 * pi * month / 12),
    seasonal_daily = 40 * sin(2 * pi * hour / 24 - pi/2) + ifelse(hour %in% 7:9 | hour %in% 17:19, 80, 0),
    wday_effect = ifelse(wday %in% c("Sat", "Sun"), -30, 10),
    temp = 15 + 10 * sin(2 * pi * month / 12) + rnorm(n(), 0, 3),
    humidity = 60 - 15 * sin(2 * pi * month / 12) + rnorm(n(), 0, 5),
    windspeed = 10 + rnorm(n(), 0, 3),
    noise = rnorm(n(), 0, 15),
    count = pmax(0, 50 + base_trend + seasonal_yearly + seasonal_daily + wday_effect - 0.5 * humidity + 2 * temp + noise)
  )

# 2. 파생 변수 생성
bike_data <- bike_data %>%
  mutate(
    year = as.factor(year(datetime)),
    month = as.factor(month(datetime)),
    hour = as.factor(hour(datetime)),
    wday = as.factor(wday)
  )

# 3. 시계열 분해 (STL)
bike_ts <- ts(bike_data$count, frequency = 24 * 7) # 주 단위 계절성
stl_result <- stl(bike_ts, s.window = "periodic")
autoplot(stl_result)

# 4. 훈련/테스트 데이터 분할
split_point <- floor(0.8 * nrow(bike_data))
train_data <- bike_data[1:split_point, ]
test_data <- bike_data[(split_point + 1):nrow(bike_data), ]

# 5. 모델 구축
# 모델에 사용할 변수 선택
features <- c("hour", "wday", "month", "year", "temp", "humidity", "windspeed")
target <- "count"
formula <- as.formula(paste(target, "~", paste(features, collapse = "+")))

# 모델 1: 다중 선형 회귀
lm_model <- lm(formula, data = train_data)
summary(lm_model)
lm_preds <- predict(lm_model, newdata = test_data)

# 모델 2: 랜덤 포레스트
set.seed(404)
# 데이터가 크므로 mtry, ntree 등 파라미터 튜닝이 필요하나 여기선 기본값 사용
rf_model <- randomForest(formula, data = train_data, ntree = 100, importance = TRUE)
rf_preds <- predict(rf_model, newdata = test_data)

# 6. 모델 성능 비교
lm_rmse <- rmse(test_data$count, lm_preds)
lm_mae <- mae(test_data$count, lm_preds)

rf_rmse <- rmse(test_data$count, rf_preds)
rf_mae <- mae(test_data$count, rf_preds)

cat("Linear Regression - RMSE:", lm_rmse, "MAE:", lm_mae, "\n")
cat("Random Forest - RMSE:", rf_rmse, "MAE:", rf_mae, "\n")

# 예측 결과 시각화 (테스트 데이터의 일부 기간)
plot_data <- test_data %>%
  slice(1:(24*7)) %>% # 첫 1주일
  mutate(lm_predicted = lm_preds[1:(24*7)],
         rf_predicted = rf_preds[1:(24*7)])

ggplot(plot_data, aes(x = datetime)) +
  geom_line(aes(y = count, color = "Actual"), size = 1) +
  geom_line(aes(y = lm_predicted, color = "Linear Model"), linetype = "dashed") +
  geom_line(aes(y = rf_predicted, color = "Random Forest"), linetype = "dotted", size=1) +
  labs(title = "Bike Demand Prediction vs Actual (First Week of Test Set)",
       y = "Count", x = "Datetime", color = "Legend") +
  scale_color_manual(values = c("Actual" = "black", "Linear Model" = "blue", "Random Forest" = "red")) +
  theme_minimal()

해설

이 문제는 시계열 데이터 분석의 전형적인 파이프라인을 보여줍니다. 특징 공학(feature engineering), 시각적 탐색, 모델링, 성능 평가를 모두 포함합니다.

  1. 데이터 생성: 현실적인 시계열 데이터를 만들기 위해 여러 요소를 조합했습니다. 장기적 추세(trend), 연/일 단위 계절성(seasonality), 요일 효과, 날씨 효과, 그리고 무작위 노이즈(noise)를 모두 합산하여 수요량을 생성했습니다. 이는 실제 데이터가 가지는 복잡한 패턴을 잘 모사합니다.

  2. 파생 변수 생성: lubridate 패키지는 시간 데이터에서 년, 월, 요일, 시간 등의 정보를 쉽게 추출하게 해줍니다. 이 변수들은 모델이 시간적 패턴을 학습하는 데 결정적인 역할을 합니다. as.factor로 변환하는 이유는 회귀 모델이 이를 연속형 숫자가 아닌 범주형 변수로 인식하게 하기 위함입니다.

  3. 시계열 분해(STL): forecast::stl 함수는 시계열 데이터를 Trend(추세), Seasonal(계절성), Remainder(나머지/불규칙) 세 가지 요소로 분해합니다.

    • Trend: 데이터의 장기적인 증가 또는 감소 경향.
    • Seasonal: 특정 주기로 반복되는 패턴 (예: 하루, 일주일, 일년).
    • Remainder: 추세와 계절성으로 설명되지 않는 나머지 변동성. autoplot으로 시각화하면 데이터의 기저 패턴을 한눈에 파악할 수 있어 모델링 전략 수립에 큰 도움이 됩니다.
  4. 데이터 분할: 시계열 데이터는 시간적 순서가 중요하므로, 무작위로 섞는 sample 방식이 아닌, 시간 순서에 따라 과거 데이터를 훈련용으로, 미래 데이터를 테스트용으로 분할해야 합니다.

  5. 모델링:

    • 다중 선형 회귀(lm): 변수와 결과 간의 선형 관계를 가정하는 간단하고 해석이 용이한 모델입니다. 각 변수가 수요량에 얼마나 영향을 미치는지 계수를 통해 직접 확인할 수 있습니다.
    • 랜덤 포레스트(randomForest): 여러 개의 결정 트리(decision tree)를 결합한 앙상블 모델입니다. 변수 간의 비선형적이고 복잡한 상호작용을 잘 잡아내며, 일반적으로 선형 모델보다 높은 예측 성능을 보입니다.
  6. 성능 평가 및 비교:

    • RMSE (Root Mean Square Error): 예측 오차의 제곱에 대한 평균을 구한 후 다시 제곱근을 씌운 값. 큰 오차에 더 큰 패널티를 부여합니다. 수식은 $ \sqrt{\frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2} $ 입니다.
    • MAE (Mean Absolute Error): 예측 오차의 절대값에 대한 평균. 오차의 크기를 직관적으로 파악하기 좋습니다. 수식은 $ \frac{1}{n}\sum_{i=1}^{n}|y_i - \hat{y}_i| $ 입니다.
    • 결과를 보면 랜덤 포레스트의 RMSE와 MAE가 선형 회귀보다 낮게 나타나, 비선형 패턴까지 잘 학습했음을 알 수 있습니다. 시각화된 플롯에서도 랜덤 포레스트(빨간 점선)가 실제값(검은 실선)의 복잡한 등락을 더 잘 따라가는 것을 확인할 수 있습니다. 이는 자전거 수요가 온도, 시간 등 여러 요인과 복잡한 상호작용을 갖기 때문이며, 이러한 관계를 랜덤 포레스트가 더 효과적으로 모델링했음을 시사합니다.

300. 주식 시장 기술적 분석 및 트레이딩 전략 백테스팅

문제 상황: 당신은 퀀트(Quant) 투자 회사의 데이터 과학자입니다. 당신의 팀은 기술적 분석 지표를 기반으로 한 자동 매매 전략을 개발하고 있습니다. 특정 주식(예: 가상의 'R-Corp')의 과거 주가 데이터를 사용하여, 이동평균선(Moving Average)을 활용한 '골든 크로스' 및 '데드 크로스' 전략을 구현하고, 이 전략이 과거 데이터에서 얼마나 효과적이었는지를 시뮬레이션(백테스팅)하여 성과를 평가하는 임무를 맡았습니다.

과제 지시 사항:

  1. 가상의 일별 주식 가격 데이터를 생성하세요. 데이터는 date, open, high, low, close (시가, 고가, 저가, 종가)를 포함해야 합니다.
  2. TTR 패키지를 사용하여 20일 단기 이동평균선(SMA20)과 60일 장기 이동평균선(SMA60)을 계산하세요.
  3. **매매 신호(Trading Signal)**를 생성하세요.
    • 매수 신호 (Buy, 1): 단기 이동평균선(SMA20)이 장기 이동평균선(SMA60)을 위로 돌파하는 시점 (골든 크로스).
    • 매도 신호 (Sell, -1): 단기 이동평균선(SMA20)이 장기 이동평균선(SMA60)을 아래로 돌파하는 시점 (데드 크로스).
    • 보유 (Hold, 0): 그 외 모든 경우.
  4. 생성된 매매 신호를 바탕으로 백테스팅(Backtesting) 시뮬레이션을 수행하세요.
    • 초기 자본금은 $100,000이라고 가정합니다.
    • 매수 신호가 발생하면 보유 현금 전체를 주식 매수에 사용합니다.
    • 매도 신호가 발생하면 보유 주식 전체를 매도하여 현금화합니다.
    • 거래 수수료는 없다고 가정합니다.
    • 일별 포트폴리오 가치(현금 + 보유 주식 평가액)를 계산하여 portfolio_value 열을 추가하세요.
  5. 백테스팅 결과를 평가하고 시각화하세요.
    • 전략의 최종 수익률과, 같은 기간 동안 주식을 단순히 사서 보유(Buy and Hold)했을 때의 수익률을 비교하세요.
    • 주가, 이동평균선, 매수/매도 시점을 하나의 그래프에 시각화하세요.
    • 포트폴리오 가치의 변화 추이를 시간 경과에 따라 시각화하세요.

정답 코드

# 0. 필요 패키지 설치 및 로드
# install.packages(c("dplyr", "lubridate", "ggplot2", "TTR", "zoo"))
library(dplyr)
library(lubridate)
library(ggplot2)
library(TTR)
library(zoo)

# 1. 가상 주식 데이터 생성
set.seed(500)
dates <- seq(as.Date("2020-01-01"), as.Date("2023-12-31"), by = "day")
n <- length(dates)
# 기하 브라운 운동(Geometric Brownian Motion)을 단순화하여 주가 시뮬레이션
price_changes <- rnorm(n, mean = 0.0005, sd = 0.02)
close_prices <- 100 * exp(cumsum(price_changes))

stock_data <- tibble(
  date = dates,
  close = close_prices
) %>% mutate(
  open = lag(close, 1) * (1 + rnorm(n, 0, 0.005)),
  high = pmax(open, close) * (1 + runif(n, 0, 0.01)),
  low = pmin(open, close) * (1 - runif(n, 0, 0.01))
) %>% na.omit()

# 2. 이동평균선 계산
stock_data <- stock_data %>%
  mutate(
    SMA20 = SMA(close, n = 20),
    SMA60 = SMA(close, n = 60)
  ) %>%
  na.omit() # 이동평균선 계산으로 인한 NA 제거

# 3. 매매 신호 생성
stock_data <- stock_data %>%
  mutate(
    # SMA20 > SMA60 이면 1, 아니면 -1. diff()를 통해 크로스 지점을 찾음
    position_check = ifelse(SMA20 > SMA60, 1, -1),
    signal = c(0, diff(position_check)) / 2 # diff가 2면 골든크로스(1), -2면 데드크로스(-1)
  )

# 4. 백테스팅 시뮬레이션
initial_capital <- 100000
cash <- initial_capital
shares <- 0
portfolio_values <- numeric(nrow(stock_data))

for (i in 1:nrow(stock_data)) {
  current_price <- stock_data$close[i]
  current_signal <- stock_data$signal[i]
  
  # 매수 신호 (1) 이고 현금이 있을 때
  if (current_signal == 1 && cash > 0) {
    shares_to_buy <- floor(cash / current_price)
    shares <- shares + shares_to_buy
    cash <- cash - shares_to_buy * current_price
  }
  # 매도 신호 (-1) 이고 주식이 있을 때
  else if (current_signal == -1 && shares > 0) {
    cash <- cash + shares * current_price
    shares <- 0
  }
  
  # 일별 포트폴리오 가치 계산
  portfolio_values[i] <- cash + shares * current_price
}

stock_data$portfolio_value <- portfolio_values

# 5. 결과 평가 및 시각화
# 최종 수익률 계산
final_strategy_return <- (last(stock_data$portfolio_value) / initial_capital - 1) * 100
buy_and_hold_return <- (last(stock_data$close) / first(stock_data$close) - 1) * 100

cat(paste("Strategy Final Return:", round(final_strategy_return, 2), "%\n"))
cat(paste("Buy and Hold Final Return:", round(buy_and_hold_return, 2), "%\n"))

# 시각화 1: 주가, 이동평균선, 매매 신호
buy_signals <- stock_data %>% filter(signal == 1)
sell_signals <- stock_data %>% filter(signal == -1)

ggplot(stock_data, aes(x = date)) +
  geom_line(aes(y = close, color = "Close Price")) +
  geom_line(aes(y = SMA20, color = "SMA20")) +
  geom_line(aes(y = SMA60, color = "SMA60")) +
  geom_point(data = buy_signals, aes(y = SMA20), color = "green", size = 4, shape = 24) + # 매수
  geom_point(data = sell_signals, aes(y = SMA20), color = "red", size = 4, shape = 25) +   # 매도
  scale_color_manual(values = c("Close Price" = "black", "SMA20" = "blue", "SMA60" = "orange")) +
  labs(title = "Stock Price, Moving Averages, and Trading Signals", y = "Price", x = "Date") +
  theme_minimal()

# 시각화 2: 포트폴리오 가치 변화
ggplot(stock_data, aes(x = date, y = portfolio_value)) +
  geom_line(color = "darkgreen", size = 1) +
  labs(title = "Portfolio Value Over Time",
       subtitle = paste("Final Value:", scales::dollar(last(stock_data$portfolio_value))),
       y = "Portfolio Value ($)", x = "Date") +
  theme_minimal()

해설

이 문제는 금융 데이터 분석, 즉 퀀트 투자의 기본적인 파이프라인을 체험할 수 있는 최종 보스 레벨의 문제입니다. 데이터 처리, 논리적 규칙 생성, 시뮬레이션, 결과 평가 및 시각화를 모두 포함합니다.

  1. 주가 데이터 생성: cumsumexp를 이용한 기하 브라운 운동(GBM)의 단순화된 형태로 주가의 무작위적 움직임과 장기적 추세를 모사했습니다. 이는 실제 주가 데이터와 유사한 특성을 갖게 합니다.

  2. 기술적 지표 계산: TTR::SMA 함수는 이동평균을 계산하는 가장 효율적인 방법 중 하나입니다. 단기(20일)와 장기(60일) 이동평균은 시장의 단기 추세와 장기 추세를 각각 나타냅니다.

  3. 매매 신호 생성:

    • ifelse(SMA20 > SMA60, 1, -1): 이 코드는 현재 단기 추세가 장기 추세보다 위에 있는지(정배열, 상승 추세) 아래에 있는지(역배열, 하락 추세)를 나타내는 상태 변수(position_check)를 만듭니다.
    • diff(): 이 함수는 벡터의 연속된 값들 간의 차이를 계산합니다. position_checkdiff를 적용하면, 상태가 변하는 시점(즉, 두 이동평균선이 교차하는 시점)에만 0이 아닌 값을 갖게 됩니다.
    • diff 결과가 2이면 -1에서 1로 상태가 바뀐 것(골든 크로스), -2이면 1에서 -1로 바뀐 것(데드 크로스)을 의미합니다. 이를 2로 나누어 각각 1(매수)과 -1(매도) 신호로 변환합니다. 이는 매우 간결하고 효율적인 R 코드 스타일입니다.
  4. 백테스팅:

    • 백테스팅의 핵심은 for 루프를 사용하여 시간의 흐름에 따라 하루하루 시뮬레이션하는 것입니다.
    • cash(현금)와 shares(보유 주식 수)라는 두 개의 상태 변수를 유지합니다.
    • 매일의 신호에 따라 이 두 변수를 업데이트합니다.
    • portfolio_value = cash + shares * current_price는 현재 포트폴리오의 총가치를 나타내는 가장 중요한 계산식입니다.
    • 이러한 루프 기반 시뮬레이션은 과거 특정 시점으로 돌아가서 "만약 이 전략을 썼더라면 어떻게 됐을까?"를 검증하는 과정입니다.
  5. 결과 평가 및 시각화:

    • 수익률 비교: 전략의 성과를 객관적으로 평가하기 위해 가장 보편적인 벤치마크인 '단순 보유(Buy and Hold)' 전략과 비교합니다. 만약 전략 수익률이 단순 보유 수익률보다 낮다면, 그 전략은 굳이 사용할 필요가 없는 비효율적인 전략일 수 있습니다.
    • 주가 차트 시각화: ggplot2를 이용해 주가, 두 이동평균선, 그리고 매수/매도 신호가 발생한 지점을 한눈에 볼 수 있도록 시각화했습니다. 초록색 삼각형(매수)은 골든 크로스 지점에서, 빨간색 역삼각형(매도)은 데드 크로스 지점에서 나타나는 것을 확인함으로써, 우리의 로직이 올바르게 작동했음을 시각적으로 검증할 수 있습니다.
    • 자산 곡선(Equity Curve): 포트폴리오 가치의 변화를 그린 그래프는 전략의 안정성을 보여줍니다. 이 곡선이 우상향하더라도 변동성이 너무 크다면 위험한 전략일 수 있습니다. 이 그래프를 통해 최대 낙폭(Maximum Drawdown) 등의 다른 성과 지표도 계산할 수 있습니다.

이 프로젝트를 통해 R을 활용하여 금융 데이터를 분석하고, 정량적 투자 전략을 수립 및 검증하는 전체 과정을 경험할 수 있습니다.


여기까지 오느라 수고 많았습니다. 준비해준 윤이나 조교도 수고 많았습니다. 인공지능 시대에 녹녹치 않은 앞날이 우리를 기다리고 있지만 경희의 이름으로 전진하는 여러분의 앞날에 좋은 결과가 있기를 바랍니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment