Machine Learning: Как да прогнозираме цени на автомобили?

Регресията е един от методите, прилагани от специалистите в областта на машинното обучение за решаване на конкретни бизнес задачи. В зависимост от начина на използване, можем да разделим задачите в 2 категории:

  • изследване на връзката на всяка от отделните характеристики с целевата променлива
  • прогнозиране на непрекъснати числови стойности (например заплата, цена, тегло и др.) на база на вече известни данни.

В тази статия с практически пример ще разгледаме решение на регресионна задача за прогнозиране на цени на автомобили. Ще използваме езика за програмиране Python и неговите библиотеки за анализ на данни и машинно обучение.

С какви данни ще работим?

Данните съдържат 4041 реда и 74 колони и включват обяви за продажба на автомобили от различни български сайтове. Извлечени са на 01.07.2021г.

5 случайно избрани реда изглеждат по следния начин:

brandmodelmonth_prodyear_prodcolorengine_typehpgearboxcategorymileagegpsadaptive_flantiblock_sysairbags_backairbags_fronttire_pressure_controlparktronicisofixauto_start_stopdvd_tvstep_tipno_key_ignitionusb_av_inaux_outputsdiff_blockageboardcomplight_sensorel_mirrorsel_susp_adjclimatronicmf_steering_wheelsw_heating7seatsbuy_backbartergas_syslbsaved_soldsbleasingmethane_syspartsnew_importcreditservice_booktuning2_3_doorsxenon_lightsalloy_wheelsmetalicheated_wipersrollbartowbarhalogen_lightsmoving_roofoffroadalarmarmoredcascowinchreinforced_glass_windowssuederight_swtaxidate_createdtime_createddaymonthyearviewsdealercityregionprice
526Mercedes-BenzMLфевруари2015ЧервенБензинов333АвтоматичнаДжип67000ДаНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНе26 април 202118:0326април202111923АвтокъщаПловдивЮжен централен49950
1049AudiA3ноември2013СивБензинов140АвтоматичнаСедан190000ДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНе15 юни 202122:1415юни2021183АвтокъщаДупницаЮгозападен23500
649PorschePanameraсептември2012ЧеренБензинов400АвтоматичнаХечбек187000ДаНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНе11 юни 202116:4211юни20212469АвтокъщаПловдивЮжен централен54000
974AudiA3февруари2006СребъренДизелов105РъчнаХечбек186000НеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНе17 юни 202121:0117юни202117АвтокъщаРусеСеверен централен7500
920AudiA3май2003ЧеренДизелов101РъчнаХечбек200000НеНеДаНеНеНеНеНеНеНеНеНеНеНеДаДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНе10 юни 202115:4410юни2021691АвтокъщаСофияЮгозападен4500

Преглед на данните

За да добием представа за данните, с които разполагаме, ще създадем няколко визуализации. Те ще ни помогнат да намерим отговор на следните въпроси:

  • Кои марки и модели са най-предлагани?
  • В кои части на България има най-много обяви и на какви цени са автомобилите?
  • Какъв е процентът на обявите от Частно лице и Автокъща според типа двигател?
  • Какво е разпределението на променливите?
  1. Кои марки и модели са най-предлагани?

Най-предлаганото превозно средство е Audi A4, като има 324 обяви за този автомобил. На 10-то място е Mercedes-Benz C с 81 обяви.

Най-предлаганите превозни средства са на марките Audi и BMW, съответно 1029 и 633 на брой обяви. На 10-то място е марката Ford с 62 обяви.

  1. В кои части на България има най-много обяви и на какви цени са автомобилите?

Изчислена е средната претеглена стойност на броя обяви за всеки регион, като теглата са броя население на градовете в съответния регион. Има най-много обяви в Югозападния регион на България – 1526 обяви средно, а най-малко в Северен централен – 85. Има и 1 единствена обява за автомобил, който се намира извън България.

