데이터 분석을 시작하면 가장 자주 하는 작업 중 하나가 필요한 데이터만 골라내는 것이다.
전체 데이터에서 특정 행이나 열을 선택하고, 조건에 맞는 데이터만 추출하거나, 배열의 모양을 바꾸는 작업은 전처리와 분석의 기본이 된다.
Python에서는 이런 작업에 주로 NumPy와 Pandas를 사용한다.
이번 글에서는 NumPy 배열의 구조를 이해하는 것부터 시작해서 Pandas의 DataFrame, 슬라이싱, Boolean Indexing, loc, iloc까지 한 흐름으로 정리한다.
두 라이브러리 모두 데이터를 다루지만 중심이 되는 자료구조와 사용 목적이 다르다.
| 구분 | NumPy | Pandas |
|---|---|---|
| 대표 자료구조 | ndarray | Series, DataFrame |
| 잘 맞는 데이터 | 수치형 다차원 배열 | 행과 열로 구성된 표 데이터 |
| 데이터 접근 | 위치 기반 인덱싱, 슬라이싱 | 열 이름, 행 인덱스, 조건 기반 선택 |
| 주요 활용 | 수치 계산, 행렬 연산, 머신러닝 입력 | 데이터 정제, 탐색, 통계 분석 |
예를 들어 머신러닝 모델에 전달할 숫자 배열은 NumPy가 자연스럽고, 사용자별 가입일·등급·구매금액처럼 열마다 의미가 다른 데이터는 Pandas가 더 읽기 쉽다.
일반적으로 다음과 같이 np라는 별칭으로 불러온다.
import numpy as np
설치가 필요하다면 Conda 환경에서는 다음처럼 설치할 수 있다.
conda install numpy
Python 리스트는 서로 다른 자료형을 함께 저장할 수 있다.
values = [1, 3.14, True, "hello"]
print(values)
반면 NumPy 배열은 일반적으로 하나의 공통 dtype을 기준으로 데이터를 저장한다.
array = np.array(values)
print(array)
서로 다른 타입이 섞여 있으면 NumPy가 모든 값을 표현할 수 있는 공통 타입으로 맞춘다.
따라서 수치 연산을 목적으로 배열을 만들 때는 데이터 타입을 의식하는 것이 중요하다.
중첩 리스트를 np.array()에 전달하면 2차원 배열을 만들 수 있다.
data = [
[10, 20, 30],
[40, 50, 60]
]
array = np.array(data)
print(array)
결과는 다음과 같은 형태가 된다.
[[10 20 30]
[40 50 60]]
Python의 중첩 리스트와 비슷하게 보이지만 실제 타입은 numpy.ndarray다.
print(type(array))
NumPy 배열을 다룰 때 자주 확인하는 속성이 있다.
print(array.shape)
print(array.ndim)
print(array.size)
위의 2 x 3 배열이라면 각각 다음 의미를 가진다.
| 속성 | 의미 | 결과 |
|---|---|---|
shape | 각 축의 크기 | (2, 3) |
ndim | 배열의 차원 수 | 2 |
size | 전체 원소 개수 | 6 |
특히 머신러닝에서는 입력 데이터의 shape이 맞지 않아 오류가 나는 경우가 많기 때문에 shape 확인이 매우 중요하다.
NumPy의 reshape()를 사용하면 원소 개수를 유지한 채 배열의 모양을 변경할 수 있다.
array = np.array([
[10, 20, 30],
[40, 50, 60]
])
reshaped = array.reshape(3, 2)
print(reshaped)
결과:
[[10 20]
[30 40]
[50 60]]
원래 배열은 2 x 3, 변경된 배열은 3 x 2지만 전체 원소 개수는 모두 6개다.
변경 전 전체 원소 개수
=
변경 후 전체 원소 개수
예를 들어 원소가 6개인데 reshape(4, 2)처럼 8개가 필요한 형태로 바꾸려고 하면 오류가 발생한다.
배열을 생성할 때부터 자료형을 지정할 수 있다.
array = np.array(
[[1, 2, 3], [4, 5, 6]],
dtype=np.float64
)
print(array)
출력:
[[1. 2. 3.]
[4. 5. 6.]]
이미 만들어진 배열의 타입을 바꾸려면 astype()을 사용한다.
integer_array = array.astype(np.int64)
print(integer_array)
출력:
[[1 2 3]
[4 5 6]]
실수 값을 정수로 바꿀 때는 소수점 이하 정보가 사라질 수 있으므로 실제 데이터에서는 변환 전에 값의 의미를 확인해야 한다.
NumPy 배열도 Python 리스트처럼 0부터 시작하는 인덱스를 사용한다.
arr = np.array([10, 20, 30, 40, 50])
print(arr[0])
print(arr[3])
print(arr[-1])
각각 첫 번째, 네 번째, 마지막 원소를 가져온다.
2차원 배열에서는 다음 형태로 접근한다.
arr[행, 열]
예를 들어:
arr = np.array([
[10, 20, 30],
[40, 50, 60]
])
print(arr[0, 0])
print(arr[1, 2])
결과는 각각 10, 60이다.
arr = np.arange(1, 7).reshape(2, 3)
print(arr)
[[1 2 3]
[4 5 6]]
첫 번째 행 전체:
print(arr[0, :])
세 번째 열 전체:
print(arr[:, 2])
전체 행에서 앞의 두 열만 선택:
print(arr[:, :2])
여기서 :는 해당 축의 범위를 선택하는 슬라이싱 표현이다.
데이터 분석에서 매우 자주 사용하는 패턴이 Boolean Indexing이다.
scores = np.array([42, 81, 67, 95, 58])
mask = scores >= 80
print(mask)
[False True False True False]
이 Boolean 배열을 다시 원본 배열에 적용하면 조건을 만족하는 값만 가져올 수 있다.
print(scores[scores >= 80])
[81 95]
흐름은 다음과 같다.
조건식 작성
↓
True / False 배열 생성
↓
True 위치의 데이터만 선택
이 개념은 Pandas의 조건 필터링에서도 거의 동일하게 사용된다.
import pandas as pd
Conda 환경에서 설치가 필요하다면:
conda install pandas
이번에는 웹 서비스 사용자 데이터를 예제로 만들어보자.
data = {
"user_id": [101, 102, 103, 104, 105],
"plan": ["free", "pro", "free", "pro", "free"],
"visits": [5, 12, 3, 20, 8]
}
df = pd.DataFrame(data)
print(df)
DataFrame은 행과 열로 구성되어 있어서 SQL 결과나 스프레드시트와 비슷하게 볼 수 있다.
한 개의 열을 선택하면 일반적으로 Series가 반환된다.
print(type(df["visits"]))
여러 열을 선택하면 DataFrame이 반환된다.
print(type(df[["user_id", "visits"]]))
| 형태 | 예시 | 반환 타입 |
|---|---|---|
| 열 1개 | df["visits"] | Series |
| 열 여러 개 | df[["user_id", "visits"]] | DataFrame |
import os
os.makedirs("./data", exist_ok=True)
exist_ok=True를 사용하면 폴더가 이미 존재해도 오류 없이 넘어간다.
df.to_csv(
"./data/users.csv",
sep=",",
index=False
)
index=False를 지정하면 DataFrame의 기본 행 인덱스를 CSV의 별도 열로 저장하지 않는다.
df = pd.read_csv("./data/users.csv")
print(df)
불러온 뒤에는 데이터가 예상한 형태인지 확인하는 습관이 좋다.
print(df.shape)
print(df.columns)
print(df.dtypes)
Pandas에서는 목적에 따라 여러 선택 방식을 사용한다.
행의 범위 선택
→ slicing
특정 열 선택
→ column indexing
조건에 맞는 행 선택
→ boolean indexing
행/열 이름 기준 선택
→ loc
행/열 위치 기준 선택
→ iloc
df[1:4]
위 코드는 위치 기준으로 1번부터 3번 위치까지의 행을 가져온다.
Python 슬라이싱과 마찬가지로 끝 위치는 포함하지 않는다.
1:4
→ 1, 2, 3
복잡한 행/열 선택에서는 의미가 더 명확한 loc, iloc를 사용하는 편이 좋다.
특정 열은 열 이름을 사용해 선택할 수 있다.
df["plan"]
결과는 Series다.
여기서 다음 코드는 두 번째 행을 가져오는 코드가 아니다.
df[1]
DataFrame의 []에 단일 값을 넣으면 기본적으로 해당 이름의 열을 찾으려고 한다.
열 이름이 1인 컬럼이 없다면 KeyError가 발생할 수 있다.
직접 실행한 실습에서도 이 방식으로 KeyError가 발생하는 것을 확인할 수 있었다.
행을 위치 기준으로 가져오고 싶다면 iloc를 사용하는 것이 더 명확하다.
df.iloc[1]
여러 열을 한 번에 선택하려면 열 이름을 리스트로 전달한다.
df[["user_id", "plan"]]
열 이름의 순서를 바꾸면 결과의 열 순서도 바뀐다.
df[["plan", "user_id"]]
필요한 열만 뽑아서 모델 입력 데이터나 리포트용 데이터셋을 만들 때 자주 사용하는 방식이다.
방문 횟수가 10회 이상인 사용자를 찾는다면:
df["visits"] >= 10
이 조건을 DataFrame에 적용한다.
df[df["visits"] >= 10]
또는 plan이 "pro"인 사용자만 선택할 수도 있다.
df[df["plan"] == "pro"]
Boolean Indexing의 흐름은 다음과 같다.
전체 데이터
↓
조건식 작성
↓
True / False 판별
↓
조건을 만족하는 행만 선택
SQL로 생각하면 WHERE 절과 비슷한 역할을 한다.
loc는 행과 열의 라벨(label) 을 기준으로 데이터를 선택한다.
df.loc[행, 열]
행 하나 선택:
df.loc[2]
여러 행 선택:
df.loc[[1, 3, 4]]
여러 행과 특정 열 선택:
df.loc[[1, 3, 4], ["user_id", "plan"]]
조건식과 함께 사용할 수도 있다.
df.loc[df["visits"] >= 10, ["user_id", "visits"]]
이 패턴은 실무에서 특히 자주 사용한다.
조건에 맞는 행 선택
+
필요한 열 선택
↓
df.loc[조건, 열 목록]
loc에서 라벨 범위를 슬라이싱하면 끝 라벨도 포함된다.
df.loc[1:3]
기본 RangeIndex라면 1, 2, 3 행까지 선택된다.
iloc는 라벨이 아니라 정수 위치(integer position) 를 사용한다.
df.iloc[행 위치, 열 위치]
두 번째 행의 첫 번째 값:
df.iloc[1, 0]
여러 행과 앞의 두 열 선택:
df.iloc[[1, 3, 4], 0:2]
0:2는 Python 슬라이싱 규칙을 따르므로 0, 1 위치의 열만 포함한다.
| 구분 | loc | iloc |
|---|---|---|
| 기준 | 라벨 | 정수 위치 |
| 열 선택 | "user_id" | 0 |
| 슬라이싱 끝 | 포함 | 제외 |
| 예시 | df.loc[1:3, "user_id":"plan"] | df.iloc[1:4, 0:2] |
기억하기 쉽게 정리하면:
loc
→ Label
iloc
→ Integer Location
df["visits"]
df[["user_id", "visits"]]
df[df["visits"] >= 10]
df.loc[
df["visits"] >= 10,
["user_id", "plan", "visits"]
]
df.iloc[0:3, 0:2]
복잡한 체인 인덱싱보다는 loc 또는 iloc로 의도를 명확하게 표현하는 편이 코드를 읽고 유지보수하기 쉽다.
웹 서비스 로그를 분석한다고 가정해보자.
data = {
"user_id": [101, 102, 103, 104, 105],
"event": ["login", "purchase", "search", "purchase", "login"],
"duration": [3.2, 12.5, 4.1, 8.7, 2.9]
}
logs = pd.DataFrame(data)
구매 이벤트만 보고 싶다면:
logs[logs["event"] == "purchase"]
구매 이벤트 중 사용자 ID와 체류시간만 필요하다면:
logs.loc[
logs["event"] == "purchase",
["user_id", "duration"]
]
결국 데이터 분석에서의 선택은 다음 흐름으로 이어진다.
전체 로그
↓
분석 목적에 맞는 조건 정의
↓
필요한 행 선택
↓
필요한 열 선택
↓
집계 / 시각화 / 머신러닝
처음에는 인덱싱 문법 자체가 복잡해 보이지만 실제로는 분석에 필요한 데이터 범위를 좁히는 과정이라고 생각하면 이해하기 쉽다.
df[1]은 두 번째 행이 아니다df[1]
단일 대괄호 인덱싱은 기본적으로 1이라는 이름의 열을 찾는다.
행 위치를 선택하려면:
df.iloc[1]
df["column"]과 df[["column"]]은 다르다df["visits"]
→ Series
df[["visits"]]
→ DataFrame
머신러닝 라이브러리에 데이터를 넘길 때 1차원과 2차원의 차이가 문제가 될 수 있으므로 반환 형태를 확인하는 습관이 중요하다.
loc와 iloc의 슬라이싱 규칙은 다르다df.loc[1:3]
→ 1, 2, 3 포함
df.iloc[1:3]
→ 1, 2만 포함
복잡한 조건을 바로 DataFrame에 적용하기보다 먼저 조건 결과를 확인하면 디버깅이 쉬워진다.
condition = df["visits"] >= 10
print(condition)
그다음 필터링한다.
df[condition]
ndarray 는 다차원 수치 데이터를 효율적으로 처리하기 위한 자료구조다.shape, ndim, size를 사용하면 배열의 형태와 차원, 전체 원소 수를 확인할 수 있다.reshape()는 전체 원소 개수를 유지하면서 배열 모양을 변경한다.arr[행, 열] 방식으로 2차원 배열의 특정 위치에 접근할 수 있다.df["열"]은 한 열, df[["열1", "열2"]]은 여러 열을 선택한다.loc는 라벨, iloc는 정수 위치를 기준으로 데이터를 선택한다.df.loc[조건, 열 목록] 형태가 읽기 쉽고 활용도가 높다.NumPy와 Pandas를 처음 공부할 때는 문법이 비슷해 보여 인덱싱 방식이 헷갈리기 쉽다.
하지만 NumPy에서는 배열의 위치와 차원, Pandas에서는 행·열의 의미와 조건을 중심으로 생각하면 구분이 훨씬 쉬워진다.
특히 Pandas에서는 loc, iloc, Boolean Indexing을 자유롭게 사용할 수 있으면 이후 데이터 전처리, 그룹화, 통계 분석, 시각화 작업으로 넘어갈 때 훨씬 수월하다.
다음 단계에서는 선택한 데이터를 기반으로 값 변경, 결측치 처리, 열 추가·삭제, 그룹화와 집계 같은 데이터 조작을 이어서 공부하면 자연스럽게 연결된다.