一文掌握sklearn中的支持向量机

2021-06-24 11:03:45 浏览数 (1)

前面两节已经介绍了线性SVC非线性SVC的分类原理。本节将在理论的基础上,简单介绍下sklearn中的支持向量机是如何实现数据分类的。并参照理论中的概念对应介绍重要参数的含义,以及如何调节参数,使得模型在数据集中得到更高的分数。

下面先介绍sklearn.svm.SVC中的各个参数的含义。文章有点长,建议搜藏。本文字数7202

代码语言:javascript复制
sklearn.svm.SVC(
                C=1.0,
                kernel='rbf',
                degree=3,
                gamma='auto_deprecated',
                coef0=0.0,
                shrinking=True,
                probability=False,
                tol=0.001,
                cache_size=200,
                class_weight=None,
                verbose=False,
                max_iter=-1,
                decision_function_shape='ovr',
                random_state=None,
                )

C : float, optional (default=1.0) 松弛系数的惩罚项系数。

  • 如果C值设定比较大,那SVC可能会选择边际较小的,能够更好地分类所有训练点的决策边界。
  • 如果C值设定比较小,那SVC会尽量最大化边界,决策功能会更简单,但代价是训练的准确度。

kernel : string, optional (default='rbf') 指定要在算法中使用核函数类型。可输入"linear", "poly", "rbf", "sigmoid", "precomputed", 或者可调用对象(如函数,类等)。如果给出可调用对象,则这个对象将被用于从特征矩阵X预先计算内核矩阵。该矩阵是一个(n_samples, n_samples)结构的数组。 degree : int, optional (default=3) 多项式核函数的次数("poly"),如果核函数没有选择"poly",这个参数将会被忽略。 gamma : float, optional (default='auto') 核函数的系数,仅在参数kernel的选项为"poly", "rbf"和"sigmoid"时有效。 当输入"auto"时自动使用"1/(n_features)"作为gamma的取值。 在sklearn0.22版本中,将可输入"scale",则使用"1/(n_features * X.std())"作为gamma的取值。 coef0 : float, optional (default=0.0) 核函数中的独立项,它只在参数kernel为 "poly"和 "sigmoid"的时候有效。 shrinking : boolean, optional (default=True) 是否使用收缩启发计算(shrinking heuristics),如果使用,有时可以加速最优化的计算进程,加速迭代速度。谨慎使用。 probability : boolean, optional (default=False) 是否启用概率估计。必须在调用fit之前启用它,启用此功能会减慢SVM的运算速度。 tol : float, optional (default=1e-3) 停止迭代的容差。 cache_size : float, optional 指定核函数占用的缓存的大小(以MB为单位) class_weight : {dict, 'balanced'}, optional 将类别i的参数设置为class_weight[i]*C。如果没有给出具体的值,则所有类都占用相同的权重1. "Balanced"模式使用y的值自动调整与输入数据中的类频率成反比的权重为 "n_samples/(n_classes*np.bincount(y))" verbose : bool, default: False 启用详细输出。此设置利用libsvm中的"进程前运行时间设置"。如果启用,则可能无法在多线程上下文中正常运行。 max_iter : int, optional (default=-1) 最大迭代次数,输入"-1"表示没有限制。 decision_function_shape : 'ovo', 'ovr', default='ovr' 对所有分类器,是否返回结构为(n_samples, n_classes)的one-rest-rest('ovr')决策函数,或者返回libsvm中原始的结构为(n_samples ,n_classes * (n_classes - 1 )/2)的one-vs-one('ovo')决策函数(在多分类中使用)。 random_state : int, RandomState instance or None, optional (default=None) 在对数据进行混洗以用于概率估计时使用的伪随机种子生成器。

  • 如果输入整数,则random_state是随机数生成器使用的随机数种子;
  • 如果是RandomState实例,则random_state是随机数生成器;
  • 如果为None,则随机数生成器是np.random使用的RandomState实例。

参数

这里涉及到SVM在软间隔数据上的推广。 线性支持向量机在解决线性不可分数据时,引入软间隔最大化。可以对每个样本点 引进一个松弛变量 ,使得函数间隔加上松弛变量后大于等于1。

于是约束条件为

y_i(w cdot x_i b)ge1-zeta_i