Тук отново е изчислена средна претеглена стойност, но този път за цената на автомобилите. Най-висока е средната цена на автомобил в Югозападния регион – 28389.80 лв. При Североизточния, Югоизточния и Южен централен са близки като стойности. За единствената обява извън България, цената на автомобила е 8900 лв.

  1. Какъв е процентът на обявите от частно лице и автокъща според типа двигател?


За обявите на автомобили от Автокъща най-предлагани са превозни средства с дизелов двигател – 2170 на брой обяви (69.6%), а най-малко с електрически – 13 обяви или 0.42%.

От частни лица се предлагат също най-много автомобили с дизелов двигател – 578 обяви или 62.6%. Има само 1 обява за автомобил с електрически двигател.

  1. Какво е разпределението на променливите?

Хистограмите представят разпределенията на числовите променливи в извадката, като с червената пунктирана вертикална линия е означена средната стойност, а със зелената е медианата.

Следната таблица представя средните и медианните стойности за всяка от числовите променливи.

VariableMeanMedian
0year_prod2009.522009.00
1age11.4812.00
2hp194.22170.00
3mileage175794.17179890.00
4price22942.4912999.00
5views2841.96581.00

Някои изводи:

  • Разпределенията на годините на производство и възрастта на автомобила са близки до нормалното. При тях медианата и средната стойност са почти равни – 2009 и 2009.52.
  • При конските сили и пробега също няма толкова голяма разлика между средната стойност и медианата.
  • По-големи разлики се забелязват при цената и броя преглеждания на обявите. Двете разпределения не са нормално разпределени, а са асиметрични и дясно изтеглени.

Тъй като променливите цена (price) и брой преглеждания (views) не са нормално разпределени, ще ги трансформираме чрез логаритмичната функция, предоставена от библиотеката NumPy. В противен случай можем да получим подвеждащи резултати от регресионните модели.

# Прилагане на трансформация с логаритъм върху 2-те променливи
df['price'] = np.log(df['price'])
df['views'] = np.log(df['views'])

Тъй като имаме много на брой бинарни променливи в извадката, ще разгледаме разпределението само на някои от тях. За да определим точно на кои, ще използваме класът Variance Threshold на библиотеката Scikit-learn, за да премахнем тези бинарни променливи, при които едната категория заема над 75% от данните.

# Откриване на бинарните променливи
df_b = df.loc[:,df.replace({'Да':1, 'Не':0}).isin([0,1]).all()]

# Прилагане на VarianceThreshold
vt = VarianceThreshold(threshold=0.75 * (1 - 0.75))
vt.fit(df_b.replace({'Да':1, 'Не':0}))
gpsauto_start_stopxenon_lightsmetalic
253НеНеНеНе
822НеНеНеНе
558НеДаДаНе
352ДаНеНеДа
3809НеНеНеНе

След прилагане на VarianceThreshold, оставаме с 4 бинарни променливи – наличие на GPS, наличие на Auto Start Stop функционалност, дали превозното средство има ксенонови фарове и дали е в цвят металик. На стълбовидните диаграми по-надолу, можете да видите тяхното разпределение.

Обявите, на които стойността на променливите е Не, са повече.

Кодиране на категорийни променливи и стандартизация на данните

Необходимо е категорийните променливи да ги превърнем в числови, тъй като регресионните модели изискват входните данни да са числа. Нужно е също така те да бъдат и мащабирани, тъй като при използване на регресионните модели, които изчисляват коефициенти, можем да получим резултати с голяма грешка.

# Отделяне на характеристиките и целевата променлива
X = df.drop(['price'], axis=1)
y = df[['price']]

# Отделяне на числовите променливи и създаване на StandartScaler обект
numeric_features = ['year_prod', 'hp', 'mileage', 'views']
numeric_transformer = StandardScaler()

# Отделяне на категорийните променливи и създаване на OneHotEncoder обект
categorical_features = df.select_dtypes('object').columns
categorical_transformer = OneHotEncoder(drop='first')

