본문 바로가기
DEV/Web 개발

Web개발 :: Code Review _ Place 추천 기능 _TIL79

by 올커 2022. 12. 28.

■ JITHub 개발일지 79일차

□  TIL(Today I Learned) ::

Code Review _ Place 추천 기능

Place App 기능 페이지

1) index.html

  • 카테고리에서 음식, 장소 선택

2) place_preference.html

  • 활성화 : 유저 경험 데이터가 없는 유저(비로그인 계정, 리뷰가 없는 계정)

(1) 음식 종류(한식/분식, 패스트푸드, 중식, …)

(2) 장소(제주시, 서귀포시)

3) place_list

  • 조건에 맞는 추천 결과를 보여주는 화면

 

  • 로그인(리뷰有) 유저 : index.html → place_list.html
  • 비로그인(리뷰X) 유저 : index.html → place_preference.html → place_list.html

Place App Structure

├─ places
│  ├─ migrations
│  ├─ __init__.py
│  ├─ admin.py
│  ├─ apps.py
│  ├─ client.py
│  ├─ index.py
│  ├─ models.py    ***
│  ├─ rcm_places.py    ***
│  ├─ serializers.py    ***
│  ├─ tests.py
│  ├─ urls.py    ***
│  └─ views.py    ***

urls.py

from django.urls import path

from . import views

urlpatterns = [
    # Place
    path('<int:place_id>/', views.PlaceDetailView.as_view(), name='place_detail_view'),
    path('<int:place_id>/bookmarks/',views.PlaceBookmarkView.as_view(), name='place_bookmark_view'),
    
    #Recommendation
    path('selection/<int:choice_no>/', views.PlaceSelectView.as_view(), name="place_select_view"),
    path('new/<int:place_id>/<str:category>/', views.NewUserPlaceListView.as_view(), name='new_user_place_list_view'),
    path('list/<int:cate_id>/', views.UserPlaceListView.as_view(), name='user_place_list_view'),

    # Search
    path('search/', views.SearchListView.as_view(), name='search'),
]

Models.py

from django.db import models
from django.db.models import Q
from django.core.validators import MaxValueValidator

from users.models import User

...

class Place(models.Model):
    place_name = models.CharField('장소명', max_length=50)
    category = models.CharField('카테고리', max_length=20)
    rating = models.DecimalField('별점', max_digits=3, decimal_places=2, default=0, validators=[MaxValueValidator(5)])
    menu = models.TextField('메뉴', null=True)
    place_desc = models.CharField('소개글', max_length=255, null=True)
    place_address = models.CharField('주소', max_length=100)
    place_number = models.CharField('장소 전화번호', max_length=20)
    place_time = models.CharField('영업 시간', max_length=30)
    place_img = models.TextField('장소 이미지', null=True)
    latitude = models.CharField('위도', max_length=50, null=True)
    longitude = models.CharField('경도', max_length=50, null=True)
    hit = models.PositiveIntegerField('조회수', default=0)

    place_bookmark = models.ManyToManyField(User, verbose_name='장소 북마크', related_name="bookmark_place",blank=True)

    objects = PlaceManager()

    class Meta:
        db_table = 'places'

    def __str__(self):
        return f'[장소명]{self.place_name}'

    @property
    def hit_count(self):
        self.hit +=1
        self.save()

 

 


Serializers.py

from rest_framework import serializers

from .models import Place

#맛집 serializer
class PlaceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Place
        fields = ('id', 'place_name', 'category', 'rating', 'menu', 'place_desc', 'place_address', 'place_number', 'place_time', 'place_img', 'latitude', 'longitude', 'hit', 'place_bookmark', )

Views.py

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.generics import get_object_or_404
from rest_framework.pagination import PageNumberPagination

from django.db.models import Case, When

from drf_yasg.utils import swagger_auto_schema

from gaggamagga.permissions import IsAdminOrOntherReadOnly
from gaggamagga.pagination import PaginationHandlerMixin
from . import client
from .models import Place
from reviews.models import Review
from .serializers import PlaceSerializer
from .rcm_places import rcm_place_user, rcm_place_new_user

import random
import pandas as pd
import numpy as np

CHOICE_ONE = ['분식', '한식', '돼지고기구이','치킨,닭강정', '햄버거', '피자', '중식', '일식', '양식',  '태국음식', '인도음식', '베트남음식', '제주시', '서귀포시']

