Введение

Переехав в Австралию из места, которое славится своей пустыней и жарким климатом, я заметил одну вещь — погода здесь самая непредсказуемая. Удивительно, как климат меняется от региона к региону, что делает его увлекательной темой для изучения.

Австралия — страна, известная своими разнообразными ландшафтами и погодными условиями. Одним из наиболее значительных погодных явлений, влияющих на страну, являются осадки. Количество осадков в Австралии значительно варьируется в зависимости от региона и времени года, что делает их решающим фактором для различных отраслей, включая сельское хозяйство, туризм и развитие инфраструктуры.

Я обнаружил, что мне постоянно интересно, что принесет погода не только на следующий день, но даже в ближайшие несколько часов. Это любопытство побудило меня углубиться в анализ данных о погоде, и я наткнулся на интригующий набор данных на Kaggle — дождь в Австралии.

Работая над проектом, мы будем замечать характер дождя и предсказывать возможность дождя на следующий день, используя различные методы моделирования. Мы обсудим ключевые аспекты данных, включая очистку данных, анализ признаков, выбор модели и оценку.

Описание набора данных

Набор данных в этом проекте собран с Kaggle. Набор данных содержит данные об осадках с 2007 по 2017 год. Он хранит характер осадков в различных городах Австралии. Данные содержат 23 функции.

Возможности:

  • Дата: дата наблюдения.
  • Местоположение. Общепринятое название местоположения метеостанции.
  • MinTemp: минимальная температура в градусах Цельсия.
  • MaxTemp: максимальная температура в градусах Цельсия.
  • Осадки. Количество осадков, выпавших за день в мм.
  • Испарение: так называемое испарение на сковороде класса А (мм) за 24 часа до 9:00.
  • Солнечный свет. Количество часов яркого солнечного света в день.
  • WindGustDir: направление самого сильного порыва ветра за 24 часа до полуночи.
  • WindGustSpeed: скорость (км/ч) самого сильного порыва ветра за 24 часа до полуночи.
  • WindDir9am: направление ветра в 9:00.
  • WindDir3pm: направление ветра в 15:00.
  • WindSpeed9am: скорость ветра (км/ч), усредненная за 10 минут до 9:00.
  • WindSpeed3pm: скорость ветра (км/ч), усредненная за 10 минут до 15:00.
  • Влажность в 9:00: влажность (в процентах) в 9:00.
  • Влажность в 15:00: влажность (в процентах) в 15:00.
  • Давление в 9:00: атмосферное давление (гПа) приведено к среднему уровню моря в 9:00.
  • Давление в 15:00: атмосферное давление (гПа) приведено к среднему уровню моря в 15:00.
  • Облака в 9:00: часть неба, закрытая облаками в 9:00. ***
  • Облако3pm: часть неба, закрытая облаками (октами: восьмыми) в 15:00. См. Cload9am для описания значений
  • Temp9am: температура (градусы C) в 9:00.
  • Temp3pm: температура (градусы C) в 15:00.
  • RainToday: логическое значение: 1, если осадки (мм) за 24 часа до 9:00 превышают 1 мм, в противном случае 0
  • RainTomorrow: количество дождя на следующий день в мм. Используется для создания переменной ответа RainTomorrow. Своего рода мера «риска». Эта функция будет целевой переменной.

* Измеряется в «октах», которые являются единицей восьмых. Он записывает, сколько восьмых неба закрыто облаками. Значение 0 указывает на абсолютно чистое небо, а значение 8 – на полную облачность.

Начало работы с проектом

Чтобы начать работу с любым проектом по науке о данных, самым первым шагом является выявление проблем. Мы уже знаем, что набор данных предполагается использовать для прогнозирования дождя на следующий день. После импорта всех библиотек и набора данных мы проверяем все функции и то, как они влияют на другие функции. Использование функций .describe() дает статистический отчет о наборе данных. Помимо этого, мы также можем использовать функцию .info() для типа данных и количества каждого столбца.

#Reading the Data
data = pd.read_csv('weatherAUS.csv')
data.head()
#Understanding the Data
data.describe().transpose()
#Checking if there are any Missing Values
data.info()

Отсутствующие значения