通过将硬间隔推广到软间隔上,让决策边界能够忍受一小部分训练误差。这时,决策边界就不是单纯地寻求最大边际,而是需要找出一个"最大边际"与"被分错的样本数量"之间的平衡,因为对于软间隔的数据来说,边际越大被分错的样本也就会越多。

因此线性不可分的线性支持向量机的学习问题变成如下凸二次规划问题(原始问题),即损失函数为

min_{w,b,zeta}frac{||w||^2}{2} Csum^n_{i=1}zeta_i
s.t.,,,y_i(w^Tcdot x b ge1-zeta_i)
,,,,,,,,,,,,zeta_i ge0,,,,i=1,2,cdots,N

其中,称为惩罚参数,值越大对误分类的惩罚越大。

参数用于权衡"训练样本的正确分类"与"决策函数的边际最大化"两个不可同时完成的目标,希望找出一个平衡点来让模型的效果最佳。

在实际使用中, 和核函数的相关参数(gamma,degree等等)们搭配,往往是SVM调参的重点。与gamma不同,没有在对偶函数中出现,并且是明确了调参目标的,所以可以明确究竟是否需要训练集上的高精确度来调整的方向。

代码语言:javascript复制
from sklearn.datasets import load_breast_cancer
from sklearn.svm import SVC
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
import numpy as np
from time import time
import datetime
data = load_breast_cancer()
X = data.data
y = data.target
Xtrain, Xtest, Ytrain, Ytest = train_test_split(X,y,test_size=0.3,random_state=420)
#调线性核函数
score = []
C_range = np.linspace(0.01,30,50)
for i in C_range:
    clf = SVC(kernel="linear",C=i,cache_size=5000).fit(Xtrain,Ytrain)
    score.append(clf.score(Xtest,Ytest))
print(max(score), C_range[score.index(max(score))])
plt.figure(figsize=(10,6))
plt.plot(C_range,score)
plt.show()

0.9298245614035088 0.6220408163265306

参数

这里需要理解SVC在非线性数据上的推广。参见非线性SVM与核函数。

线性SVM需要求解凸二次规划问题

min_{alpha},,,,frac12sum^N_{i=1}sum^N_{j=1}alpha_ialpha_jy_iy_j(x_icdot x_j)-sum^N_{i=1}alpha_i
s.t. ,,,,,sum^N_{i=1}alpha_iy_i=0 ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
,,,,,,,,0 le alpha_i le C,,,,,i=1,2,3,cdots,N

在线性支持向量机对偶问题的目标函数中的内积可以用核函数来替代,推广到非线性数据上:

min_{alpha},,,,frac12sum^N_{i=1}sum^N_{j=1}alpha_ialpha_jy_iy_jK(x_i,x_j)-sum^N_{i=1}alpha_i

同样分类决策函数中的内积也可以用核函数替代:

f(x)=signleft(sum^N_{i=1}alpha_i^*y_iK(x_i,x_j) b^*right)

选用不同的核函数,就可以解决不同数据分布下的寻找超平面问题。在SVC中,这个功能由参数"kernel"和一系列与核函数相关的参数来进行控制。参数"kernel"在sklearn中可选以下几种选项:

输入

含义

解决问题

核函数表达式

gamma

degree

coef0

"linear"

线性核

线性

K(x,y)=x*y

No

No

No

"poly"

多项式核

偏线性

K(x,y)=(γ(x*y) r)^d

Yes

Yes

Yes

"sigmoid"

双曲正切核

非线性

K(x,y)=tanh(γ(x*y) r)

Yes

No

Yes

"rbf"

高斯径向基

偏非线性

K(x,y)=exp(-γ||x-y||^2), γ>0

Yes

No

No

可以看出,除了选项"linear"之外,其他核函数都可以处理非线性问题。多项式核函数有次数 ,当为1的时候它就是在处理线性问题,当为更高次项的时候它就是在处理非线性问题。

至于在实际问题中,选用哪个核函数来处理问题,并没有太多研究,但可以通过在不同的核函数中循环去找寻最佳的核函数来选取合适的核函数。

代码见附录1

