scala-MLlib官方文档---spark.mllib package--Evaluation metrics+Optimization

九、Evaluation metrics

spark.mllib附带了许多机器学习算法,可用于学习数据并进行数据预测。当将这些算法应用于构建机器学习模型时,需要根据某些标准评估模型的性能,具体取决于应用程序及其要求。 spark.mllib还提供了一组度量标准,用于评估机器学习模型的性能。
特定的机器学习算法属于更广泛的机器学习应用程序类型,例如分类,回归,聚类等。这些类型中的每一种都有完善的性能评估指标,而此部分将详细介绍spark.mllib中当前可用的那些指标。

Classification model evaluation

尽管分类算法的类型很多,但分类模型的评估都具有相似的原理。在监督分类问题中,每个数据点都存在真实输出和模型生成的预测输出。因此,可以将每个数据点的结果分配给以下四个类别之一:

  • 真阳性(TP)-标签为阳性,预测也为阳性
  • 真负(TN)-标签为负,预测也为负
  • 误报(FP)-标签为负,但预测为正
  • 假阴性(FN)-标签为正,但预测为负

这四个数字是大多数分类器评估指标的基础。考虑分类器评估时的基本要点是,单纯的准确性(即预测正确与否)通常不是一个好的指标。其原因是因为数据集可能高度不平衡。例如,如果模型被设计为从数据集中预测欺诈的模型,其中95%的数据点不是欺诈,而5%的数据点是欺诈,则无论输入如何,预测都不欺诈的朴素分类器将为95 %准确。因此,通常会使用诸如精度和召回率之类的指标,因为它们考虑到了错误的类型。在大多数应用中,精度和查全率之间存在一些理想的平衡,可以通过将两者合并为一个度量标准(称为F度量)来捕获平衡。
1)二进制分类
二进制分类器用于将给定数据集的元素分为两个可能的组(例如欺诈或非欺诈)之一,这是多类分类的一种特殊情况。大多数二元分类指标可以概括为多类分类指标。
(1)阈值调整
重要的是要理解,许多分类模型实际上为每个类别输出“分数”(通常是概率的乘积),其中较高的分数表示较高的可能性。在二元情况下,模型可以输出每个类别的概率:P(Y = 1 | X)和P(Y = 0 | X)。在某些情况下,可能需要调整模型,以便仅在概率很高时预测类别(例如,如果模型预测欺诈> 90,则仅阻止信用卡交易),而不是简单地采用较高的概率百分比概率)。因此,存在一个预测阈值,该阈值可以根据模型输出的概率来确定预测类别。
调整预测阈值将改变模型的精度和召回率,这是模型优化的重要组成部分。为了可视化精度,召回率和其他指标如何随阈值变化,通常的做法是将竞争指标相互绘制,并按阈值进行参数设置。 P-R曲线绘制(精确度,召回率)点对应不同的阈值,而接收器工作特性曲线或ROC曲线绘制(准确率,误报率)点。
示例代码
以下代码段说明了如何加载样本数据集,在数据上训练二进制分类算法以及如何通过几种二进制评估指标评估算法的性能。
有关API的详细信息,请参考LogisticRegressionWithLBFGS Scala文档和BinaryClassificationMetrics Scala文档。

import org.apache.spark.mllib.classification.LogisticRegressionWithLBFGS
import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics
import org.apache.spark.mllib.regression.LabeledPoint
import org.apache.spark.mllib.util.MLUtils

// Load training data in LIBSVM format
val data = MLUtils.loadLibSVMFile(sc, "data/mllib/sample_binary_classification_data.txt")

// Split data into training (60%) and test (40%)
val Array(training, test) = data.randomSplit(Array(0.6, 0.4), seed = 11L)
training.cache()

// Run training algorithm to build the model
val model = new LogisticRegressionWithLBFGS()
  .setNumClasses(2)
  .run(training)

// Clear the prediction threshold so the model will return probabilities
model.clearThreshold

// Compute raw scores on the test set
val predictionAndLabels = test.map { case LabeledPoint(label, features) =>
  val prediction = model.predict(features)
  (prediction, label)
}

// Instantiate metrics object
val metrics = new BinaryClassificationMetrics(predictionAndLabels)

// Precision by threshold
val precision = metrics.precisionByThreshold
precision.foreach { case (t, p) =>
  println(s"Threshold: $t, Precision: $p")
}