После понимания набора данных мы должны искать пропущенные значения. Отсутствующие значения в наборе данных играют очень важную роль в проекте. Если их не лечить, результаты могут быть неточными. Как уже упоминалось, мы идентифицируем отсутствующие значения в каждом столбце с помощью функции .info(). Мы также можем использовать функцию .isna(). Помимо этого, мы можем визуализировать отсутствующие значения в наборе данных с помощью функции missingno.matrix() из библиотеки missingno.

#Finding number of Misssing Values in each column
data.isna().sum()

#visualizing missing values in each column
missingno.matrix(data, figsize = (30,20))

С 145460 строками мы проверяем, как отсутствующие значения влияют на столбцы. Лучше всего, чтобы каждый столбец был нормально распределен. Чтобы проверить распределение каждого столбца, мы воспользуемся функцией .hist(). Это позволит визуализировать распределение каждой функции.

#Plotting each column
data.hist(bins=10 ,figsize=(16,12), color = 'royalblue')
plt.show()

Мы можем заметить, что только некоторые из них имеют почти нормальное распределение (например, MinTemp, Humidity3pm, MaxTemp, Temp9am), в то время как другие смещены либо вправо, либо влево. Нам нужно очистить набор данных, чтобы сделать их нормально распределенными.

Обработка пропущенных значений

Существуют разные способы обработки пропущенных значений. Наиболее распространенный способ их устранения — удаление пропущенных значений. Однако это было бы полезно, когда пропущено всего несколько значений. Другой метод заключается в добавлении наиболее распространенных значений к нулевым значениям. Таким образом, наш набор данных остается того же размера, а пропущенные значения обрабатываются.

# Treating missing values
# missing numerical columns replaces with median
def r_num(df):
    for i in df.select_dtypes(['int', 'float']):
        df[i] = df[i].fillna(df[i].median())
    return df

# Replace missing object columns with mode
def r_obj(df):
    for i in df.select_dtypes('object'):
        df[i] = df[i].fillna(method='ffill')
    return df

df = r_num(df)
df = r_obj(df)
df.info()

В наборе данных есть 2 типа данных. Отсутствующие значения числового типа данных обрабатываются с использованием медианы, а значения объектного типа данных обрабатываются по режиму.

При проверке нормальности каждого столбца некоторые из столбцов снова показывают асимметрию и не имеют нормального распределения. Это может быть связано с присутствующими в них выбросами. Мы будем обращаться с ними по мере проведения исследовательского анализа данных.

Корреляция

Корреляция относится к статистической взаимосвязи между двумя объектами. Он измеряет степень линейной зависимости двух переменных.

#Correlation
corr = df.corr()
#Plotting the Correlation
plt.subplots(figsize=(11,9))
sns.heatmap(corr,annot=True, square=True)

Так как наша цель — иметь дело с дождем, мы проверим коллинеарность признаков с Rainfall. Благодаря этому мы можем знать, какие столбцы оставить, а какие удалить.

# extract the correlation coefficients for Rainfall column
rainfall_corr = corr['Rainfall']

# print the correlation coefficients in descending order
print(rainfall_corr.sort_values(ascending=False))

Исследовательский анализ данных (EDA)

EDA — это подход к анализу наборов данных для обобщения их основных характеристик, часто с использованием статистических графиков и других методов визуализации данных.

Мы рассмотрим каждую функцию и проанализируем их как статистически, так и визуально.

Местоположение

Построение набора данных в соответствии с местоположением.

#Counting the number of data in each place
df.Location.value_counts()
#plotting the location of the data where it is collected from
sns.set(rc={'figure.figsize':(15,6)})
sns.countplot(data=df, x="Location")
plt.xticks(rotation=90)
plt.grid(linewidth = 0.6)
plt.show()

Seaborn позволяет нам красочно построить график.

Направление ветра

Мы пытаемся понять направление ветра в течение дня, которое может вызвать дождь.

sns.set(rc={'figure.figsize':(13,6)})
fig, ax =plt.subplots(3,1)

sns.countplot(data=df,x='WindDir9am',ax=ax[0])
sns.countplot(data=df,x='WindDir3pm',ax=ax[1])
sns.countplot(data=df,x='WindGustDir',ax=ax[2])
fig.tight_layout()