# Кодиране на нечисловите променливи и стандартизация на данните
preprocessor = ColumnTransformer(
    transformers=[
        ('scaler', numeric_transformer, numeric_features),
        ('onehot', categorical_transformer, categorical_features),
        ('col_keep', 'passthrough', ['gps', 'auto_start_stop', 'xenon_lights', 'metalic'])])

# Прилагане на обработките
X_transformed = preprocessor.fit_transform(X).toarray()

# Създаване на списък с колоните
cols_list = [preprocessor.transformers_[0][2],
             preprocessor.transformers_[1][1].get_feature_names(categorical_features),
             preprocessor.transformers_[2][2]]
cols = list(itertools.chain.from_iterable(cols_list))

# Запазване на данните в нов DataFrame
X_coded = pd.DataFrame(X_transformed, columns=cols)

След обработката на данните, колоните в извадката ни стават 510 на брой. Следващата стъпка е да открием дали има аномалии в данните и кои са по-важните характеристики, които да използваме при изграждане на моделите за машинно обучение.

Обработка на отличителни стойности и избор на по-важни характеристики

За откриване на аномалии ще използваме методът k най-близки съседи (KNN) на библиотеката PyOD.

# Създаване на KNN обект с 5 съседа и метрика 'евклидово разстояние'
alg = KNN(n_neighbors=5, metric='euclidean')

# Прилагане на алгоритъма KNN
knn = alg.fit(X_coded)
X_coded['outlier'] = knn.labels_

Алгоритъмът KNN e открил 404 обяви, в които се срещат аномалии. Тях ще ги премахнем от извадката, тъй като може да повлияят негативно на моделите за машинно обучение.

Следващата стъпка е да използваме класа SelectKBest на библиотеката Scikit-learn, за да открием 5-те характеристики, които оказват най-голямо влияние при при определяне на цената. Той получава като параметър статистически тест. В нашия случай това е f_regression, който първо открива корелацията между всяка характеристика и целевата променлива и конвентира получените резултати в F score и p-value. Накрая се извеждат тези променливи, получили най-добри оценки от теста.

# Откриване на 5-те най-значими характеристики чрез SelectKBest
importances = fs.select_k_best(X_coded, y, 5)

# Визуализация на резултатите
fs.plot_best_features(importances)

SelectKBest e определил година на производство, конски сили, това дали превозното средство е с ръчна скоростна кутия, дали има ксенонови фарове и Auto Start Stop функционалност като най-важните променливи, определящи цената.

На следващата графика можете да видите корелационна матрица, която ни показва колко е силна зависимостта между отделните характеристики.

# Откриване на корелация между независимите характеристики
X_coded = X_coded[importances.features.values]
corr = X_coded.corr()

Премахваме променливите наличие на Auto Start Stop функционалност и ръчна скоростна кутия, тъй като между първата и година на производство има 60% положителна корелация, а между втората и променливата конски сили има 59% отрицателна корелация.

Изграждане на модели за машинно обучение с параметри по подразбиране

По време на тази стъпка ще създадем модели за машинно обучение, използващи различни регресионни алгоритми, като ще оставим параметрите, които са им зададени по подразбиране.

Първо е необходимо да разделим извадката на 2 части – 70% от данните ще са за обучение на моделите, а 30% за тест. Ще използваме train_test_split на библиотеката Scikit-learn.

# Разделяне на извадката
X_train, X_test, y_train, y_test = train_test_split(X_coded,
                                                    y,
                                                    test_size=0.3,
                                                    random_state=17)

След като сме разделили данните, трябва да създадем и обучим моделите. Ще използваме 12 различни регресионни алгоритми при изграждане на моделите, след което ще тестваме до колко добре те прогнозират стойностите на целевата променлива.