// Recall by threshold
val recall = metrics.recallByThreshold
recall.foreach { case (t, r) =>
  println(s"Threshold: $t, Recall: $r")
}

// Precision-Recall Curve
val PRC = metrics.pr

// F-measure
val f1Score = metrics.fMeasureByThreshold
f1Score.foreach { case (t, f) =>
  println(s"Threshold: $t, F-score: $f, Beta = 1")
}

val beta = 0.5
val fScore = metrics.fMeasureByThreshold(beta)
f1Score.foreach { case (t, f) =>
  println(s"Threshold: $t, F-score: $f, Beta = 0.5")
}

// AUPRC
val auPRC = metrics.areaUnderPR
println(s"Area under precision-recall curve = $auPRC")

// Compute thresholds used in ROC and PR curves
val thresholds = precision.map(_._1)

// ROC Curve
val roc = metrics.roc

// AUROC
val auROC = metrics.areaUnderROC
println(s"Area under ROC = $auROC")

2)多类别分类
多类分类描述了一个分类问题,其中每个数据点有M> 2个可能的标签(其中M = 2是二进制分类问题)。例如,将笔迹样本分类为具有10种可能类别的数字0到9。
对于多类别指标,肯定和否定的概念略有不同。预测和标签仍然可以是肯定的或否定的,但必须在特定类别的上下文中加以考虑。每个标签和预测采用多个类别之一的值,因此对于它们的特定类别来说,它们被认为是正的,而对于所有其他类别来说,它们都是负的。因此,每当预测和标签匹配时,就会出现真阳性,而当预测和标签都不采用给定类的值时,就会出现真阴性。按照这种约定,给定的数据样本可能有多个真实的负数。从肯定标签和否定标签的先前定义中扩展假阴性和假阳性很简单。
(1)基于标签的指标
与只有两个可能的标签的二进制分类相反,多类分类问题有很多可能的标签,因此引入了基于标签的度量的概念。准确性衡量所有标签的准确性-通过数据点的数量对任何类别进行正确预测(正确肯定)的次数。按标签的精度仅考虑一类,并根据标签出现在输出中的次数来衡量正确预测特定标签的时间。
在这里插入图片描述
示例代码
以下代码段说明了如何加载样本数据集,如何在数据上训练多类分类算法以及如何通过几种多类分类评估指标来评估算法的性能。
有关API的详细信息,请参阅MulticlassMetrics Scala文档。

import org.apache.spark.mllib.classification.LogisticRegressionWithLBFGS
import org.apache.spark.mllib.evaluation.MulticlassMetrics
import org.apache.spark.mllib.regression.LabeledPoint
import org.apache.spark.mllib.util.MLUtils

// Load training data in LIBSVM format
val data = MLUtils.loadLibSVMFile(sc, "data/mllib/sample_multiclass_classification_data.txt")

// Split data into training (60%) and test (40%)
val Array(training, test) = data.randomSplit(Array(0.6, 0.4), seed = 11L)
training.cache()

// Run training algorithm to build the model
val model = new LogisticRegressionWithLBFGS()
  .setNumClasses(3)
  .run(training)

// Compute raw scores on the test set
val predictionAndLabels = test.map { case LabeledPoint(label, features) =>
  val prediction = model.predict(features)
  (prediction, label)
}

// Instantiate metrics object
val metrics = new MulticlassMetrics(predictionAndLabels)

// Confusion matrix
println("Confusion matrix:")
println(metrics.confusionMatrix)

// Overall Statistics
val accuracy = metrics.accuracy
println("Summary Statistics")
println(s"Accuracy = $accuracy")

// Precision by label
val labels = metrics.labels
labels.foreach { l =>
  println(s"Precision($l) = " + metrics.precision(l))
}

// Recall by label
labels.foreach { l =>
  println(s"Recall($l) = " + metrics.recall(l))
}

// False positive rate by label
labels.foreach { l =>
  println(s"FPR($l) = " + metrics.falsePositiveRate(l))
}

// F-measure by label
labels.foreach { l =>
  println(s"F1-Score($l) = " + metrics.fMeasure(l))
}