У нас есть 3 графика, показывающие направление ветра в разное время суток. Сначала в 9:00, 15:00, а затем у нас есть направление порыва ветра.

  • В 9 часов утра направление ветра в основном северное и наименее западное юго-западное.
  • Точно так же ветер в 15:00 дует в основном с юго-востока и меньше всего с северо-северо-востока.
  • Направление порыва ветра в основном на западе и меньше всего на северо-северо-востоке, как ветер в 15:00.

Дождь сегодня и дождь завтра

Мы группируем 2 функции и анализируем их.

fig, ax =plt.subplots(1,2)
print("Rain Today Value Count: \n", df.RainToday.value_counts())
print("\nRain Tomorrow Value Count: \n",df.RainTomorrow.value_counts())

plt.figure(figsize=(19,18))
sns.countplot(data=df,x='RainToday',ax=ax[0])
sns.countplot(data=df,x='RainTomorrow',ax=ax[1])

Осадки

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

#Rainfall per month
df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
rainfall =[df['Date'].dt.year, df['Date'].dt.month, df['Rainfall']]
headers = ['Year', 'Month', 'Rainfall']
rainfall_df = pd.concat(rainfall, axis=1, keys=headers)

plt.figure(figsize=(10,8))
a = rainfall_df.groupby('Month').agg({'Rainfall':'sum'})
a.plot(kind='bar', color='blue')
plt.title('Rainfall distribution in each month', fontsize=15)
plt.xlabel('Month', fontsize=10)
plt.ylabel('Rainfall (in mm)', fontsize=10)
plt.xticks(rotation=0)

На графике видно, что в октябре меньше всего дождей. Однако в марте больше всего дождей в течение года. Возможно, это связано с сезонными изменениями.

#Rainfall per year
a = rainfall_df.groupby('Year').agg({'Rainfall':'count'})
a.plot(kind='bar', color='green')
plt.title('Rainfall distribution in each year', fontsize=15)

Что касается Года, 2008 год кажется наименее дождливым, поскольку набор данных начинается с середины 2008 года. Мы видим, что 2014, 2015, 2016 годы являются наиболее дождливыми годами и имеют почти одинаковое количество дождей.

#plotting Rainfall by Location
plt.figure(figsize=(13,8))
plt.scatter(df['Location'],df['Rainfall'], color='darkgoldenrod')
plt.xlabel("Location")
plt.xticks(rotation=90)
plt.title('Rainfall(mm) in each location', fontsize=15)
plt.ylabel("Rainfall")
plt.show()

Анализируя количество осадков в разных районах, мы видим, что в Крейнсе, Брисбене, Таунвилле, Дарвине и Кофс-Харборе выпало больше осадков по сравнению с другими местами. Самый сухой дождь в Нхиле, возможно, потому, что у него меньше данных.

Выбросы

После того, как мы проанализировали различные функции данных, пришло время обработать выбросы, вызвавшие аномалии столбцов. Лучший способ проверить выбросы — построить коробчатую диаграмму. Диаграммы рассеяния также помогают визуализировать выбросы. Но здесь мы будем использовать диаграммы для каждой функции данных.

#outliers
plt.figure(figsize=[25,15])
df.boxplot(column= ['MinTemp', 'MaxTemp', 'Rainfall', 'Evaporation', 
                    'Sunshine', 'WindGustSpeed', 'WindSpeed9am', 
                    'WindSpeed3pm', 'Humidity9am', 'Humidity3pm', 
                    'Cloud9am', 'Cloud3pm', 'Temp9am', 'Temp3pm'])
plt.xticks(rotation=50)
plt.show()

Вместо визуализации мы также можем найти выбросы, проверив z-оценку данных или используя метод IQR для их извлечения.

Обработка выбросов

Существует много способов лечения выбросов. Наиболее распространенный способ борьбы с выбросами — их удаление. Этот метод полезен, когда количество выбросов меньше, поскольку потеря нескольких точек данных не повлияет на анализ. Другой распространенный метод — масштабирование выбросов. Таким образом, данные не удаляются и дополнительно нормализуются.

В этом проекте мы будем отбрасывать выбросы, так как нам не нужно беспокоиться о потере точек данных.

#treating the outliers
threshold = 0.04
for col in df:
    if df[col].dtype=='float64':   
    # Lower and upper threshold
        lower_threshold = df[col].quantile(threshold)
        upper_threshold = df[col].quantile(1-threshold)
    
    # Dropping the values below lower threshold and beyond upper threshold
        df = df[(df[col]>=lower_threshold) & (df[col]<=upper_threshold)]