CHOICE_CATEGORY = (
        ('1', '분식'),
        ('2', '한식'),
        ('3', '돼지고기구이'),    #-- 한식
        ('4', '치킨,닭강정'),
        ('5', '햄버거'),
        ('6', '피자'),           #-- 패스트푸드
        ('7', '중식'),
        ('8', '일식'),
        ('9', '양식'),
        ('10', '태국음식'),
        ('11', '인도음식'),
        ('12', '베트남음식'),       #-- 아시아
        ('13', '제주시'),
        ('14', '서귀포시'),
    )

class PlaceListPagination(PageNumberPagination):
    page_size = 10

1) 맛집 상세 페이지, 삭제

 

class PlaceDetailView(APIView):
    permission_classes = [IsAdminOrOntherReadOnly]

    # 맛집 상세 페이지
    @swagger_auto_schema(operation_summary="맛집 상세 페이지",
                    responses={200 : '성공', 404 : '찾을 수 없음', 500 : '서버 에러'})
    def get(self, request, place_id):
        place = get_object_or_404(Place, id=place_id)
        place.hit_count
        serializer = PlaceSerializer(place)
        return Response(serializer.data, status=status.HTTP_200_OK)

    # 맛집 삭제
    @swagger_auto_schema(operation_summary="맛집 삭제",
                    responses={200 : '성공', 401 : '인증 에러', 404 : '찾을 수 없음', 500 : '서버 에러'})
    def delete(self, request, place_id):
        place = get_object_or_404(Place, id=place_id)
        place.delete()
        return Response({"message":"맛집 삭제 완료"},status=status.HTTP_200_OK)

2) 맛집 취향 선택

class PlaceSelectView(APIView):
    permission_classes = [AllowAny]
    
    # 맛집 취향 선택(리뷰가 없거나, 비로그인 계정일 경우)
    @swagger_auto_schema(operation_summary="맛집 취향 선택",
                responses={200 : '성공', 500 : '서버 에러'})
    def get(self, request, choice_no):     # choice_no : index에서 선택했던 음식종류 or 장소(1~12, 13, 14)
        load_no = random.randint(1, 6)
        # Case1: choice place location
        if choice_no > 12:      # 제주시(13), 서귀포시(14) 장소 선택시
            place_list = []
            for i in range(0, 12):      # contain을 통해 해당되는 장소의 쿼리셋을 가져오고, 그 중 카테고리별 필터링한 값의 첫번째를 가져온다.
                pick = Place.objects.filter(place_address__contains=CHOICE_CATEGORY[choice_no-1][1],category=CHOICE_CATEGORY[i][1]).first()
                if pick == None:        # 해당되는 카테고리가 없다면 pass
                    pass
                else:                   # 해당되는 카테고리가 있다면 id를 place_list에 추가한다.
                    place_list.append(pick.id)
            
            # Create list for custom sorting
            # for문을 통해 뽑아낸 리스트의 순서를 그대로 저장하기위해 아래 
            preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(place_list)])
            place = Place.objects.filter(id__in=place_list).order_by(preserved)
            serializer = PlaceSerializer(place, many=True)
            return Response(serializer.data, status=status.HTTP_200_OK)
        
        # Case2: choice food group
        else:
            if (choice_no == 3)|(choice_no == 6)|(choice_no == 12): #(한식, 패스트푸드, 아시아)
                # Merge queryset for 3categories
                place_list = []
                pick1 = Place.objects.filter(category=CHOICE_CATEGORY[choice_no-1][1])[load_no-1:load_no+2]
                pick2 = Place.objects.filter(category=CHOICE_CATEGORY[choice_no-2][1])[load_no-1:load_no+2]
                pick3 = Place.objects.filter(category=CHOICE_CATEGORY[choice_no-3][1])[load_no-1:load_no+2]
                pick = (pick1|pick2|pick3)     # 쿼리셋 병합
                serializer = PlaceSerializer(pick, many=True)
                return Response(serializer.data, status=status.HTTP_200_OK)
            else:   #(그외)
                place_list = []
                pick = Place.objects.filter(category=CHOICE_CATEGORY[choice_no-1][1])[0:9]
                serializer = PlaceSerializer(pick, many=True)
                return Response(serializer.data, status=status.HTTP_200_OK)
when_cases = []
for pos, pk in enumerate(place_list):
    when_cases.append(When(pk=pk, then=pos))

preserved = Case(*when_cases)

>>> *(asterisk)를 사용한 Unpacking
Case(When(...), When(...), When(...), ...)

3) 맛집 추천

 