// Weighted stats
println(s"Weighted precision: ${metrics.weightedPrecision}")
println(s"Weighted recall: ${metrics.weightedRecall}")
println(s"Weighted F1 score: ${metrics.weightedFMeasure}")
println(s"Weighted false positive rate: ${metrics.weightedFalsePositiveRate}")

3)多标签分类
多标签分类问题涉及将数据集中的每个样本映射到一组类标签。在这种类型的分类问题中,标签不是相互排斥的。例如,当将一组新闻文章分类为主题时,一篇文章可能既是科学又是政治。
因为标签不是互斥的,所以预测和真实标签现在是标签集的向量,而不是标签的向量。因此,多标签度量将精度,召回率等基本概念扩展到集合操作上。例如,当给定类别的某个特定数据点存在于预测集中且该类别存在于真实标签集中时,则该类别为真阳性。
(1)可用指标
在这里插入图片描述
在这里插入图片描述
示例
以下代码段说明了如何评估多标签分类器的性能。这些示例将伪造的预测和标签数据用于多标签分类,如下所示。
在这里插入图片描述
示例代码

import org.apache.spark.mllib.evaluation.MultilabelMetrics
import org.apache.spark.rdd.RDD

val scoreAndLabels: RDD[(Array[Double], Array[Double])] = sc.parallelize(
  Seq((Array(0.0, 1.0), Array(0.0, 2.0)),
    (Array(0.0, 2.0), Array(0.0, 1.0)),
    (Array.empty[Double], Array(0.0)),
    (Array(2.0), Array(2.0)),
    (Array(2.0, 0.0), Array(2.0, 0.0)),
    (Array(0.0, 1.0, 2.0), Array(0.0, 1.0)),
    (Array(1.0), Array(1.0, 2.0))), 2)

// Instantiate metrics object
val metrics = new MultilabelMetrics(scoreAndLabels)

// Summary stats
println(s"Recall = ${metrics.recall}")
println(s"Precision = ${metrics.precision}")
println(s"F1 measure = ${metrics.f1Measure}")
println(s"Accuracy = ${metrics.accuracy}")

// Individual label stats
metrics.labels.foreach(label =>
  println(s"Class $label precision = ${metrics.precision(label)}"))
metrics.labels.foreach(label => println(s"Class $label recall = ${metrics.recall(label)}"))
metrics.labels.foreach(label => println(s"Class $label F1-score = ${metrics.f1Measure(label)}"))

// Micro stats
println(s"Micro recall = ${metrics.microRecall}")
println(s"Micro precision = ${metrics.microPrecision}")
println(s"Micro F1 measure = ${metrics.microF1Measure}")

// Hamming loss
println(s"Hamming loss = ${metrics.hammingLoss}")

// Subset accuracy
println(s"Subset accuracy = ${metrics.subsetAccuracy}")

4)Ranking systems
排名算法(通常被认为是推荐系统)的作用是根据一些训练数据向用户返回一组相关的项目或文档。相关性的定义可能会有所不同,并且通常是特定于应用程序的。排名系统指标旨在量化这些排名或建议在各种情况下的有效性。一些度量将一组推荐文档与一组相关文档的基础事实进行比较,而其他度量可能会明确包含数字等级。
在这里插入图片描述在这里插入图片描述
示例
以下代码段说明了如何加载样本数据集,如何在数据上训练交替的最小二乘推荐模型以及如何通过几个排名指标评估推荐器的性能。下面提供了该方法的简要摘要。
MovieLens评分等级为1-5:
5: Must see
4: Will enjoy
3: It’s okay
2: Fairly bad
1: Awful
因此,如果预测收视率低于3,我们不建议您看电影。为了将收视率映射到置信度分数,我们使用:
5 -> 2.5
4 -> 1.5
3 -> 0.5
2 -> -0.5
1 -> -1.5.
此映射表示未观察到的条目通常介于“正常”和“相当差”之间。在这个非正数权重的扩展世界中,0的语义“与从未交互过的相同”。
示例代码
有关API的详细信息,请参考RegressionMetrics Scala文档和RankMetrics Scala文档。

import org.apache.spark.mllib.evaluation.{RankingMetrics, RegressionMetrics}
import org.apache.spark.mllib.recommendation.{ALS, Rating}

// Read in the ratings data
val ratings = spark.read.textFile("data/mllib/sample_movielens_data.txt").rdd.map { line =>
  val fields = line.split("::")
  Rating(fields(0).toInt, fields(1).toInt, fields(2).toDouble - 2.5)
}.cache()