Мы устанавливаем порог и вычисляем IQR. С установленным IQR мы будем отбрасывать значения, выходящие за пределы допустимого диапазона. Несмотря на то, что выбросы обрабатываются, некоторые из них все еще остаются. Мы не будем обрабатывать их, так как оставшихся выбросов едва ли немного, это не окажет значительного влияния на общие данные.

Если мы сейчас проверим распределение каждой функции, окажется, что все они распределены нормально.

Кодирование данных

Когда мы выполняли EDA, мы заметили, что каждая функция имеет разный тип данных. Нам нужно будет преобразовать их в единый формат для дальнейшего анализа — этот метод называется кодированием данных.

#Converting into numerical
df['RainTomorrow'] = df['RainTomorrow'].map({'Yes': 1, 'No': 0})
df['RainToday'] = df['RainToday'].map({'Yes': 1, 'No': 0})
print(df['RainToday'].head(3))
print(df['RainTomorrow'].head(3))

#Encoding categorical variables to numeric ones
from sklearn.preprocessing import LabelEncoder
for c in df.columns:
#Since we are encoding object datatype to integer/float
    if df[c].dtype=='object':    
        lbl = LabelEncoder()
        lbl.fit(list(df[c].values))
        df[c] = lbl.transform(df[c].values)

Как только данные будут закодированы, мы удалим столбец «Дата», так как он нам не понадобится.

Разделение данных

С предварительно обработанными данными мы разделим данные на обучающий и тестовый наборы для выполнения различных методов моделирования. Мы также можем установить набор проверки данных. Однако это необходимо, когда мы выполняем настройку гиперпараметров, а проверочный набор извлекается из обучающего набора.

Здесь нам не понадобится проверочный набор. Итак, данные разбиваются на тренировочные и тестовые в соотношении 70:30.

# spliting training and testing data
X = df.drop(['RainTomorrow'], axis = 1)
y = df['RainTomorrow']

X_train, X_test, y_train, y_test = train_test_split(X,y,test_size=0.3,random_state=27)

print('x_train shape is: ', X_train.shape)
print('y_train shape is: ', y_train.shape)
print('x_test shape is: ', X_test.shape)
print('y_test shape is: ', y_test.shape)

Поскольку целевой переменной является RainTomorrow, мы будем отбрасывать ее и иметь только во фрейме данных «y».

Моделирование данных

Можно использовать различные модели. Однако мы будем использовать только несколько моделей для предсказания дождя.

Гауссовский наивный метод байесовского метода

Это очень распространенный алгоритм классификации машинного обучения, который предполагает наивную независимость между признаками. Он упрощает процесс моделирования, эффективен и может обрабатывать большие наборы данных со многими функциями. Можно предположить, что в данных об осадках некоторые характеристики, такие как температура, влажность и скорость ветра, подчиняются распределению Гаусса. Гауссовский NB использует это предположение для оценки вероятности дождя. Вероятностный характер позволяет легко интерпретировать и понимать прогнозы модели.

#GaussianNB Model
model = GaussianNB()
model.fit(X_train, y_train) # Train the model on the training data
  
predicted = model.predict(X_test)  
#classification Report    
from sklearn.metrics import classification_report
print(classification_report(y_test, predicted))

print("The accuracy of Gaussian Naive Bayes model is : ", accuracy_score(y_test, predicted)*100, "%")
print("\nF1 score for Gaussian Naive Bayes is :",f1_score(y_test, predicted,)*100, "%")

Модель была оценена с использованием отчета о классификации, в котором говорится, что точность модели составляет 81%. Нам все еще нужно посмотреть на производительность других моделей, чтобы найти наиболее подходящую модель.

Линейная регрессия

Линейная регрессия позволяет нам оценить линейную связь между независимыми переменными и зависимой переменной (RainTomorrow). Это эффективно и просто по сравнению с другими сложными методами моделирования. Он используется в целях прогнозирования путем оценки вероятности завтрашнего дождя на основе других признаков.

# Create a linear regression model
model = LinearRegression()

# Train the model on the training data
model.fit(X_train, y_train)