可以观察到,线性核函数和多项式核函数在数据相对线性可分时表现不错,在像环形数据那样的非线性可分数据则表现糟糕。在线性数据集上,线性核函数和多项式核函数即便有扰动项也可以表现不错,可见多项式核函数是虽然也可以处理非线性情况,但更偏向于线性的功能。多项式核函数多被用于图像处理之中。

Sigmoid核函数在线性可分数据上不如线性核函数,在线性不可分数据上不如高斯径向基。

高斯径向基核函数rbf基本在任何数据集上都表现不错,属于比较万能的核函数。

量纲不统一对SVC的影响

在线性数据集--乳腺癌数据集上实验。

代码语言:javascript复制
from sklearn.datasets import load_breast_cancer
from sklearn.svm import SVC
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
import numpy as np
from time import time
import datetime
data = load_breast_cancer()
X = data.data
y = data.target
Xtrain, Xtest, Ytrain, Ytest = train_test_split(X,y,test_size=0.3,random_state=420)
Kernel = ["linear","poly","rbf","sigmoid"]
for kernel in Kernel:
    time0 = time()
    clf= SVC(kernel = kernel
             , gamma="auto"
             , degree = 1
             , cache_size=5000
             ).fit(Xtrain,Ytrain)
    print("The accuracy under kernel %s is %f" % (kernel,clf.score(Xtest,Ytest)))
    print(datetime.datetime.fromtimestamp(time()-time0).strftime("%M:%S:%f"))

The accuracy under kernel linear is 0.929825 00:00:481713 The accuracy under kernel poly is 0.923977 00:00:093716 The accuracy under kernel rbf is 0.596491 00:00:044915 The accuracy under kernel sigmoid is 0.596491 00:00:004982

注意到有三点:

  • 对于核函数Kernel="poly"时,参数degree = 1,其实就是线性核函数,两者得分几乎一样。当参数degree设置默认值时,多项式核函数要消耗大量的时间,运算非常的缓慢。
  • 乳腺癌数据集是一个线性数据集,线性核函数的效果很好。而rbfsigmoid两个擅长非线性的数据从效果上来看完全不可用。
  • 线性核函数的运行速度远远不如非线性的两个核函数。

查看数据的量纲

代码语言:javascript复制
import pandas as pd
data = pd.DataFrame(X)
data.describe([0.01,0.05,0.1,0.25,0.5,0.75,0.9,0.99]).T

将数据标准化后继续带入SVC中查看结果。

代码语言:javascript复制
from sklearn.preprocessing import StandardScaler
X = StandardScaler().fit_transform(X)
Xtrain, Xtest, Ytrain, Ytest = train_test_split(X,y,test_size=0.3,random_state=420)
Kernel = ["linear","poly","rbf","sigmoid"]
for kernel in Kernel:
    time0 = time()
    clf= SVC(kernel = kernel
             , gamma="auto"
             , degree = 1
             , cache_size=5000
             ).fit(Xtrain,Ytrain)
    print("The accuracy under kernel %s is %f" % (kernel,clf.score(Xtest,Ytest)))
    print(datetime.datetime.fromtimestamp(time()-time0).strftime("%M:%S:%f"))

The accuracy under kernel linear is 0.976608 00:00:009973 The accuracy under kernel poly is 0.964912 00:00:003989 The accuracy under kernel rbf is 0.970760 00:00:007978 The accuracy under kernel sigmoid is 0.953216 00:00:003989

量纲统一之后所有核函数的运算时间都大大地减少了,尤其是对于线性核,而多项式核函数变成了计算最快的。其次,rbf表现出了非常优秀的结果。可以得到的结论:

  1. 线性核,尤其是多项式核函数在高次项时计算非常缓慢
  2. rbf和多项式核函数都不擅长处理量纲不统一的数据集,但这两个缺点都可以由数据无量纲化来解决。因此,SVM执行之前,非常推荐先进行数据的无量纲化!

核函数相关的参数

degree & gamma & coef0

  • 对于线性核函数,"kernel"是唯一能够影响它的参数。
  • 参数"gamma"就是表达式中的,degree就是多项式核函数的次数,参数coef0就是常数项 。其中,高斯径向基核函数受到gamma的影响,而多项式核函数受到全部三个参数的影响。

高斯径向基核函数

调整gamma的方式其实比较容易,那就是画学习曲线。