# Създаване на модели с параметри по подразбиране
knn = KNeighborsRegressor()
lr = LinearRegression()
sgd = SGDRegressor()
hr = HuberRegressor()
ridge = Ridge()
br = BayesianRidge()
omp = OrthogonalMatchingPursuit()
et = ExtraTreesRegressor()
gbr = GradientBoostingRegressor()
ab = AdaBoostRegressor()
rf = RandomForestRegressor()
dt = DecisionTreeRegressor()

# Запазване на моделите в списък
regressors = [knn, lr, sgd, hr, ridge, br, omp, et, gbr, ab, rf, dt]

# Обучение на моделите
for regressor in regressors:
    regressor.fit(X_train, y_train)

# Създаване на празен списък за резултатите
predictions = []

# Прогнозиране на стойности и запазване на резултатите с списъка
for name, regressor in zip(names,regressors):
    locals()['y_pred_' + str(name)] = np.exp(regressor.predict(X_test))
    predictions.append(locals()['y_pred_' + str(name)])

Получените прогнозирани стойности е необходимо да бъдат съпоставени с действителните. За тази цел ще разгледаме някои често използвани метрики за оценка на регресионни алгоритми. Това ще ни позволи да добием представа до колко добре моделите с параметри по подразбиране са прогнозирали цените на превозните средства.

Метриките, които ще разгледаме са:

  • R-квадрат (R-squared) и Изравнен R-квадрат (Adjusted R-squared)
  • Средна абсолютна грешка (MAE) и Средна абсолютна процентна грешка (MAPE)
  • Средна квадратична грешка (Mean Squared Error) & Корен от средната квадратична грешка (Root Mean Squared Error)
ModelR2_scoreAdj R2MSERMSEMAEMAPE
7ExtraTreesRegressor0.880.88123842231.5511128.444694.3822.86
10RandomForestRegressor0.880.88124523408.5711159.014787.3422.39
11DecisionTreeRegressor0.880.88120830168.1410992.284789.5324.04
8GradientBoostingRegressor0.880.88123208997.3611099.955015.9122.27
0KNeighborsRegressor0.840.84163466018.5812785.385425.2323.23
2SGDRegressor0.710.71294067408.7217148.396255.4424.45
4Ridge0.680.68323521437.0517986.706331.0924.58
5BayesianRidge0.680.68323748351.9717993.016331.5124.58
1LinearRegression0.680.68324008977.9218000.256331.9924.58
3HuberRegressor0.650.65350406954.5418719.166412.7324.87
9AdaBoostRegressor0.690.69305244621.7717471.257002.4127.06
6OrthogonalMatchingPursuit0.440.44556220119.7123584.3210120.7842.96

Алгоритмите използващи дървете на решенията се справят най-добре от всички модели, като коефициентите им на детерминация са 0.88. ExtraTreesRegressor е на първо място според средна абсолютна грешка, а GradientBoostingRegressor е с най-нисък резултат за средна абсолютна процентна грешка – 22.27%, но стойностите на останалите грешки са по-високи. Най-лошо се е справил OrthogonalMatchingPursuit с 0.44 коефициент на детерминация и много високи грешки.

Избор на модел и откриване на оптимални параметри

Ще изберем модела, използващ алгоритъма ExtraTreesRegressor, защото като цяло даде най-добри резултати при теста с параметри по подразбиране. В тази стъпка ще използваме библиотеката Hyperopt, за да открием оптималните параметри за модела, при които средната абсолютна процентна грешка е най-ниска.

Първо ще зададем какво да бъде пространството от параметри, след което ще създадем и приложим целева фукнция.

# Създаване на пространство от параметри
params = {'criterion': hp.choice('criterion', ['mae', 'mse']),
        'max_depth': hp.quniform('max_depth', 1, 100, 1),
        'max_features': hp.choice('max_features', ['sqrt','log2', None]),
        'min_samples_leaf': hp.choice('min_samples_leaf', [2, 5, 10, 14]),
        'min_samples_split' : hp.choice ('min_samples_split', [2, 4, 6, 8, 10]),
        'n_estimators' : hp.choice('n_estimators', [200, 500, 800])
    }