# Make predictions on the test data
predicted = model.predict(X_test)

# Evaluate the model using R-squared
r2 = r2_score(y_test, predicted)
print("The R-squared score of the linear regression model is:", r2)
mse = mse_(y_test, predicted)
print("Mean squared error: ", mse)

# Evaluate the model using RMSE
rmse = np.sqrt(mse)
print("Root Mean Squared Error: ", rmse)

Чтобы оценить производительность модели, мы используем R-квадрат, MSE и RMSE.

  • Оценка R-квадрата модели линейной регрессии является мерой того, насколько хорошо модель соответствует данным. Он варьируется от 0 до 1, чем выше значение, тем лучше соответствие. Здесь показатель R-квадрата равен 0,156, что означает, что модель объясняет только 15,6% изменчивости целевой переменной.
  • Среднеквадратическая ошибка (MSE) — это мера среднеквадратичной разницы между прогнозируемыми значениями и фактическими значениями. Чем ниже MSE, тем лучше производительность. В этом случае MSE составляет 0,107, что означает, что прогнозируемые значения примерно на 0,107 единиц отличаются от фактических значений.
  • Среднеквадратическая ошибка (RMSE) представляет собой квадратный корень из MSE, а также является мерой средней разницы между прогнозируемыми и фактическими значениями, но в тех же единицах, что и целевая переменная. Здесь RMSE составляет 0,327, что означает, что прогнозируемые значения примерно на 0,327 единиц отличаются от фактических значений.

Логистическая регрессия

Этот статистический метод широко используется для задач бинарной классификации. Он моделирует взаимосвязь между переменными-предикторами (например, температурой, влажностью) и вероятностью дождя. Он может обрабатывать несбалансированные наборы данных, фиксировать нелинейные отношения и позволяет моделировать сложные отношения.

# Initiatlize the model
logreg = LogisticRegression(solver='liblinear', random_state = 0)  
#liblinear is optimized for high dimensionality and can handle sparse input data. 

logreg.fit(X_train, y_train) # Fit the model
y_pred_test = logreg.predict(X_test) # Predict data points 

print(classification_report(y_test, y_pred_test))
# Print accuracy scores
print(f'Model accuracy score: {round(accuracy_score(y_test, y_pred_test) * 100, 3)}%')
print(f'Training set score: {round(logreg.score(X_train, y_train) * 100, 3)}%')
print(f'Test set score: {round(logreg.score(X_test, y_test) * 100, 3)}%')
print("F1 score for Logistic Regression model is :",f1_score(y_test, y_pred_test,)*100, "%")

Мы оценили модель, извлекая отчет о классификации модели, который сообщает нам о точности, балле F1, точности и отзыве. Кроме того, мы проводим перекрестную проверку модели и визуализируем ее.

#Cross Validation for logistic regression
from sklearn.model_selection import cross_validate
results = cross_validate(estimator = logreg,
                        X = X_train,
                        y = y_train,
                        cv = 9,
                        scoring = ['accuracy', 'precision', 'recall','f1'],
                        return_train_score = True)
results
labels = ['1st Fold', '2nd Fold', '3rd Fold', '4th Fold', '5th Fold', '6th Fold', '7th Fold', '8th Fold', '9thFold']
x_axis = np.arange(len(labels))

plt.figure(figsize = (15, 7))
plt.bar(x_axis - 0.2, results['train_accuracy'], width = 0.4, color = 'indigo', label = 'Training')
plt.bar(x_axis + 0.2, results['test_accuracy'], width = 0.4, color = 'peru', label = 'Validation')
plt.xticks(x_axis,labels)
plt.ylim(0.5,1)
plt.legend()
plt.title('Accuracy Scores in 9 Folds')

Оценка точности обучения и проверки почти одинакова во всех 9 случаях. Это кажется хорошим результатом, поскольку и обучение, и проверка находятся в одном и том же диапазоне, который составляет около 86% и почти одинаков.

K — ближайший сосед

KNN — это универсальная модель машинного обучения для задач классификации и регрессии. Это непараметрический алгоритм, который не предполагает базовое распределение данных. KNN идентифицирует кластеры, рассматривая ближайших соседей. Ключевым гиперпараметром является K, представляющий количество рассматриваемых соседей.

