TensorFlow 实战(七)(4)https://developer.aliyun.com/article/1522945
15.4 部署模型并通过 API 进行服务
现在,你已经有了一个数据管道、训练好的模型以及一个可以运行包含了运行模型和访问模型的 API 所需的一切的脚本。现在,使用 TFX 提供的一些服务,你将在 Docker 容器中部署模型,并通过 API 进行访问。在这个过程中,你将运行一些步骤来验证基础结构(例如,容器是否可运行且健康)和模型(即在模型的新版本发布时,检查它是否比上一个版本更好),最后,如果一切都好,将模型部署到基础结构上。
这是一个漫长的旅程。让我们回顾一下我们到目前为止取得的成就。我们已经使用了以下 TFX 组件:
- CsvExampleGen—从 CSV 文件中以 TFRecord 对象的形式加载数据。
- StatisticsGen—关于 CSV 数据中各列分布的基本统计数据和可视化。
- SchemaGen—生成数据的模式/模板(例如数据类型、域、允许的最小/最大值等)。
- Transform—使用 tensorflow_transform 库中提供的操作(例如,独热编码、桶化)将原始列转换为特征。
- Trainer—定义一个 Keras 模型,使用转换后的数据进行训练,并保存到磁盘。此模型具有一个名为 serving default 的签名,指示模型对于传入的请求应该执行什么操作。
- ExampleValidator—用于验证训练和评估示例是否符合定义的模式,并可用于检测异常。
15.4.1 验证基础结构
使用 TFX,当你拥有一个完全自动化的管道时,几乎可以确保一切工作正常。我们将在这里讨论一个这样的步骤:基础结构验证步骤。在这个步骤中,tfx.components.InfraValidator 将自动进行
- 使用提供的特定版本的 TensorFlow serving 镜像创建一个容器
- 加载并在其中运行模型
- 发送多个请求以确保模型能够响应
- 关闭容器
让我们看一下如何使用这个组件来验证我们在前一节中设置的本地 Docker 配置(请参阅下一个清单)。
Listing 15.11 定义 InfraValidator
from tfx.components import InfraValidator from tfx.proto import infra_validator_pb2 infra_validator = InfraValidator( model=trainer.outputs['model'], ❶ examples=example_gen.outputs['examples'], ❷ serving_spec=infra_validator_pb2.ServingSpec( ❸ tensorflow_serving=infra_validator_pb2.TensorFlowServing( ❹ tags=['2.6.3-gpu'] ), local_docker=infra_validator_pb2.LocalDockerConfig(), ❺ ), request_spec=infra_validator_pb2.RequestSpec( ❻ tensorflow_serving=infra_validator_pb2.TensorFlowServingRequestSpec(❼ signature_names=['serving_default'] ), num_examples=5 ❽ ) ) context.run(infra_validator)
❶ InfraValidator 需要验证的模型的位置。
❷ 用于构建对模型的 API 调用的数据来源
❸ 包含一组与对模型进行的具体调用相关的规范
❹ 定义要使用的 TensorFlow serving Docker Image 的版本/标签
❺ 告诉 InfraValidator 我们将使用本地的 Docker 服务进行测试
❻ 包含与对模型进行的特定调用相关的规范集合
❼ 定义要使用的模型签名
❽ 定义了向模型发送的请求数量
InfraValidator 和其他任何 TFX 组件一样,需要准确地提供多个参数才能运行。
- model—由 Trainer 组件返回的 Keras 模型。
- examples—由 CSVExampleGen 给出的原始示例。
- serving_spec—期望一个 ServingSpec protobuf 消息。它将指定 TensorFlow serving Docker 镜像的版本以及是否使用本地 Docker 安装(这里已完成)。
- request_spec—一个 RequestSpec protobuf 消息,它将指定需要达到的签名以验证模型是否正常工作。
如果此步骤无误完成,您将在管道根目录中看到图 15.9 中显示的文件。
图 15.9 运行 InfraValidator 后的目录/文件结构
您可以看到一个名为 INFRA_BLESSED 的文件出现在 InfraValidator 子目录中。这引出了“祝福”的概念。TFX 将在成功运行流水线中祝福某些元素。一旦被祝福,它将创建一个带有后缀 BLESSED 的文件。如果该步骤失败,那么将创建一个带有后缀 NOT_BLESSED 的文件。祝福有助于区分运行正常和运行失败的事物。例如,一旦被祝福,我们可以确信基础设施按预期工作。这意味着像
- 架设一个容器
- 加载模型
- 达到定义的 API 端点
可以无问题地执行。
15.4.2 解析正确的模型
接下来,我们将定义一个解析器。解析器的目的是使用明确定义的策略(例如,使用最低验证错误的模型)解决随时间演变的特殊工件(如模型)。然后,解析器通知后续组件(例如,我们接下来将定义的模型评估器组件)要使用哪个工件版本。正如您可能已经猜到的那样,我们将使用解析器来解析管道中的经过训练的 Keras 模型。因此,如果您多次运行管道,则解析器将确保在下游组件中使用最新和最优秀的模型:
from tfx import v1 as tfx model_resolver = tfx.dsl.Resolver( strategy_class=tfx.dsl.experimental.LatestBlessedModelStrategy, model=tfx.dsl.Channel(type=tfx.types.standard_artifacts.Model), model_blessing=tfx.dsl.Channel( type=tfx.types.standard_artifacts.ModelBlessing ) ).with_id('latest_blessed_model_resolver') context.run(model_resolver)
在定义验证模型的解析器时,我们将定义三件事:
- strategy_class(来自 tfx.dsl.components.common.resolver.ResolverStrategy 命名空间的类)—定义解析策略。当前支持两种策略:最新的祝福模型(即通过一组定义的评估检查的模型)和最新的模型。
- 模型(tfx.dsl.Channel)—将 TFX 工件类型的模型包装在一个 tfx.dsl.Channel 对象中。tfx.dsl.Channel 是一个 TFX 特定的抽象概念,连接数据消费者和数据生产者。例如,在管道中选择正确的模型时就需要一个通道,以从可用模型池中选择。
- model_blessing(tfx.dsl.Channel)—将类型为 ModelBlessing 的 TFX 工件包装在 tfx.dsl.Channel 对象中。
你可以查看各种工件,将其包装在一个 tf.dsl.Channel 对象中,网址为mng.bz/2nQX
。
15.4.3 评估模型
我们将在将模型推送到指定的生产环境之前的最后一步对模型进行评估。基本上,我们将定义几个模型需要通过的评估检查。当模型通过时,TFX 将对模型进行认可。否则,TFX 将使模型保持未认可状态。我们将在后面学习如何检查模型是否被认可。为了定义评估检查,我们将使用 tensorflow_model_analysis 库。第一步是定义一个评估配置,其中指定了检查项:
import tensorflow_model_analysis as tfma eval_config = tfma.EvalConfig( model_specs=[ tfma.ModelSpec(label_key='area') ❶ ], metrics_specs=[ tfma.MetricsSpec( metrics=[ ❷ tfma.MetricConfig(class_name='ExampleCount'), ❸ tfma.MetricConfig( class_name='MeanSquaredError', ❹ threshold=tfma.MetricThreshold( ❺ value_threshold=tfma.GenericValueThreshold( upper_bound={'value': 200.0} ), change_threshold=tfma.GenericChangeThreshold( ❻ direction=tfma.MetricDirection.LOWER_IS_BETTER, absolute={'value': 1e-10} ) ) ) ] ) ], slicing_specs=[ ❼ tfma.SlicingSpec(), ❽ tfma.SlicingSpec(feature_keys=['month']) ❾ ])
❶ 定义一个包含标签特征名称的模型规范。
❷ 定义一个指标规范列表。
❸ 获取评估的示例数。
❹ 将均方误差定义为一项指标。
❺ 将阈值上限定义为一个检查。
❻ 将误差变化(与先前模型相比)定义为一个检查(即,误差越低越好)。
❼ 切片规范定义了在评估时数据需要如何分区。
❽ 在整个数据集上进行评估,不进行切片(即,空切片)。
❾ 在分区数据上进行评估,其中数据根据月份字段进行分区。
EvalConfig 相当复杂。让我们慢慢来。我们必须定义三件事:模型规范(作为 ModelSpec 对象)、指标规范(作为 MetricsSpec 对象列表)和切片规范(作为 SlicingSpec 对象列表)。ModelSpec 对象可用于定义以下内容:
- name—可用于在此步骤中标识模型的别名模型名称。
- model_type—标识模型类型的字符串。允许的值包括 tf_keras、tf_estimator、tf_lite 和 tf_js、tf_generic。对于像我们的 Keras 模型,类型会自动推导。
- signature_name—用于推断的模型签名。默认情况下使用 serving_default。
- label_key—示例中标签特征的名称。
- label_keys—对于多输出模型,使用标签键列表。
- example_weight_key—如果存在,则用于检索示例权重的可选键(或特征名称)。
有关 ModelSpec 对象的更多信息,请参阅mng.bz/M5wW
。在 MetricsSpec 对象中,可以设置以下属性:
- metrics—MetricConfig 对象的列表。每个 MetricConfig 对象将类名作为输入。您可以选择在 tfma.metrics.Metric(
mng.bz/aJ97
)或 tf.keras.metrics.Metric(mng.bz/gwmV
)命名空间中定义的任何类。
SlicingSpec 定义了评估期间数据需要如何进行分区。例如,对于时间序列问题,您需要查看模型在不同月份或天数上的表现。为此,SlicingSpec 是一个方便的配置。SlicingSpec 具有以下参数:
- feature_keys—可用于定义一个特征键,以便您可以根据其对数据进行分区。例如,对于特征键月份,它将通过选择具有特定月份的数据来为每个月份创建一个数据分区。如果未传递,它将返回整个数据集。
注意,如果没有提供,TFX 将使用您在管道最开始(即实施 CsvExampleGen 组件时)定义的评估集合。换句话说,所有指标都在数据集的评估集合上进行评估。接下来,它定义了两个评估通过的条件:
- 均方误差小于 200。
- 均方损失改善了 1e - 10。
如果对于一个新训练的模型满足下列两个条件,那么该模型将被标记为“通过”(即通过了测试)。
最后,我们定义了评估器(mng.bz/e7BQ
),它将接收一个模型并运行在 eval_config 中定义的评估检查。您可以通过为 examples、model、baseline_model 和 eval_config 参数传入值来定义一个 TFX 评估器。baseline_model 是由 Resolver 解析的:
from tfx.components import Evaluator evaluator = Evaluator( examples=example_gen.outputs['examples'], model=trainer.outputs['model'], baseline_model=model_resolver.outputs['model'], eval_config=eval_config) context.run(evaluator)
不幸的是,运行评估器不会提供您所需的结果。事实上,它会导致评估失败。在日志的底部,您将看到如下输出:
INFO:absl:Evaluation complete. Results written to ➥ .../pipeline/examples/forest_fires_pipeline/Evaluator/evaluation/14. INFO:absl:Checking validation results. INFO:absl:Blessing result False written to ➥ .../pipeline/examples/forest_fires_pipeline/Evaluator/blessing/14.
上面的输出表明,Blessing 结果为 False。鉴于它只显示了约为 150 的损失,而我们将阈值设为 200,为什么模型失败仍然是一个谜。要了解发生了什么,我们需要查看写入磁盘的结果。如果您在/ examples\forest_fires_pipeline\Evaluator目录中查看,您会看到像 validation、metrics 等文件。使用 tensorflow_model_analysis 库,这些文件可以提供宝贵的见解,帮助我们理解出了什么问题。tensorflow_model_analysis 库提供了几个方便的函数来加载存储在这些文件中的结果:
import tensorflow_model_analysis as tfma validation_path = os.path.join( evaluator.outputs['evaluation']._artifacts[0].uri, "validations" ) validation_res = tfma.load_validation_result(validation_path) print('='*20, " Output stored in validations file ", '='*20) print(validation_res) print("="*75)
运行结果为:
metric_validations_per_slice { slice_key { single_slice_keys { column: "month" bytes_value: "sep" } } failures { metric_key { name: "mean_squared_error" } metric_threshold { value_threshold { upper_bound { value: 200.0 } } } metric_value { double_value { value: 269.11712646484375 } } } } validation_details { slicing_details { slicing_spec { } num_matching_slices: 12 } }
您可以清楚地看到发生了什么。它指出,为月份"sep"创建的切片导致了 269 的错误,这就是为什么我们的评估失败了。如果您想要关于所有使用的切片及其结果的详细信息,您可以检查指标文件:
metrics_path = os.path.join( evaluator.outputs['evaluation']._artifacts[0].uri, "metrics" ) metrics_res = tfma.load_metrics(metrics_path) print('='*20, " Output stored in metrics file ", '='*20) for r in metrics_res: print(r) print('-'*75) print("="*75)
运行结果为以下内容。为了节省空间,这里只显示了完整输出的一小部分:
slice_key { single_slice_keys { column: "month" bytes_value: "sep" } } metric_keys_and_values { key { name: "loss" } value { double_value { value: 269.11712646484375 } } } metric_keys_and_values { key { name: "mean_squared_error" } value { double_value { value: 269.11712646484375 } } } metric_keys_and_values { key { name: "example_count" } value { double_value { value: 52.0 } } } --------------------------------------------------------------------------- slice_key { } metric_keys_and_values { key { name: "loss" } value { double_value { value: 160.19691467285156 } } } metric_keys_and_values { key { name: "mean_squared_error" } value { double_value { value: 160.19691467285156 } } } metric_keys_and_values { key { name: "example_count" } value { double_value { value: 153.0 } } } ...
这个输出让我们对发生了什么有了更多了解。由于我们将示例计数视为指标之一,我们可以看到每个切片中的示例数。例如,在五月份,评估集合中只有一个示例,这很可能是一个异常值。为了解决这个问题,我们将阈值提高到 300。一旦您这样做了,需要重新运行评估器,从评估器的日志中可以看到我们的模型已通过检查:
INFO:absl:Evaluation complete. Results written to ➥ .../pipeline/examples/forest_fires_pipeline/Evaluator/evaluation/15. INFO:absl:Checking validation results. INFO:absl:Blessing result True written to ➥ .../pipeline/examples/forest_fires_pipeline/Evaluator/blessing/15.
解决这个问题的最佳方法是确定为什么“sep” 月份给出的值如此之大,而其他月份与整体损失值持平或低于。确定问题后,我们应该确定纠正措施以更正此问题(例如,重新考虑异常值定义)。在此之后,我们将继续进行管道的下一部分。
15.4.4 推送最终模型
我们已经达到管道中的最后步骤。我们需要定义一个推送器。 推送器(mng.bz/pOZz
)负责将经过评估检查的认可模型(即通过的模型)推送到定义好的生产环境。 生产环境可以简单地是您文件系统中的本地位置:
from tfx.components import Pusher from tfx.proto import pusher_pb2 pusher = Pusher( model=trainer.outputs['model'], model_blessing=evaluator.outputs['blessing'], infra_blessing=infra_validator.outputs['blessing'], push_destination=pusher_pb2.PushDestination( filesystem=pusher_pb2.PushDestination.Filesystem( base_directory=os.path.join('forestfires-model-pushed')) ) ) context.run(pusher)
推送器接受以下元素作为参数:
- model—由 Trainer 组件返回的 Keras 模型
- model_blessing—Evaluator 组件的认可状态
- infra_blessing—InfraValidator 的认可状态
- push_destination—作为 PushDestination protobuf 消息推送的目标
如果这一步运行成功,您将在我们的管道根目录中的称为 forestfires-model-pushed 的目录中保存模型。
15.4.5 使用 TensorFlow serving API 进行预测
最后一步是从推送目的地检索模型,并基于我们下载的 TensorFlow 服务镜像启动 Docker 容器。 Docker 容器将提供一个 API,我们可以通过各种请求进行 ping 。
让我们更详细地看一下如何将 API 融入整体架构中(图 15.10)。 机器学习模型位于 API 的后面。 API 定义了各种 HTTP 端点,您可以通过 Python 或类似 curl 的包来 ping 这些端点。 这些端点将以 URL 的形式提供,并且可以在 URL 中期望参数或将数据嵌入请求体中。 API 通过服务器提供。 服务器公开了一个网络端口,客户端可以与服务器进行通信。 客户端可以使用格式<主机名>:<端口>/<端点>向服务器发送请求。 我们将更详细地讨论请求实际的样子。
图 15.10 模型如何与 API、TensorFlow 服务器和客户端交互
要启动容器,只需
- 打开一个终端
- 将 cd 移入 Ch15-TFX-for-MLOps-in-TF2/tfx 目录
- 运行 ./run_server.sh
接下来,在 Jupyter 笔记本中,我们将发送一个 HTTP POST 请求。 HTTP 请求有两种主要类型:GET 和 POST。 如果您对差异感兴趣,请参考侧边栏。 HTTP POST 请求是一个包含了可以请求的 URL 和头信息的请求,也包含了负载,这对 API 完成请求是必要的。 例如,如果我们正在击中与 serving_default 签名对应的 API 端点,我们必须发送一个输入以进行预测。
GET vs. POST 请求
GET 和 POST 是 HTTP 方法。HTTP 是一个定义客户端和服务器应该如何通信的协议。客户端将发送请求,服务器将在特定的网络端口上监听请求。客户端和服务器不一定需要是两台单独的机器。在我们的情况下,客户端和服务器都在同一台机器上。
每当您通过键入 URL 访问网站时,您都在向该特定网站发出请求。一个请求具有以下解剖结构(mng.bz/OowE
):
- 方法类型 — GET 或 POST
- 一个路径 — 到达您想要到达的服务器端点的 URL
- 一个主体 — 需要客户端完成请求的任何大型有效载荷(例如,用于机器学习预测服务的输入)
- 一个头部 — 服务器需要的附加信息(例如,发送主体中的数据类型)
主要区别在于 GET 用于请求数据,而 POST 请求用于将数据发送到服务器(可以选择返回某些内容)。GET 请求不会有请求主体,而 POST 请求会有请求主体。另一个区别是 GET 请求可以被缓存,而 POST 请求不会被缓存,这使得它们对于敏感数据更安全。您可以在mng.bz/YGZA
中了解更多信息。
我们将定义一个请求主体,其中包含我们要击中的签名名称以及我们要为其预测的输入。接下来,我们将使用 Python 中的 requests 库发送一个请求到我们的 TensorFlow 模型服务器(即 Docker 容器)。在此请求中,我们将定义要到达的 URL(由 TensorFlow 模型服务器自动生成)和要携带的有效载荷。如果请求成功,我们应该会得到一个有效的预测作为输出:
import base64 import json import requests req_body = { "signature_name": "serving_default", "instances": [ str(base64.b64encode( b"{\"X\": 7,\"Y\": ➥ 4,\"month\":\"oct\",\"day\":\"fri\",\"FFMC\":60,\"DMC\":30,\"DC\":200,\ ➥ "ISI\":9,\"temp\":30,\"RH\":50,\"wind\":10,\"rain\":0}]") ) ] } data = json.dumps(req_body) json_response = requests.post( 'http:/ /localhost:8501/v1/models/forest_fires_model:predict', data=data, headers={"content-type": "application/json"} ) predictions = json.loads(json_response.text)
我们首先要做的是用特定的请求主体定义一个请求。对请求主体的要求在www.tensorflow.org/tfx/serving/api_rest
中定义。它是一个键值对字典,应该有两个键:signature_name 和 instances。signature_name 定义要在模型中调用哪个签名,而 instances 将包含输入数据。请注意,我们不是直接传递原始形式的输入数据。相反,我们使用 base64 编码。它将字节流(即二进制输入)编码为 ASCII 文本字符串。您可以在mng.bz/1o4g
中了解更多信息。您可以看到我们首先将字典转换为字节流(即 b"" 格式),然后在其上使用 base64 编码。如果您还记得我们之前讨论的编写模型服务函数(其中包含 signature def serve_tf_examples_fn(serialized_tf_examples))时,它期望一组序列化的示例。序列化是通过将数据转换为字节流来完成的。
当数据准备好后,我们使用 requests 库创建一个 POST 请求到 API。首先,我们定义一个头部,以表示我们传递的内容或载荷是 JSON 格式的。接下来,我们通过 requests.post() 方法发送一个 POST 请求,传递 URL,格式为 :/v1/models/:predict
,数据(即 JSON 载荷),和头部信息。这不是我们唯一可以使用的 API 端点。我们还有其他端点(www.tensorflow.org/tfx/serving/api_rest
)。主要有四个可用的端点:
- http:/ /:/v1/models/:predict — 使用模型和请求中传递的数据预测输出值。不需要提供给定输入的目标值。
- http:/ /:/v1/models/:regress — 用于回归问题。当输入和目标值都可用时使用(即可以计算误差)。
- http:/ /:/v1/models/:classify — 用于分类问题。当输入和目标值都可用时使用(即可以计算误差)。
- http:/ /:/v1/models//metadata — 提供有关可用端点/模型签名的元数据。
这将返回一些响应。如果请求成功,将包含响应;否则,会包含 HTTP 错误。您可以在 mng.bz/Pn2P
上看到各种 HTTP 状态/错误代码。在我们的情况下,我们应该得到类似于
{'predictions': [[2.77522683]]}
这意味着我们的模型已成功处理了输入并产生了有效的预测。我们可以看到,模型返回的预测值完全在我们在数据探索期间看到的可能值范围内。这结束了我们对 TensorFlow 扩展(TFX)的讨论。
练习 4
如何将多个输入发送到模型的 HTTP 请求中?假设您有以下两个输入,您想要使用模型进行预测。
Example 1 | Example 2 | |
X | 9 | 7 |
Y | 6 | 4 |
month | aug | aug |
day | fri | fri |
FFMC | 91 | 91 |
DMC | 248 | 248 |
DC | 553 | 553 |
ISI | 6 | 6 |
temp | 20.5 | 20.5 |
RH | 58 | 20 |
wind | 3 | 0 |
rain | 0 | 0 |
要在 HTTP 请求中为该输入传递多个值,可以在 JSON 数据的实例列表中附加更多示例。
摘要
- MLOps 定义了一个工作流程,将自动化大部分步骤,从收集数据到交付对该数据进行训练的模型。
- 生产部署涉及部署一个带有健壮 API 的训练模型,使客户能够使用模型进行其设计目的的操作。该 API 提供几个 HTTP 端点,格式为客户端可以使用与服务器通信的 URL。
- 在 TFX 中,您将 MLOps 管道定义为一系列 TFX 组件。
- TFX 有组件用于加载数据(CsvExampleGen)、生成基本统计信息和可视化(StatisticsGen)、推断模式(SchemaGen)以及将原始列转换为特征(Transform)。
- 要通过 HTTP 请求提供 Keras 模型,需要签名。
- 签名定义输入和输出的数据格式,以及通过 TensorFlow 函数(例如,用@tf.function 装饰的函数)生成输出所需的步骤。
- Docker 是一种容器化技术,可以将一个软件单元封装为一个单一容器,并可以在不同环境(或计算机)之间轻松移植。
- Docker 在容器中运行一个软件单元。
- TFX 为验证基础设施和模型提供了验证组件。TFX 可以启动一个容器并确保它按预期运行,还可以确保模型通过各种评估标准(例如,损失小于阈值),从而确保高质量的模型。
- 一旦模型被推送到生产环境,我们会启动一个 Docker 容器(基于 TensorFlow 服务镜像),将模型装入容器并通过 API 提供服务。我们可以发出 HTTP 请求(嵌入输入),以生成预测。
练习答案
练习 1
outputs = {} # Treating dense features outputs[_transformed_name('DC')] = tft.scale_to_0_1( sparse_to_dense(inputs['DC']) ) # Treating bucketized features outputs[_transformed_name('temp')] = tft.apply_buckets( sparse_to_dense(inputs['temp']), bucket_boundaries=[(20, 30)])
练习 2
categorical_columns = [ tf.feature_column.embedding_column( tf.feature_column.categorical_column_with_identity( key, num_buckets=num_buckets, default_value=0 ), dimension=32 ) for key, num_buckets in zip( _transformed_names(_VOCAB_FEATURE_KEYS), _MAX_CATEGORICAL_FEATURE_VALUES )
练习 3
docker run -v /tmp/inputs:/data -p 5000:5000 tensorflow/tensorflow:2.5.0
练习 4
req_body = { "signature_name": "serving_default", "instances": [ str(base64.b64encode( b"{\"X\": 9,\"Y\": ➥ 6,\"month\":\"aug\",\"day\":\"fri\",\"FFMC\":91,\"DMC\":248,\"DC\":553, ➥ \"ISI\":6,\"temp\":20.5,\"RH\":58,\"wind\":3,\"rain\":0}]") ), str(base64.b64encode( b"{\"X\": 7,\"Y\": ➥ 4,\"month\":\"aug\",\"day\":\"fri\",\"FFMC\":91,\"DMC\":248,\"DC\":553, ➥ \"ISI\":6,\"temp\":20.5,\"RH\":20,\"wind\":0,\"rain\":0}]") ), ] }