# Създаване на целева функция
def objective(params):
    et_new = ExtraTreesRegressor(criterion = params['criterion'], max_depth = params['max_depth'],
                                 max_features = params['max_features'],
                                 min_samples_leaf = params['min_samples_leaf'],
                                 min_samples_split = params['min_samples_split'],
                                 n_estimators = params['n_estimators'], 
                                 )

    mape = cross_val_score(et_new, X_train, y_train, scoring=neg_mean_absolute_error, cv = 5).mean()

    return {'loss': -mape, 'status': STATUS_OK }

Тъй като целевата променлива е преминала през трансформация с логаритъм, откриването на оптималните параметри е на база стойността на MAPE, а не MAE.

# Прилагане на целевата функция
trials = Trials()
best = fmin(fn= objective,
            space= params,
            algo= tpe.suggest,
            max_evals = 80,
            trials= trials)

Оптималните параметри, които е открихме чрез функцията fmin(), са следните:

HyperparameterValue
criterionMAE
max_depth53
max_featuresNone
min_samples_leaf2
min_samples_split6
n_estimators200

След като вече имаме оптималните параметри, е необходимо да тестваме до колко добре моделът ще прогнозира стойностите на целевата променлива.

# Създаване на нов модел с оптималните параметри
et_optimised = ExtraTreesRegressor(criterion = criterion[best['criterion']],
                                   max_depth = best['max_depth'], 
                                   max_features = m_features[best['max_features']], 
                                   min_samples_leaf = leaf[best['min_samples_leaf']], 
                                   min_samples_split = split[best['min_samples_split']], 
                                   n_estimators = n_est[best['n_estimators']])

# Обучение на оптимизирания модел
et_optimised.fit(X_train, y_train)

# Прогнозиране на стойности
et_new_pred = np.exp(et_optimised.predict(X_test))

Получените резултати от модела са следните:

MetricsScores
0R2_score0.84
1Adj R20.84
2MSE162523237.78
3RMSE12748.46
4MAE5093.31
5MAPE22.15

Резултатите са малко по-лоши от тези, получени при модела с параметрите по подразбиране по отношение на всички метрики освен средна абсолютна процентна грешка, но това е, защото търсихме оптимални параметри, при които тя да е най-ниска. Също така моделът не е обучен върху цялата обучаваща извадка наведнъж, а при оптимизиране на параметрите се използва кръстосана валидация и моделът се обучава и тества всеки път върху различни по-малки части от данните. Това позволява генерализация и ни помага да избегнем преобучение на модела (проблем, който би довел до лоши резултати при постъпване на нови данни).

Средната цена на превозно средство е 23383.51 лв, така че средна абсолютна грешка от 5093.31 лв можем да кажем, че е приемлива. Моделът може допълнително да се оптимизира, за да се намали тази грешка, като се добавят още стойности в пространството от параметри и чрез Hyperopt (или някоя друга библиотека за избор на оптимални параметри) да се открият по-подходящи стойности за параметрите на модела. Всичко е въпрос на множество експерименти докато се достигне до оптимално решение.

Коефициентът на детерминация е 0.84, което означава, че 84% от дисперсията на цената се обяснява от стойностите на независимите променливи.

Средната абсолютна процентна грешка е 22.15%, което я прави приемлива според интерпретацията на C. D. Lewis в книгата Industrial and business forecasting methods: a practical guide to exponential smoothing and curve fitting.

Следната графика съпоставя пронозираните и действителните стойности и ни позволява да видим до колко те се разминават като стойности.

В сравнение с предишната графика, тази диаграма на разсейването ни показва точно колко са разликите между пронозираните и действителните стойности. Когато те са с отрицателен знак, тогава моделът е поставил по-ниска цена за автомобила от действителната, а когато е с положителен знак, е поставил по-висока цена.

Запазване на модела и тест върху нови данни