代码语言:javascript复制
score = []
gamma_range = np.logspace(-10, 1, 50) #返回在对数刻度上均匀间隔的数字
for i in gamma_range:
    clf = SVC(kernel="rbf",gamma = i,cache_size=5000).fit(Xtrain,Ytrain)
    score.append(clf.score(Xtest,Ytest))
print(max(score), gamma_range[score.index(max(score))])
plt.figure(figsize=(10,6))
plt.plot(gamma_range,score)
plt.show()

多项式核函数

因为三个参数共同作用在一个数学公式上影响它的效果,往往使用网格搜索来共同调整三个对多项式核函数有影响的参数。

代码语言:javascript复制
from sklearn.model_selection import StratifiedShuffleSplit
from sklearn.model_selection import GridSearchCV
time0 = time()
gamma_range = np.logspace(-10,1,20)
coef0_range = np.linspace(0,5,10)
param_grid = dict(gamma = gamma_range,coef0 = coef0_range)

cv = StratifiedShuffleSplit(n_splits=5, test_size=0.3, random_state=420)
grid = GridSearchCV(SVC(kernel = "poly",degree=1,cache_size=5000),
param_grid=param_grid, cv=cv)
grid.fit(X, y)
print("The best parameters are %s with a score of %0.5f" % (grid.best_params_,grid.best_score_))
print(datetime.datetime.fromtimestamp(time()-time0).strftime("%M:%S:%f"))

>>> The best parameters are {'coef0': 0.0, 'gamma': 0.18329807108324375} with a score of 0.96959
>>> 00:08:358310

网格搜索为我们返回了参数coef0=0,gamma=0.18329807108324375,但整体的分数是0.96959,虽然比调参前略有提高,但依然没有超过高斯径向基核函数rbf的结果。

class_weight

二分类SVC中样本不均衡问题参数

机器学习中样本不平衡处理方法中介绍了一些基本方法,比如上采样下采样。但这些采样方法会增加样本的总数,对于支持向量机这个样本总是对计算速度影响巨大的算法来说,并不想轻易地增加样本数量。况且,支持向量机中的决策结果仅仅决策边界的影响,而决策边界又仅仅受到参数和支持向量的影响,单纯地增加样本数量不仅会增加计算时间,可能还会增加无数对决策边界无影响的样本点。因此在支持向量机中,要依赖调节样本均衡的参数:SVC类中的class_weight和接口fit中可以设定的sample_weight

参数:class_weight

可输入字典或者"balanced”,可不填,默认None

对SVC,将类i的参数设置为class_weight [i] * C

  • 如果没有给出具体的class_weight,则所有类都被假设为占有相同的权重1,模型会根据数据原本的状况去训练。
  • 如果希望改善样本不均衡状况,请输入形如{"标签的值1":权重1,"标签的值2":权重2}的字典,则参数将会自动被设为:标签的值1的C:权重1 * C,标签的值2的C:权重2*C
  • 使用"balanced"模式,这个模式使用的值自动调整与输入数据中的类频率成反比的权重为n_samples/(n_classes * np.bincount(y))

接口fit的参数:sample_weight

数组,结构为 (n_samples, ),必须对应输入fit中的特征矩阵的每个样本。

每个样本在fit时的权重,让权重乘以每个样本对应的值来迫使分类器强调设定的权重更大的样本。通常,较大的权重加在少数类的样本上,以迫使模型向着少数类的方向建模。

通常来说,这两个参数我们只选取一个来设置。如果同时设置了两个参数,则会同时受到两个参数的影响,即 class_weight中设定的权重 * sample_weight中设定的权重 * C

代码见附录2

灰色是做样本平衡之前的决策边界,大约有一半少数类(红色)被分错,多数类(紫色点)几乎都被分类正确了。

橙色是做样本平衡之后的决策边界,做了样本平衡后,少数类几乎全部都被分类正确了,但是多数类有许多被分错了。

从准确率的角度来看,不做样本平衡的时候准确率反而更高,做了样本平衡准确率反而变低了,这是因为做了样本平衡后,为了要更有效地捕捉出少数类,模型误伤了许多多数类样本,而多数类被分错的样本数量 > 少数类被分类正确的样本数量,使得模型整体的精确性下降。这里需要根据实际需要决定是否需要使用class_weight 参数。