# Create a KNN model with k=5
model = KNeighborsClassifier(n_neighbors=5)
# Train the model on the training data
model.fit(X_train, y_train)

# Make predictions on the test data
predicted = model.predict(X_test)

# Evaluate the model using accuracy
accuracy = accuracy_score(y_test, predicted)
print(classification_report(y_test, predicted))
print("The accuracy of KNN model is : ", accuracy_score(y_test, predicted)*100, "%")
print("\nF1 score for KNN model is :",f1_score(y_test, predicted,)*100, "%")

# Create a KNN model with k=9
model = KNeighborsClassifier(n_neighbors=9)

# Train the model on the training data
model.fit(X_train, y_train)

# Make predictions on the test data
predicted = model.predict(X_test)

# Evaluate the model using accuracy
accuracy = accuracy_score(y_test, predicted)
print(classification_report(y_test, predicted))
print("The accuracy of KNN model is : ", accuracy_score(y_test, predicted)*100, "%")
print("\nF1 score for KNN model is :",f1_score(y_test, predicted,)*100, "%")

Чтобы проверить производительность модели, была проведена перекрестная проверка как с 5-кратным, так и с 9-кратным повторением. Точность k=5 составила 85%, тогда как k=9 имела лучшую точность, 86%. Тем не менее, это ненамного лучше, чем логистическая регрессия, так как показатель точности все еще меньше, 85,60%. Даже оценка F1 кажется меньше, 25,33%, но больше, чем у логистической регрессии.

При визуализации перекрестной проверки KNN для 9 кратностей оценки точности по 9 кратностям находятся в диапазоне 85–90 %. Точность обучения всегда была выше, чем оценка точности проверки.

По сравнению с моделью логистической регрессии оценка точности обучения и проверки KNN не совпадает и имеет большую разницу, чем в логистической регрессии.

Оценка модели

Мы разделили данные на обучающий и тестовый набор данных. Для моделей мы использовали наивный байесовский алгоритм Гаусса, линейную регрессию, логистическую регрессию и KNN. Мы даже провели перекрестную проверку моделей и проверили их точность в 5 или 9 раз. Для сравнения каждой модели мы получили следующие результаты:

  • Гауссовский наивный байесовский метод имел точность 81%,
  • Линейная регрессия подходила, но R-квадрат был недостаточно высоким, а среднеквадратическая ошибка была довольно высокой.
  • Логистическая регрессия была точной на 86%.
  • KNN имел показатель точности 85% как для 5, так и для 9 раз.

С оценкой точности мы можем сделать вывод, что логистическая регрессия является наиболее подходящей моделью для этого набора данных. Набор данных может дать лучшие результаты с другими моделями, такими как Random Forest и XGB Classifier, поскольку они имеют лучшую способность обработки выбросов и обеспечивают ранжирование важности признаков, что может помочь в определении наиболее подходящих признаков для прогнозирования. Мы всегда можем поэкспериментировать и найти больше способов решить проблему.

Заключение

Из этого набора данных мы узнаем, как справиться с любым проектом по науке о данных. Самый первый шаг, очистка данных, играет решающую роль в любом проекте. Для того, чтобы очистить, можно использовать различные методы. Понимание того, что данные пытаются сказать, является еще одним важным шагом, мы используем EDA, чтобы понять это. Даже после очистки не все данные готовы к обучению или тестированию, именно здесь вступает в действие предварительная обработка данных, которая позволяет нам сделать данные пригодными для использования. Он включает в себя обработку выбросов, нормализацию и преобразование в тот же формат. Очищенные и обработанные данные готовы к обучению и тестированию. Затем мы можем использовать различные типы моделей и оценивать результаты. Методы выбора и оценки функций помогают понять ценность функций с целевой переменной в любом проекте. Следуя этим шагам, любой набор данных можно легко проанализировать, чтобы найти полезную информацию.

В сфере проектов по науке о данных ключом к успеху является экспериментирование.

Изучение разнообразных методологий, алгоритмов и методов не только обогащает наше понимание данных, но также позволяет нам находить новые идеи и эффективно оптимизировать наши модели.

Надеюсь, вы найдете эту статью полезной!

Чтобы оставаться со мной в курсе,

  • Свяжитесь со мной в LinkedIn
  • Подпишись на меня в Твиттере"
  • Проверьте мой Github для большего количества проектов