Ще използваме библиотеката joblib, за да съхраним както модела, така и функцията с предварителната обработка на данните в отделни файлове, които да можем след това да използваме директно върху новите данни и да правим прогнози.

joblib.dump(et_optimised, './model/et_model.pickle')
joblib.dump(preprocessor,'./model/preprocessor.pickle')

След като сме съхранили файловете, ще приложим модела върху извадка с данни, които той не е виждал до този момент. Тя съдържа 473 реда с обяви за превозни средства, на които липсва поставена цена.

Първо е необходимо данните да преминат през съответната предварителна обработка, за да може да бъдат използвани от модела за машинно обучение.

# Прилагане на предварителна обработка върху новите данни
df_coded = p.preprocess_data(df)

5 случайни реда от данните след прилагане на предварителната обработка изглеждат по следния начин:

year_prodxenon_lightshp
11-0.24632300.0636255
127-1.278920-0.738607
119-0.41842200.14162
218-0.2463231-0.827744
165-1.7952100.319894
# Зареждане на модела за машинно обучение
predictor = joblib.load(f'./model/et_model.pickle')

# Прогнозиране на стойности
y_pred = np.exp(predictor.predict(df_coded))

Следната таблица представя 5 реда от новите данни, но вече с прогнозираните от модела цени.

brandmodelmonth_prodyear_prodagecolorengine_typehpgearboxcategorymileagegpsadaptive_flantiblock_sysairbags_backairbags_fronttire_pressure_controlparktronicisofixauto_start_stopdvd_tvstep_tipno_key_ignitionusb_av_inaux_outputsdiff_blockageboardcomplight_sensorel_mirrorsel_susp_adjclimatronicmf_steering_wheelsw_heating7seatsbuy_backbartergas_syslbsaved_soldsbleasingmethane_syspartsnew_importcreditservice_booktuning2_3_doorsxenon_lightsalloy_wheelsmetalicheated_wipersrollbartowbarhalogen_lightsmoving_roofoffroadalarmarmoredcascowinchreinforced_glass_windowssuederight_swtaxidate_createdtime_createddaymonthyearviewsdealercityregionprice
427VWSciroccoдекември199031Тъмно син мет.Бензинов112РъчнаКупе223445НеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеНеДаНеНеНеНеНеНеНеНе1 юли 20219:311юли202125Частно лицеРусеСеверен централен9100
118ToyotaCorollaфевруари200615Тъмно син мет.Дизелов116РъчнаВан172000НеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНе1 юли 202111:231юли202115АвтокъщаВарнаСевероизточен9250.36
346BMW535юни20147МеталикБензинов306АвтоматичнаСедан128500ДаНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНе1 юли 202110:061юли202111Частно лицеСофияЮгозападен78271.8
2NissanX-trailсептември200516СивДизелов136РъчнаДжип184000НеНеНеДаНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНе1 юли 202111:571юли2021124Частно лицеРусеСеверен централен11352.8
106SkodaSuperbюни20183БялДизелов190АвтоматичнаКомби167000ДаНеНеНеНеНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеДаНеНеНеДаНеНеНеНеНеНеНеНеНеНеНеНеНеНеНеНе1 юли 202111:271юли202189АвтокъщаВарнаСевероизточен28540.2

Извод

В този практически пример успяхме да изградим модел, който да прогнозира цени на превозни средства. Това обаче не означава, че те трябва да са напременно такива, каквито моделът е определил, тъй като той прави прогнози с известна грешка. Резултатите могат винаги да бъдат подобрени след допълнителни по-задълбочени анализи, експериментиране с различни параметри и прилагане на допълнителни обработки върху данните.

Цялостният пример можете да изтеглите от тук.

Ако искате да научите повече за метриките за оценка на регресионни модели, можете да прочетете в статията Machine Learning: Метрики за оценка на регресионни модели.

Искате да научите повече за машинното обучение?

Включете се в курса по машинно обучение и анализ на данни с Python.

Научете повече

Автор: Десислава Христова