SVM实现概率预测

参数probability

接口predict_proba & decision_function

decision_function返回输入的特征矩阵中每个样本到划分数据集的超平面的距离。在SVM中利用超平面来判断样本,本质上来说,当两个点的距离是相同的符号的时候,越远离超平面的样本点归属于某个标签类的概率就很大。接口decision_function返回的值也因此被我们认为是SVM中的置信度(confidence)。

不过,置信度始终不是概率,它没有边界,可以无限大,大部分时候也不是以百分比或者小数的形式呈现,而SVC的判断过程又不像决策树一样可以求解出一个比例。

为了解决这个矛盾,SVC有重要参数probability。设置为True则会启动,启用之后,SVC的接口predict_proba和predict_log_proba将生效。在二分类情况下,SVC将使用Platt缩放来生成概率,即在decision_function生成的距离上进行Sigmoid压缩,并附加训练数据的交叉验证拟合,来生成类逻辑回归的SVM分数。

Platt缩放中涉及的交叉验证对于大型数据集来说非常昂贵,计算会非常缓慢。另外,由于Platt缩放的理论原因,在二分类过程中,有可能出现predict_proba返回的概率小于0.5,但样本依旧被标记为正类的情况出现,毕竟支持向量机本身并不依赖于概率来完成自己的分类。如果我们的确需要置信度分数,但不一定非要是概率形式的话,那建议可以将probability设置为False,使用decision_function这个接口而不是predict_proba。

至此,sklearn中的重要参数已基本介绍完毕,学习完本文已基本达到会使用支持向量机建立模型的目的。若您有更深入的学习需求,可以查看源码或查看深度学习相关的文章。

代码附录

代码语言:javascript复制
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from sklearn import svm
from sklearn.datasets import make_circles, make_moons, make_blobs,make_classification

n_samples = 100

datasets = [
    make_moons(n_samples=n_samples, noise=0.2, random_state=0),
    make_circles(n_samples=n_samples, noise=0.2, factor=0.5, random_state=1),
    make_blobs(n_samples=n_samples, centers=2, random_state=5),
    make_classification(n_samples=n_samples,n_features = 2,n_informative=2,n_redundant=0, random_state=5)
]

Kernel = ["linear","poly","sigmoid","rbf"]

#四个数据集分别是什么样子呢?
# for X,Y in datasets:
#     plt.figure(figsize=(5,4))
#     plt.scatter(X[:,0],X[:,1],c=Y,s=50,cmap="rainbow")

nrows=len(datasets)
ncols=len(Kernel)   1

fig, axes = plt.subplots(nrows, ncols,figsize=(25,16))

#第一层循环:在不同的数据集中循环
for ds_cnt, (X,Y) in enumerate(datasets):
    
    #在图像中的第一列,放置原数据的分布
    ax = axes[ds_cnt, 0]
    if ds_cnt == 0:
        ax.set_title("Input data",fontsize=25)
    ax.scatter(X[:, 0], X[:, 1], c=Y, zorder=10, cmap=plt.cm.Paired,edgecolors='k')
    ax.set_xticks(())
    ax.set_yticks(())
    
    #第二层循环:在不同的核函数中循环
    #从图像的第二列开始,一个个填充分类结果
    for est_idx, kernel in enumerate(Kernel):
        
        #定义子图位置
        ax = axes[ds_cnt, est_idx   1]
        
        #建模
        clf = svm.SVC(kernel=kernel, gamma=2).fit(X, Y)
        score = clf.score(X, Y)
        
        #绘制图像本身分布的散点图
        ax.scatter(X[:, 0], X[:, 1], c=Y
                   ,zorder=10
                   ,cmap=plt.cm.Paired,edgecolors='k')
        #绘制支持向量
        ax.scatter(clf.support_vectors_[:, 0], clf.support_vectors_[:, 1], s=100,
                    facecolors='none', zorder=10, edgecolors='white')
        
        #绘制决策边界
        x_min, x_max = X[:, 0].min() - .5, X[:, 0].max()   .5
        y_min, y_max = X[:, 1].min() - .5, X[:, 1].max()   .5
        
        #np.mgrid,合并了我们之前使用的np.linspace和np.meshgrid的用法
        #一次性使用最大值和最小值来生成网格
        #表示为[起始值:结束值:步长]
        #如果步长是复数,则其整数部分就是起始值和结束值之间创建的点的数量,并且结束值被包含在内
        XX, YY = np.mgrid[x_min:x_max:200j, y_min:y_max:200j]
        #np.c_,类似于np.vstack的功能
        Z = clf.decision_function(np.c_[XX.ravel(), YY.ravel()]).reshape(XX.shape)
        #填充等高线不同区域的颜色
        ax.pcolormesh(XX, YY, Z > 0, cmap=plt.cm.Paired,shading='auto')
        #绘制等高线
        ax.contour(XX, YY, Z, colors=['k', 'k', 'k'], linestyles=['--', '-', '--'],
                    levels=[-1, 0, 1])
        
        #设定坐标轴为不显示
        ax.set_xticks(())
        ax.set_yticks(())
        
        #将标题放在第一行的顶上
        if ds_cnt == 0:
            ax.set_title(kernel,fontsize=25)
            
        #为每张图添加分类的分数   
        ax.text(0.95, 0.06, ('%.2f' % score).lstrip('0')
                , size=15
                , bbox=dict(boxstyle='round', alpha=0.8, facecolor='white')
                  #为分数添加一个白色的格子作为底色
                , transform=ax.transAxes #确定文字所对应的坐标轴,就是ax子图的坐标轴本身
                , horizontalalignment='right' #位于坐标轴的什么方向
               )