// Map ratings to 1 or 0, 1 indicating a movie that should be recommended
val binarizedRatings = ratings.map(r => Rating(r.user, r.product,
  if (r.rating > 0) 1.0 else 0.0)).cache()

// Summarize ratings
val numRatings = ratings.count()
val numUsers = ratings.map(_.user).distinct().count()
val numMovies = ratings.map(_.product).distinct().count()
println(s"Got $numRatings ratings from $numUsers users on $numMovies movies.")

// Build the model
val numIterations = 10
val rank = 10
val lambda = 0.01
val model = ALS.train(ratings, rank, numIterations, lambda)

// Define a function to scale ratings from 0 to 1
def scaledRating(r: Rating): Rating = {
  val scaledRating = math.max(math.min(r.rating, 1.0), 0.0)
  Rating(r.user, r.product, scaledRating)
}

// Get sorted top ten predictions for each user and then scale from [0, 1]
val userRecommended = model.recommendProductsForUsers(10).map { case (user, recs) =>
  (user, recs.map(scaledRating))
}

// Assume that any movie a user rated 3 or higher (which maps to a 1) is a relevant document
// Compare with top ten most relevant documents
val userMovies = binarizedRatings.groupBy(_.user)
val relevantDocuments = userMovies.join(userRecommended).map { case (user, (actual,
predictions)) =>
  (predictions.map(_.product), actual.filter(_.rating > 0.0).map(_.product).toArray)
}

// Instantiate metrics object
val metrics = new RankingMetrics(relevantDocuments)

// Precision at K
Array(1, 3, 5).foreach { k =>
  println(s"Precision at $k = ${metrics.precisionAt(k)}")
}

// Mean average precision
println(s"Mean average precision = ${metrics.meanAveragePrecision}")

// Normalized discounted cumulative gain
Array(1, 3, 5).foreach { k =>
  println(s"NDCG at $k = ${metrics.ndcgAt(k)}")
}

// Get predictions for each data point
val allPredictions = model.predict(ratings.map(r => (r.user, r.product))).map(r => ((r.user,
  r.product), r.rating))
val allRatings = ratings.map(r => ((r.user, r.product), r.rating))
val predictionsAndLabels = allPredictions.join(allRatings).map { case ((user, product),
(predicted, actual)) =>
  (predicted, actual)
}

// Get the RMSE using regression metrics
val regressionMetrics = new RegressionMetrics(predictionsAndLabels)
println(s"RMSE = ${regressionMetrics.rootMeanSquaredError}")

// R-squared
println(s"R-squared = ${regressionMetrics.r2}")

回归模型评估

从多个自变量预测连续输出变量时,将使用回归分析。
在这里插入图片描述
示例代码
以下代码段说明了如何加载样本数据集,在数据上训练线性回归算法以及如何通过多个回归指标评估算法的性能。
有关API的详细信息,请参考RegressionMetrics Scala文档。

import org.apache.spark.mllib.evaluation.RegressionMetrics
import org.apache.spark.mllib.linalg.Vector
import org.apache.spark.mllib.regression.{LabeledPoint, LinearRegressionWithSGD}

// Load the data
val data = spark
  .read.format("libsvm").load("data/mllib/sample_linear_regression_data.txt")
  .rdd.map(row => LabeledPoint(row.getDouble(0), row.get(1).asInstanceOf[Vector]))
  .cache()

// Build the model
val numIterations = 100
val model = LinearRegressionWithSGD.train(data, numIterations)

// Get predictions
val valuesAndPreds = data.map{ point =>
  val prediction = model.predict(point.features)
  (prediction, point.label)
}

// Instantiate metrics object
val metrics = new RegressionMetrics(valuesAndPreds)

// Squared error
println(s"MSE = ${metrics.meanSquaredError}")
println(s"RMSE = ${metrics.rootMeanSquaredError}")

// R-squared
println(s"R-squared = ${metrics.r2}")

// Mean absolute error
println(s"MAE = ${metrics.meanAbsoluteError}")

// Explained variance
println(s"Explained variance = ${metrics.explainedVariance}")

十一、Optimization

Mathematical description

