안녕하세요! 오랜만에 LLM 컨텐츠를 주제로 돌아왔습니다
LLM 관련 기술을 학습하고, 여러 프로젝트를 수행하면서 문득 한 가지 생각이 들더군요.
‘상품에 대한 설명을 참고하여 가격을 어느 정도 정확하게 예측을 할 수 있을까?’
기존에는, 상품의 카테고리 / 크기 / 제조사 등 다양한 범주형 변수를 수치화하여 전통적인 머신러닝 모델을 통해 가격을 예측하는 경우가 많았습니다 (물론, 평점과 같은 연속형 변수도 포함하여 사용할 수 있습니다).
상품 가격과 관련성이 높은 피처들을 많이 수집하고 정교하게 피처링을 하면, 간단한 모델로도 파괴적인 성능을 보여줄 수 있을 것입니다.
하지만, 제 경험 상 한정된 정보와 품질이 떨어지는 데이터를 마주하게 될 확률이 상당히 높았습니다.
그래서, 사전에 학습된 LLM 모델을 이용하여 가격을 예측하는 방법을 떠올리게 되었습니다.
만약, 해당 방법론이 working 하다면, 기존 방식보다 상대적으로 feature engineering에 소요되는 시간을 줄일 수도 있습니다.
또한, 모델의 input이 텍스트이므로, 전통적인 머신러닝 모델보다 유연하게 데이터를 입력 받을 수 있습니다.
자, 그럼 실제로 제가 생각한 방법이 적합한 것인지 한 번 모험을 떠나봅시다~!
저의 모험 여정은 총 네 개의 스텝에 걸쳐서 공유 드릴 예정입니다
데이터 전처리 후, 다양한 모델과의 비교를 통해, 어떤 모델이 가장 정확하게 제품 가격을 예측할 수 있는지 지켜보겠습니다.
이번 세션에서는 데이터 소개 및 전처리 방법에 대해 자세하게 다뤄볼게요 
사용할 데이터
해당 데이터는 UCSD에 소속된 McAuley Lab에서 23년도에 Amazon에 등록된 제품 정보 및 각종 리뷰 데이터를 수집하여 공유하였습니다. 감사합니다!!
제품은 뷰티, 가전, 수제품, 헬스케어, 영화, 의류 등 총 34개의 카테고리로 이루어져 있습니다.
이때, 저는 메모리가 감당 가능한 선에서 아래 여섯 가지의 제품 군을 선정했습니다.
•
Tools and Home Improvement
•
Cell Phones and Accessories
•
Health and Personal Care
•
Handmade Products
•
Appliances
•
Musical Instruments
데이터 로드
데이터 전처리를 위해, 먼저 허깅페이스에서 Amazon 데이터를 로컬로 불러들어야 합니다.
이를 위해, 제품을 표현하는 Item 클래스를 정의하고, 이를 기반으로 병렬적으로 데이터를 load 하는 클래스를 작성했습니다.
[Items.py]
from typing import Optional
from transformers import AutoTokenizer
import re
BASE_MODEL = "meta-llama/Meta-Llama-3.1-8B"
MIN_TOKENS = 150
MAX_TOKENS = 160
MIN_CHARS = 300
CEILING_CHARS = MAX_TOKENS * 7
class Item:
"""
An Item is a cleaned, curated datapoint of a Product with a Price
"""
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
PREFIX = "Price is $"
QUESTION = "How much does this cost to the nearest dollar?"
REMOVALS = [
'"Batteries Included?": "No"',
'"Batteries Included?": "Yes"',
'"Batteries Required?": "No"',
'"Batteries Required?": "Yes"',
"By Manufacturer", "Item", "Date First", "Package", ":",
"Number of", "Best Sellers", "Number", "Product "
]
title: str
price: float
category: str
token_count: int = 0
details: Optional[str]
prompt: Optional[str] = None
include = False
def __init__(self, data, price):
self.title = data['title']
self.price = price
self.parse(data)
def scrub_details(self):
"""
Clean up the details string by removing common text that doesn't add value
"""
details = self.details
for remove in self.REMOVALS:
details = details.replace(remove, "")
return details
def scrub(self, stuff):
"""
Clean up the provided text by removing unnecessary characters and whitespace
Also remove words that are 7+ chars and contain numbers, as these are likely irrelevant product numbers
ex: Clothes Dryer Drum Slide, General Electric, Hotpoint, WE1M333, WE1M504
-> Clothes Dryer Drum Slide, General Electric, Hotpoint,
"""
stuff = re.sub(r'[:\[\]"{}【】\s]+', ' ', stuff).strip()
stuff = stuff.replace(" ,", ",").replace(",,,",",").replace(",,",",")
words = stuff.split(' ')
select = [word for word in words if len(word)<7 or not any(char.isdigit() for char in word)]
return " ".join(select)
def parse(self, data):
"""
Parse this datapoint and if it fits within the allowed Token range,
then set include to True
"""
contents = '\n'.join(data['description'])
if contents:
contents += '\n'
features = '\n'.join(data['features'])
if features:
contents += features + '\n'
self.details = data['details']
if self.details:
contents += self.scrub_details() + '\n'
if len(contents) > MIN_CHARS:
contents = contents[:CEILING_CHARS]
text = f"{self.scrub(self.title)}\n{self.scrub(contents)}"
tokens = self.tokenizer.encode(text, add_special_tokens=False)
if len(tokens) > MIN_TOKENS:
tokens = tokens[:MAX_TOKENS]
text = self.tokenizer.decode(tokens)
self.make_prompt(text)
self.include = True
def make_prompt(self, text):
"""
Set the prompt instance variable to be a prompt appropriate for training
"""
self.prompt = f"{self.QUESTION}\n\n{text}\n\n"
self.prompt += f"{self.PREFIX}{str(round(self.price))}.00"
self.token_count = len(self.tokenizer.encode(self.prompt, add_special_tokens=False))
def test_prompt(self):
"""
Return a prompt suitable for testing, with the actual price removed
"""
return self.prompt.split(self.PREFIX)[0] + self.PREFIX
Python
복사
•
제품 정보를 바탕으로 Item 클래스의 인스턴스를 생성합니다. 이 과정에서 parse() 함수를 호출합니다.
•
parse() 함수에서는 ‘description’, ‘features’, ‘details’에 기재된 문자열을 하나로 합친 다음, 정규표현식을 통해 불필요한 문자를 제거하는 과정을 수행합니다. 또한, LLM 파인튜닝에 사용할 프롬프트를 생성합니다(make_prompt() 함수 / self.prompt).
Item 클래스 인스턴스 생성에 필요한 데이터 예시는 아래와 같습니다.
{'main_category': 'Tools & Home Improvement',
'title': 'Clothes Dryer Drum Slide, General Electric, Hotpoint, WE1M333, WE1M504',
'average_rating': 3.5,
'rating_number': 18,
'features': [],
'description': ['Brand new dryer drum slide, replaces General Electric, Hotpoint, RCA, WE1M333, WE1M504.'],
'price': 'None',
'images': {'hi_res': ['https://m.media-amazon.com/images/I/51TIpnkrEpL._AC_SL1500_.jpg'],
'large': ['https://m.media-amazon.com/images/I/21HQKcHPIkL._AC_.jpg'],
'thumb': ['https://m.media-amazon.com/images/I/21HQKcHPIkL._AC_US75_.jpg'],
'variant': ['MAIN']},
'videos': {'title': [], 'url': [], 'user_id': []},
'store': 'GE',
'categories': ['Appliances', 'Parts & Accessories'],
'details': '{"Manufacturer": "RPI", "Part Number": "WE1M333,", "Item Weight": "0.352 ounces", "Package Dimensions": "5.5 x 4.7 x 0.4 inches", "Item model number": "WE1M333,", "Is Discontinued By Manufacturer": "No", "Item Package Quantity": "1", "Batteries Included?": "No", "Batteries Required?": "No", "Best Sellers Rank": {"Tools & Home Improvement": 1315213, "Parts & Accessories": 181194}, "Date First Available": "February 25, 2014"}',
'parent_asin': 'B00IN9AGAE',
'bought_together': None,
'subtitle': None,
'author': None}
Plain Text
복사
[loaders.py]
from typing import List, Optional
from tqdm import tqdm
from datasets import load_dataset
from concurrent.futures import ProcessPoolExecutor
from items import Item
CHUNK_SIZE = 1000
MIN_PRICE = 0.5
MAX_PRICE = 999.49
class ItemLoader:
def __init__(self, name):
self.name = name
self.dataset = None
def from_datapoint(self, datapoint) -> Optional[Item]:
"""
Try to create an Item from this datapoint
Return the Item if successful, or None if it shouldn't be included
"""
try:
price_str = datapoint['price']
if price_str:
price = float(price_str)
if MIN_PRICE <= price <= MAX_PRICE:
item = Item(datapoint, price)
return item if item.include else None
except ValueError:
return None
def from_chunk(self, chunk):
"""
Create a list of Items from this chunk of elements from the Dataset
"""
batch = []
for datapoint in chunk:
result = self.from_datapoint(datapoint)
if result:
batch.append(result)
return batch
def chunk_generator(self):
"""
Iterate over the Dataset, yielding chunks of datapoints at a time
"""
size = len(self.dataset)
for i in range(0, size, CHUNK_SIZE):
yield self.dataset.select(range(i, min(i + CHUNK_SIZE, size)))
def load_in_parallel(self, workers):
"""
Use concurrent.futures to farm out the work to process chunks of datapoints -
This speeds up processing significantly, but will tie up your computer while it's doing so!
"""
results = []
chunk_count = (len(self.dataset) // CHUNK_SIZE) + 1
with ProcessPoolExecutor(max_workers=workers) as pool:
for batch in tqdm(pool.map(self.from_chunk, self.chunk_generator()), total=chunk_count):
results.extend(batch)
for result in results:
result.category = self.name
return results
def load(self, workers=8) -> List[Item]:
"""
Load in this dataset; the workers parameter specifies how many processes
should work on loading and scrubbing the data
"""
print(f"Loading dataset {self.name}", flush=True)
self.dataset = load_dataset("McAuley-Lab/Amazon-Reviews-2023", f"raw_meta_{self.name}", split="full", trust_remote_code=True)
results = self.load_in_parallel(workers)
return results
Python
복사
•
load() 함수를 통해, Hugging Face에서 Amzaon 상품에 대한 메타데이터를 로컬로 불러옵니다.
•
이때, load_in_parallel() 함수를 호출하여, 병렬 컴퓨팅을 통해 메타데이터를 Item 인스턴스로 변환합니다.
이제, 실제 데이터를 로딩해봅시다. 총 94만여개의 제품 데이터를 가져왔네요
하지만, 이는 LLM 모델 파인튜닝을 하기에는 너무나도 많은 양입니다. 따라서, 데이터 전처리 과정을 거쳐 일부 제품 데이터만 샘플링하려고 합니다.
dataset_names = [
"Tools_and_Home_Improvement",
"Cell_Phones_and_Accessories",
"Health_and_Personal_Care",
"Handmade_Products",
"Appliances",
"Musical_Instruments",
]
items = []
for dataset_name in dataset_names:
loader = ItemLoader(dataset_name)
items.extend(loader.load())
print(f"A grand total of {len(items):,} items")
>> A grand total of 944,154 items
Python
복사
LLM 모델 파인튜닝과 데이터의 양
본 프로젝트에서는 Closed-source model로서 GPT-4를, Open-source model로서 LLAMA-3.1-8B 버전을 사용합니다.
GPT 개발을 담당하는 OpenAI에 따르면, 파인튜닝 task에 따라 다를 수 있지만, 일반적으로 50~100개의 데이터를 통해 효과적으로 모델 학습이 가능하다고 하네요.
한편, LLAMA-3.1-8B 모델 기반 파인튜닝을 수행할 때, 90만 개의 데이터에 대한 예상 시간이 2~3일 정도 되더라구요(A100 40GB) …
코랩을 통해 모델을 학습시키는 데, 비용 감당이 안되더라구요…
따라서, 사전에 데이터를 샘플링할 수 밖에 없었습니다 ㅜ
데이터 전처리
데이터 관찰
자, 이제 데이터 전처리를 위해서 Label인 가격의 분포를 확인해보겠습니다.
# Plot the distribution of prices
prices = [item.price for item in items]
plt.figure(figsize=(15, 6))
plt.title(f"Prices: Avg {sum(prices)/len(prices):,.1f} and highest {max(prices):,}\n")
plt.xlabel('Price ($)')
plt.ylabel('Count')
plt.hist(prices, rwidth=0.7, color="blueviolet", bins=range(0, 1000, 10))
plt.show()
Python
복사
평균 가격은 50달러이고, 최고 가격은 999.32 달러네요!
또한, 대부분의 데이터 포인트가 0~200 달러 사이에 분포한 것을 확인할 수 있습니다.
데이터 샘플링 시, 최대한 가격대 별로 고르게 수행할 수 있는 방법을 마련해야겠습니다.
다음은, 제품 카테고리의 분포를 살펴보도록 하겠습니다.
category_counts = Counter()
for item in items:
category_counts[item.category] += 1
categories = category_counts.keys()
counts = [category_counts[category] for category in categories]
# Bar chart by category
plt.figure(figsize=(15, 6))
plt.bar(categories, counts, color="goldenrod")
plt.title('How many in each category')
plt.xlabel('Categories')
plt.ylabel('Count')
plt.xticks(rotation=30, ha='right')
# Add value labels on top of each bar
for i, v in enumerate(counts):
plt.text(i, v, f"{v:,}", ha='center', va='bottom')
# Display the chart
plt.show()
Python
복사
Home improvement와 Cell phone 관련 제품이 대부분의 비중을 차지하네요.
저의 목표는 범용적으로 제품의 가격을 정확하게 예측하는 것이 목표입니다. 따라서, 데이터 샘플링 과정에서 카테고리의 분포도 고려해야만 합니다.
마지막으로, 가격대 별 (1$부터 1000$까지 1$ 단위로) 데이터 포인트의 갯수도 확인해보겠습니다.
import pandas as pd
slots = defaultdict(list)
for item in items:
slots[round(item.price)].append(item)
len_slots = defaultdict(int)
for price in slots.keys(): # From 1$ to 1000$
len_slots[price] = len(slots[price])
# Make DataFrame
df = pd.DataFrame(
data={
'price': len_slots.keys(),
'num_items': len_slots.values()
}
)
# Plot the distribution of prices
plt.figure(figsize=(15, 6))
plt.title(f"Number of Items: Avg {df.describe().loc['mean', 'num_items']:,.1f} and highest {df.describe().loc['max', 'num_items']:,}\n")
plt.xlabel('Price ($)')
plt.ylabel('Count')
plt.xticks(ticks=[i for i in range(0, 1000, 100)])
plt.bar(x=df['price'], height=df['num_items'], width=0.7)
plt.show()
Python
복사
데이터 전처리
앞서 살펴봤듯이, 저는 데이터 전처리(여기서는 샘플링)을 위해, 총 두 가지 사항을 고려했습니다.
•
가격대 별 제품 갯수 분포
•
카테고리 별 제품 갯수 분포
한 카테고리의 제품 또는 특정 가격대의 제품만 집중적으로 학습할 경우, 다른 제품의 가격을 정확하게 예측하지 못할 가능성이 많기 때문입니다.
따라서, 저는 다음과 같은 샘플링 전략을 세웠습니다.
•
특정 가격(150$) 이상의 제품은 모두 학습 데이터에 포함한다.
•
0~150$ 달러에 속한 제품에서 일부 제품만 샘플링한다. 이때, 제품의 수가 많은 카테고리 일수록, 샘플링 될 확률이 낮도록 설정한다.
두 가지 전략을 반영한 샘플링 코드는 다음과 같습니다. 총 198,092 개의 데이터를 샘플링했군요!
def inverse_frequency_sampling(frequencies: dict) -> dict:
values = np.array(list(frequencies.values()))
inverse_weights = 1 / values
normalized_weights = inverse_weights / np.sum(inverse_weights)
weighted_categories = {category: weight for category, weight in zip(frequencies.keys(), normalized_weights)}
return weighted_categories
# Create a dataset called "sample" which tries to more evenly take from the range of prices
# Set random seed for reproducibility
weighted_categories = inverse_frequency_sampling(category_counts)
np.random.seed(42)
random.seed(42)
sample = []
for i in range(1, 1000):
slot = slots[i]
if i >= 150:
sample.extend(slot)
elif len(slot) <= 1000:
sample.extend(slot)
else:
weights = np.array([weighted_categories[item.category] for item in slot])
weights = weights / np.sum(weights)
selected_indices = np.random.choice(len(slot), size=1000, replace=False, p=weights)
selected = [slot[i] for i in selected_indices]
sample.extend(selected)
>> weighted_categories
{'Tools_and_Home_Improvement': 0.0063880685753043434,
'Cell_Phones_and_Accessories': 0.014469315360038308,
'Health_and_Personal_Care': 0.7528361774639492,
'Handmade_Products': 0.05384522099950132,
'Appliances': 0.12074308788600839,
'Musical_Instruments': 0.051718129715198354}
print(f"There are {len(sample):,} items in the sample")
>> There are 198,092 items in the sample
Python
복사
이제, 샘플링 한 이후의 제품 분포를 확인해볼까요?
•
가격대 별 제품 분포 > 0$~150$ 사이의 제품을 샘플링하여, 보다 even한 분포를 가지게 되었습니다.
•
카테고리 별 제품 분포 > Home improvement 및 Cell phone 관련 제품 군의 수가 20% 이하로 샘플링 된 것을 확인했습니다.
데이터 업로드
이제, 전처리가 끝난 제품 데이터를 Hugging Face 저장소에 업로드합니다.
이때, 19만 개의 제품 데이터도 너무 많다고 판단했습니다. 따라서, 학습에 사용할 데이터는 2만개 정도로 압축하였습니다.
random.seed(42)
random.shuffle(sample)
train = sample[30_000:50_000]
validation = sample[200:300]
test = sample[190_000:]
print(f"Divided into a training set of {len(train):,} items, validation set of {len(validation):,} items, and test set of {len(test):,} items")
>> Divided into a training set of 20,000 items, validation set of 100 items, and test set of 8,092 items
Python
복사
실질적으로, LLM 파인튜닝에 필요한 Item 프롬프트를 한 번 살펴볼까요?
print(train[0].prompt)
>> How much does this cost to the nearest dollar?
Aluminum Manual Truck & Trailer Hand Crank Tarp Kit w/wo Tarp (Hand Crank ONLY (NO TARP))
All Truck Products EZ-Mount Dump Tarp Roller Kits are built for dump truck and dump trailer tarp applications. The extruded aluminum roller axle telescopes to the exact width of your truck. The tarp can attach to the roller with a tarp pocket or by preformed slot in the roller to bolt through the tarp's grommets. The kit includes crank handle, mounting brackets with bearings, tarp roller, Mesh Tarp, 20 ft pull-rope, 4 rope storing J-hooks, two rubber tarp straps with S-hooks, and mounting and tarp bolts. Dimensions 47 x 5.5 x 5.
Price is $144.00
Python
복사
print(test[0].test_prompt())
>> How much does this cost to the nearest dollar?
KOHLER Triton Centerset Lavatory Faucet, Polished Chrome (Handles Not Included)
From the Manufacturer With practical design and solid brass construction, Triton faucets are an exceptional value. Competitively priced Triton faucets feature washerless ceramic valving, a durable Polished Chrome finish and vandal-resistant index buttons. The two-handle centerset faucet will hold up to years of daily use. Select from a choice of lever, cross, square or wristblade handle style options. (Handles Not Included) Two-handle centerset lavatory faucet for 4-inch centers KOHLER ceramic disc valves exceed industry longevity standards two times for a lifetime of durable performance Solid brass construction for durability and reliability Choose from lever, cross, square, or wristblade handle options For installation on 4-inch
Price is $
Python
복사
테스트용 프롬프트는 $ 기호 이후에, 가격이 생략된 것을 확인할 수 있습니다.
맞아요! LLM 모델이 생략된 가격을 예측하는 것이죠
이제, 실제 업로드를 수행합니다.
train_prompts = [item.prompt for item in train]
train_prices = [item.price for item in train]
validation_prmopts = [item.prompt for item in validation]
validation_prices = [item.price for item in validation]
test_prompts = [item.test_prompt() for item in test]
test_prices = [item.price for item in test]
# Create a Dataset from the lists
train_dataset = Dataset.from_dict({"text": train_prompts, "price": train_prices})
validation_dataset = Dataset.from_dict({"text": validation_prmopts, "price": validation_prices})
test_dataset = Dataset.from_dict({"text": test_prompts, "price": test_prices})
dataset = DatasetDict({
"train": train_dataset,
"validation": validation_dataset,
"test": test_dataset
})
HF_USER = "jeffrey03"
DATASET_NAME = f"{HF_USER}/amazon-item-price-data-lite-version"
dataset.push_to_hub(DATASET_NAME, private=False)
Python
복사
또한, Baseline 모델 구축을 위해, 제품 데이터를 pickle 형식으로 저장합니다.
with open('train.pkl', 'wb') as file:
pickle.dump(train, file)
with open('test.pkl', 'wb') as file:
pickle.dump(test, file)
Python
복사
정리
이번 시간에는, Amazon 상품 가격 예측 봇 생성을 위해 데이터를 분석하고 전처리하는 시간을 가졌습니다.
다음 포스팅에서는 Baseline 모델을 구축하고, 이를 전처리 한 모델과 결합하여 예측 결과를 확인할게요.
고생하셨습니다