##### 맛집(리뷰가 없거나, 비로그인 계정일 경우) #####
class NewUserPlaceListView(PaginationHandlerMixin, APIView):
    permission_classes = [AllowAny]
    pagination_class = PlaceListPagination

    # 맛집 리스트 추천
    @swagger_auto_schema(operation_summary="맛집 리스트 추천(비유저)",responses={200 : '성공', 500 : '서버 에러'})
    def get(self, request, place_id, category):
        places = pd.DataFrame(list(Place.objects.values()))
        cate_id = CHOICE_ONE.index(category)+1
        if cate_id <= 12:       # Case1: choice food group
            if (cate_id == 3)|(cate_id == 6)|(cate_id == 12): #(한식, 패스트푸드, 아시아)
                category1 = CHOICE_CATEGORY[cate_id-1][1]    분식
                category2 = CHOICE_CATEGORY[cate_id-2][1]    한식
                category3 = CHOICE_CATEGORY[cate_id-3][1]    돼지고기구이
                place1 = places[places['category'].str.contains(category1)]
                place2 = places[places['category'].str.contains(category2)]
                place3 = places[places['category'].str.contains(category3)]
                place_list = [place1, place2, place3]
                places = pd.concat(place_list, ignore_index=True)  # 데이터 프레임 병합, 기존 index 무시
            else:
                cate = CHOICE_CATEGORY[cate_id-1][1]
                places = places[places['category'].str.contains(cate)]
        else:                   # Case2: choice place location
            cate = CHOICE_CATEGORY[cate_id-1][1]
            places = places[places['place_address'].str.contains(cate)]

        # Create dataframe
        reviews = pd.DataFrame(list(Review.objects.values()))

        places.rename(columns={'id':'place_id'}, inplace=True)   # reviews 데이터 프레임과 컬럼 이름 맞추기
        place_ratings = pd.merge(places, reviews, on='place_id')   # places와 reviews를 하나의 데이터프레임으로 병합
        review_user = place_ratings.pivot_table('rating_cnt', index='author_id', columns='place_id')   # 피봇테이블 생성
        place_list = rcm_place_new_user(review_user=review_user, place_id=place_id)  # 머신러닝(코사인 유사도) 실행
        preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(place_list)])
        place = Place.objects.filter(id__in=place_list).order_by(preserved)   # place_list의 id 포함조건을 or로 줌, preserverd에 따라 정렬
        page = self.paginate_queryset(place)    # 페이지네이션 실행
        serializer = self.get_paginated_response(PlaceSerializer(page, many=True).data)
        return Response(serializer.data, status=status.HTTP_200_OK)
##### 맛집(유저일 경우) #####
class UserPlaceListView(PaginationHandlerMixin, APIView):
    permission_classes = [IsAuthenticated]
    pagination_class = PlaceListPagination

    # 맛집 리스트 추천
    @swagger_auto_schema(operation_summary="맛집 리스트 추천(유저)",
                    responses={200 : '성공', 401 : '인증 에러', 500 : '서버 에러'})
    def get(self, request, cate_id):
        places = pd.DataFrame(list(Place.objects.values()))
        if cate_id <= 12:       # Case1: choice food group
            if (cate_id == 3)|(cate_id == 6)|(cate_id == 12):
                category1 = CHOICE_CATEGORY[cate_id-1][1]
                category2 = CHOICE_CATEGORY[cate_id-2][1]
                category3 = CHOICE_CATEGORY[cate_id-3][1]
                place1 = places[places['category'].str.contains(category1)]
                place2 = places[places['category'].str.contains(category2)]
                place3 = places[places['category'].str.contains(category3)]
                place_list = [place1, place2, place3]
                places = pd.concat(place_list, ignore_index=True)
            else:
                category = CHOICE_CATEGORY[cate_id-1][1]
                places = places[places['category'].str.contains(category)]
        else:                   # Case2: choice place location
            category = CHOICE_CATEGORY[cate_id-1][1]
            places = places[places['place_address'].str.contains(category)]

        # Create dataframe
        reviews = pd.DataFrame(list(Review.objects.values()))
        places.rename(columns={'id':'place_id'}, inplace=True)
        place_ratings = pd.merge(places, reviews, on='place_id')
        review_user = place_ratings.pivot_table('rating_cnt', index='author_id', columns='place_id')

        if (request.user.id not in review_user.index):     # 리뷰 데이터에 해당 유저가 없는 경우
            col = random.choice(review_user.columns.to_list())    # .to_list 리스트타입으로 변형한 후 랜덤 column 뽑기
            review_user.loc[request.user.id] = np.nan     # 해당 유저 아이디를 na로 데이터열을 
            review_user.loc[request.user.id, col] = 5     # 위에서 뽑은 column에 5점 주기
        review_user = review_user.fillna(0)               # na로 비어있는 값들은 0으로 변환
        place_list = rcm_place_user(review_user=review_user, user_id = request.user.id)
        preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(place_list)])
        place = Place.objects.filter(id__in=place_list).order_by(preserved)
        page = self.paginate_queryset(place)
        serializer = self.get_paginated_response(PlaceSerializer(page, many=True).data)
        return Response(serializer.data, status=status.HTTP_200_OK)