1)Gradient descent
解决minw∈ℝdf(w)形式的优化问题的最简单方法是梯度下降。这样的一阶优化方法(包括梯度下降及其随机变型)非常适合于大规模和分布式计算。
梯度下降法的目的是通过在最陡下降方向上迭代采取步骤来找到函数的局部最小值,该梯度是当前点(即当前参数值)上函数的导数(称为梯度)的负数。如果目标函数f在所有参数上都不是可微的,但仍是凸的,则次梯度是梯度的自然概括,并承担阶跃方向的作用。在任何情况下,计算f的梯度或子梯度都是昂贵的-它需要完整遍历整个数据集,以便计算所有损失项的贡献。

2)Stochastic gradient descent (SGD–随机梯度下降)
在这里插入图片描述
Gradients. A table of (sub)gradients of the machine learning methods implemented in spark.mllib, is available in the classification and regression section.
Proximal Updates. As an alternative to just use the subgradient R′(w) of the regularizer in the step direction, an improved update for some cases can be obtained by using the proximal operator instead. For the L1-regularizer, the proximal operator is given by soft thresholding, as implemented in L1Updater.
3)分布式SGD的更新方案
在这里插入图片描述
在每次迭代中,通过标准火花例程执行对分布式数据集(RDD)的采样,以及每个工作机的部分结果之和的计算。
如果将点miniBatchFraction的分数设置为1(默认值),则每次迭代中得到的步骤都是精确的(子)梯度下降。在这种情况下,所使用的步长方向没有随机性,也没有方差。另一方面,如果选择的miniBatchFraction非常小,则仅采样单个点,即| S | = miniBatchFraction⋅n= 1,则该算法等效于标准SGD。在这种情况下,步进方向取决于该点的均匀随机采样。
4)Limited-memory BFGS (L-BFGS–内存有限的BFGS)
L-BFGS是拟牛顿方法族中的一种优化算法,用于解决minw∈ℝdf(w)形式的优化问题。 L-BFGS方法在不评估目标函数的二阶偏导数以构造Hessian矩阵的情况下,将目标函数局部近似为二次方。粗略的Hessian矩阵可以通过以前的梯度评估得出,因此在用Newton方法显式计算Hessian矩阵时,没有垂直可伸缩性问题(训练特征的数量)。结果,与其他一阶优化相比,L-BFGS通常可以实现更快的收敛。
5)Choosing an Optimization Method(选择一种优化方法)
线性方法在内部使用优化,spark.mllib中的某些线性方法同时支持SGD和L-BFGS。根据目标函数的性质,不同的优化方法可以具有不同的收敛性保证,因此我们在此不涉及任何文献。通常,当L-BFGS可用时,我们建议使用它而不是SGD,因为L-BFGS趋向于收敛更快(迭代次数更少)。

Implementation in MLlib(MLlib中的实现)

1)Gradient descent and stochastic gradient descent(梯度下降和随机梯度下降)
梯度下降方法(包括随机子梯度下降(SGD))作为MLlib中的低级基元包括在内,在此基础上开发了各种ML算法,请参见“线性方法”部分。
SGD类GradientDescent设置以下参数:

  • Gradient是一个类,它以当前参数值计算要优化的函数的随机梯度,即相对于单个训练示例。 MLlib包括常见损失函数的梯度类别,例如铰链,逻辑,最小二乘。梯度类将训练示例,其标签和当前参数值作为输入。
  • Updater是执行实际梯度下降步骤的类,即针对损耗部分的给定梯度,在每次迭代中更新权重。更新程序还负责从正则化部分执行更新。 MLlib包括针对没有正则化的情况的更新程序,以及L1和L2正则化器。
  • stepSize是表示梯度下降的初始步长的标量值。 MLlib中的所有更新程序在第t步都使用等于stepSize /t√的步长。
  • numIterations是要运行的迭代次数。
  • regParam是使用L1或L2正则化时的正则化参数。
  • miniBatchFraction是每次迭代中采样的总数据的一部分,用于计算梯度方向。
    • 采样仍然需要遍历整个RDD,因此减小miniBatchFraction可能不会大大加快优化速度。当梯度的计算成本很高时,用户将看到最大的加速,因为只有选定的样本才用于计算梯度。