plt.tight_layout()
plt.show()

代码语言:javascript复制
import numpy as np
import matplotlib.pyplot as plt
from sklearn import svm
from sklearn.datasets import make_blobs
plt.rcParams['axes.unicode_minus']=False

class_1 = 500 #类别1有500个样本
class_2 = 50 #类别2只有50个
centers = [[0.0, 0.0], [2.0, 2.0]] #设定两个类别的中心
clusters_std = [1.5, 0.5] #设定两个类别的方差,通常来说,样本量比较大的类别会更加松散
X, y = make_blobs(n_samples=[class_1, class_2],
                  centers=centers,
                  cluster_std=clusters_std,
                  random_state=0, shuffle=False)

#其中红色点是少数类,紫色点是多数类
#不设定class_weight
clf = svm.SVC(kernel='linear', C=1.0)
clf.fit(X, y)
#设定class_weight
wclf = svm.SVC(kernel='linear', class_weight={1: 10})
wclf.fit(X, y)
#给两个模型分别打分看看,这个分数是accuracy准确度



#首先要有数据分布
plt.figure(figsize=(10,6))
plt.scatter(X[:, 0], X[:, 1], c=y, cmap="rainbow",s=10)
ax = plt.gca() #获取当前的子图,如果不存在,则创建新的子图
#绘制决策边界的第一步:要有网格

xlim = ax.get_xlim()
ylim = ax.get_ylim()
xx = np.linspace(xlim[0], xlim[1], 30)
yy = np.linspace(ylim[0], ylim[1], 30)
YY, XX = np.meshgrid(yy, xx)
xy = np.vstack([XX.ravel(), YY.ravel()]).T
#第二步:找出我们的样本点到决策边界的距离
Z_clf = clf.decision_function(xy).reshape(XX.shape)
a = ax.contour(XX, YY, Z_clf, colors='black', levels=[0], alpha=0.5, linestyles=['-'])
Z_wclf = wclf.decision_function(xy).reshape(XX.shape)
b = ax.contour(XX, YY, Z_wclf, colors='orange', levels=[0], alpha=0.5, linestyles=['-'])
#第三步:画图例
plt.legend([a.collections[0], b.collections[0]], ["non weighted", "weighted"],loc="upper right",fontsize=14) 
plt.text(0.95, 0.06, ('non weighted:%.2f n weighted:%.2f ' % (clf.score(X,y), wclf.score(X,y)))
        , size=15
        , bbox=dict(boxstyle='round', alpha=0.8, facecolor='white')
          #为分数添加一个白色的格子作为底色
        , transform=ax.transAxes #确定文字所对应的坐标轴,就是ax子图的坐标轴本身
        , horizontalalignment='right' #位于坐标轴的什么方向
       )

plt.show()

0 人点赞