rcm_places.py (머신러닝 추천시스템)

- 유사도 측정 방식 : Cosine Similarity 채택

 

- 협업 필터링 추천 : ‘특정 인자(맛집)에 대한 선호도가 유사한 유저는 다른 인자(맛집)에 대해서도 선호도가 비슷할 것이다’ 라고 가정

- 사용자의 아이템 평가 데이터를 이용해 비슷한 선호도를 갖는 다른 사용자가 선택한 아이템 추천

  1. 환경 셋팅
import pandas as pd
import numpy as np

from sklearn.metrics.pairwise import cosine_similarity

import sys
import os
import django

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.append(BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gaggamagga.settings')
django.setup()

from reviews.models import Review
from places.models import Place

import random
  • django startapp을 통해 만든 파일이 아닌 곳에서 models를 불러오기 위해서 django 사용환경을 설정해주어야 했다.

(1) BASE_DIR = os.path.dirname(os.path.abspath(file)) (파일 경로 변수에 저장)

(2) sys.path.append (해당파일 경로에 환경변수 추가)

(3) os.environ.setdefault (환경변수 setting)

(4) django.setup() (django 환경 로드)

 

# 유사한 유저 정보 조회 및 추천(기존 사용이력이 없는 사용자)
def rcm_place_new_user(review_user, place_id):

    # 유저 데이터 데이터 프레임 생성
    new_idx = int(review_user.iloc[len(review_user)-1].name)+1     # 데이터프레임의 가장 마지막 레코드에 유저 추가
    review_user.loc[new_idx] = np.nan
    review_user.loc[new_idx, place_id] = 5
    review_user = review_user.fillna(0)

    # 코사인 유사도 분석
    user_sim_np = cosine_similarity(review_user, review_user)    # 만들어진 review_user를 활용해 cosine 유사도 분석
    user_sim_df = pd.DataFrame(user_sim_np, index=review_user.index, columns=review_user.index)   # 분석 결과를 Dataframe으로 변환

    # 유사한 유저 선택
    picked_user = user_sim_df.sort_values(by=new_idx, ascending=False).index[1]    # sort_values를 통해 내림차순 정렬 후 가장 유사한 유저 pick
    result = review_user.query(f"author_id == {picked_user}").sort_values(ascending=False, by=picked_user, axis=1)   # pick한 유저가 맛집별로 준 rating값 내림차순으로 가져오기

    result_list = []
    for column in result:
        result_list.append(column)    # 위에서 가져온 rating값을 리스트에 넣고 리턴
    return result_list
# 유사한 유저 정보 조회 및 추천(기존 유저)
def rcm_place_user(review_user, user_id):

    # 코사인 유사도 분석
    user_sim_np = cosine_similarity(review_user, review_user)
    user_sim_df = pd.DataFrame(user_sim_np, index=review_user.index, columns=review_user.index)

    # 유사한 유저 선택
    picked_user = user_sim_df.sort_values(by=user_id, ascending=False).index[1]
    result = review_user.query(f"author_id == {picked_user}").sort_values(ascending=False, by=picked_user, axis=1)

    # 가장 유사한 유저 추천
    result_list = []
    for column in result:
        result_list.append(column)
    return result_list

pagination.py

from rest_framework.pagination import PageNumberPagination

class BasePagination(PageNumberPagination):
    page_size_query_param = "page_size"

class PaginationHandlerMixin(object):
    @property
    def paginator(self):
        if not hasattr(self, "_paginator"):
            if self.pagination_class is None:
                self._paginator = None
            else:
                self._paginator = self.pagination_class()
        else:
            pass
        return self._paginator

    def paginate_queryset(self, queryset):
        if self.paginator is None:
            return None
        return self.paginator.paginate_queryset(queryset, self.request, view=self)

    def get_paginated_response(self, data):
        assert self.paginator is not None
        return self.paginator.get_paginated_response(data)

 

 

 

반응형

댓글