2)L-BFGS
L-BFGS目前只是MLlib中的低级优化原语。如果要在各种ML算法(例如线性回归和Logistic回归)中使用L-BFGS,则必须传递目标函数的梯度,然后自己将updater传递到优化器中,而不是使用LogisticRegressionWithSGD等训练API。请参见下面的示例。它将在下一版本中解决。
由于L1Updater中的软阈值逻辑设计用于梯度下降,因此无法使用L1Updater进行L1正则化。请参阅开发者说明。
L-BFGS方法LBFGS.runLBFGS具有以下参数:

  • Gradient是一类,用于在当前参数值下计算要优化的目标函数(即相对于单个训练示例)的梯度。 MLlib包括常见损失函数的梯度类别,例如铰链,逻辑,最小二乘。梯度类将训练示例,其标签和当前参数值作为输入。
  • Updater是用于为L-BFGS计算正则化部分的目标函数的梯度和损失的类。 MLlib包括针对没有正则化的情况的更新程序,以及L2正则化器。
  • numCorrections 是L-BFGS更新中使用的更正数。建议10。
  • maxNumIterations是L-BFGS可以运行的最大迭代次数。
  • regParam是使用正则化时的正则化参数。
  • convergenceTol控制当L-BFGS被认为收敛时仍然允许多少相对变化。这必须是非负的。较低的值容忍度较低,因此通常会导致运行更多的迭代。此值同时考虑了Breeze LBFGS内部的平均改进和梯度范数。

返回是一个包含两个元素的元组。第一个元素是一个包含每个要素权重的列矩阵,第二个元素是一个包含针对每次迭代计算的损耗的数组。
这是一个使用L-BFGS优化器通过L2正则训练二进制logistic回归的示例。

import org.apache.spark.mllib.classification.LogisticRegressionModel
import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics
import org.apache.spark.mllib.linalg.Vectors
import org.apache.spark.mllib.optimization.{LBFGS, LogisticGradient, SquaredL2Updater}
import org.apache.spark.mllib.util.MLUtils

val data = MLUtils.loadLibSVMFile(sc, "data/mllib/sample_libsvm_data.txt")
val numFeatures = data.take(1)(0).features.size

// Split data into training (60%) and test (40%).
val splits = data.randomSplit(Array(0.6, 0.4), seed = 11L)

// Append 1 into the training data as intercept.
val training = splits(0).map(x => (x.label, MLUtils.appendBias(x.features))).cache()

val test = splits(1)

// Run training algorithm to build the model
val numCorrections = 10
val convergenceTol = 1e-4
val maxNumIterations = 20
val regParam = 0.1
val initialWeightsWithIntercept = Vectors.dense(new Array[Double](numFeatures + 1))

val (weightsWithIntercept, loss) = LBFGS.runLBFGS(
  training,
  new LogisticGradient(),
  new SquaredL2Updater(),
  numCorrections,
  convergenceTol,
  maxNumIterations,
  regParam,
  initialWeightsWithIntercept)

val model = new LogisticRegressionModel(
  Vectors.dense(weightsWithIntercept.toArray.slice(0, weightsWithIntercept.size - 1)),
  weightsWithIntercept(weightsWithIntercept.size - 1))

// Clear the default threshold.
model.clearThreshold()

// Compute raw scores on the test set.
val scoreAndLabels = test.map { point =>
  val score = model.predict(point.features)
  (score, point.label)
}

// Get evaluation metrics.
val metrics = new BinaryClassificationMetrics(scoreAndLabels)
val auROC = metrics.areaUnderROC()

println("Loss of each step in training process")
loss.foreach(println)
println(s"Area under ROC = $auROC")

开发者说明

由于Hessian大约是根据以前的梯度评估构建的,因此在优化过程中无法更改目标函数。结果,仅使用miniBatch不能随机地运行L-BFGS。因此,除非我们有更好的了解,否则我们不会提供此服务。
Updater是最初为梯度体面设计的类,它计算实际的梯度下降步长。但是,我们可以忽略L-BFGS正则化的目标函数的梯度和损失,只需忽略诸如梯度步长这样的梯度梯度逻辑的逻辑部分即可。我们将其重构为正则化器以替换更新器,以将正则化和后续步骤更新之间的逻辑分开。

发布了2 篇原创文章 · 获赞 0 · 访问量 612

猜你喜欢

转载自blog.csdn.net/pt798633929/article/details/103850224